feat(cms): improve catalog, editorial recovery and operations
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 2m10s
CI / publish-container (push) Failing after 1m18s

This commit is contained in:
Simo committed 2026-09-09 19:36:15 +02:00
1 parent 2fead134e6
commit c389c3893d
122 files changed
+8029 -595

No files matched your search

+7 -1
View File
@@ -18,7 +18,8 @@ prod.log
db_backup_*.sql db_backup_*.sql
# Local project documentation # Local project documentation
/docs/ /docs/*
!/docs/cms-upgrade-2026-09.md
# Local gitea binary symlink # Local gitea binary symlink
gitea gitea
@@ -39,3 +40,8 @@ coverage/
# Shared deployment lock (never application source) # Shared deployment lock (never application source)
.deploy.lock .deploy.lock
# Browser verification artifacts
test-results/
playwright-report/
blob-report/
+1
View File
@@ -54,6 +54,7 @@ RUN apk add --no-cache tini curl \
COPY --from=builder --chown=nextjs:nextjs /app/public ./public COPY --from=builder --chown=nextjs:nextjs /app/public ./public
COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nextjs /app/drizzle/migrations ./drizzle/migrations
COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs COPY --chown=nextjs:nextjs scripts/docker-start.mjs ./docker-start.mjs
USER nextjs USER nextjs
EXPOSE 3002 EXPOSE 3002
+48
View File
@@ -0,0 +1,48 @@
# CMS upgrade — September 2026
## Operator changes
| Area | Behavior and location |
| --- | --- |
| Release | Deployment checks HTTP health, release identity and Chromium pages before marking the running image verified. Registry publication reuses that verified digest on the shared runner; independent hosts build and verify their own image. |
| Shared HK | Dialogs scroll within the available viewport. Table column preferences persist per page in the current browser session; filters already remain in the URL. No favorites added. |
| Catalog Studio | Dedicated detail drawer and five separate completeness states: SQL, offers, furnidata, icon and Nitro. Sprite/type conflicts are consistently excluded from import and linked to audit. Missing source files are never represented as available. |
| Operations | Command center includes permission-filtered error groups, personal import failures, open support tickets and news drafts. A failed source is shown separately from an empty source. |
| Error center | Retained occurrence counts, first/last times, identified users, release counts, self-assignment and recognized local links. Assignment requires edit permission and is audited. |
| News | Private server-backed autosave, recover/discard/retry, prior saved revisions restored as a draft, and concurrent-edit detection. New status/search filters and pagination make older drafts reachable. |
| Jobs | Cancellation finishes the current item and stops pending items. Completed work stays completed. Uncertain interrupted mutations are excluded from retry. Live lease checks stop known stale worker writes. |
| Installation | `/admin/devops/installation` shows release, DB latency, Redis, emulator, storage permissions, migration history and worker heartbeat. Renderer defaults are recognized. Registry access is explicitly unverified from the web process. |
| Public dashboard | Current/next published event, clearer unread-message action, useful empty/error states and mobile layout refinements. |
| Audit | Exact actor/action and UTC date filters, readable recorded before/after values and permission-protected CSV of the filtered page, capped at 100 rows. |
| Performance | Active import polling remains 5 seconds; idle polling is 30 seconds and pauses in hidden tabs. History returns at most 30 owned jobs and initially renders 50 items per job. Health probes are deduplicated within a render; diagnostics report observed probe duration. |
| Text | New messages are translated in English, Italian and Dutch. Other locales have explicit English fallback strings. Existing translation debt is not reported as resolved. |
## Deployment requirements
- Apply migration `0026_article_editor_recovery.sql` through `pnpm db:migrate` before enabling the new news editor. It adds private drafts and revision tables without modifying existing articles.
- Keep `storage` persistent and writable. Error assignments and import cancellation markers use the existing shared storage.
- Run the existing `pnpm jobs:worker` process with the installation configuration. It now publishes a heartbeat to Redis every minute. A web process alone does not establish that scheduled-news jobs are running.
- The deploy runner installs Chromium before cutover. Browser checks visit only public login/news/staff pages; they do not create production content or authenticate staff. The host must satisfy Chromium system-library requirements.
- Use the existing Gitea registry secrets. The CMS does not read or display those credentials.
## Boundaries
- Operations is a bounded operational summary: errors use at most 1,000 retained events and imports use the latest 30 owned jobs. Empty checked records do not prove that all historical work is resolved.
- Import history bounds payloads and concurrent file reads. Directory metadata scanning and worker enumeration still scale with stored history.
- Redis lease checks and file saves are separate operations. They reduce stale writes but do not provide atomic fencing across Redis, SQL and filesystem operations. An import interrupted after SQL may require local-data inspection.
- Revision history displays the latest 20 saved versions; revisions are retained in the database. New-article recovery has one private slot per staff account.
- The database connection probe is a measurement, not a performance benchmark. No throughput or latency improvement is claimed without production measurements.
- A rollback restores an application image; it does not reverse database migrations. The new tables are additive.
## Verification
Local verification on 2026-09-09:
- Production build succeeded with fixture configuration and an intentionally unavailable database. Build-time fallback logs are expected in this check.
- Full Vitest run: 254 files passed, 4 skipped; 1,466 tests passed, 6 skipped. Coverage thresholds passed (19.89% lines); this does not imply exhaustive coverage.
- Global Biome rules/import checks passed across 1,328 files; modified source/locales were formatted separately to avoid unrelated Windows line-ending changes.
- Translation audit: no invalid ICU messages, variable mismatches or missing static references. Existing locale gaps and 1,560 hardcoded-text candidates still need editorial work; they are not silently marked translated.
- Headless Edge verification of actual shared components with compiled CSS and the CMS theme at widths 1,280 and 390 pixels: the switch changes state and thumb position, the dialog stays within the 720px viewport, the final button is reachable, and the background page does not scroll. This isolated fixture does not establish full authenticated-page parity.
- Deployment rollback, verified-digest publication, ownership/cancellation and concurrent news edit behavior have focused regression tests.
Docker runtime, database migration execution and authenticated browser flows still require an integration environment. Check the Gitea pipeline and live release identifier after publication. A successful local build is not production verification.
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS website_article_drafts (
user_id BIGINT UNSIGNED NOT NULL,
article_key VARCHAR(32) NOT NULL,
version INT UNSIGNED NOT NULL,
payload LONGTEXT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, article_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS website_article_revisions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
article_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
payload LONGTEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX article_history (article_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
// Read-only production checks. Content mutation tests belong to a seeded staging DB.
test("login remains usable on a narrow viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 });
const response = await page.goto("/login");
expect(response?.ok()).toBe(true);
await expect(page.locator('input[type="password"]').first()).toBeVisible();
await expect(page.locator('button[type="submit"]').first()).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth + 1,
),
).toBe(true);
await expect(page.locator("body")).not.toContainText("Application error");
});
test("public news is rendered without a server error", async ({ page }) => {
const response = await page.goto("/news");
expect(response?.ok()).toBe(true);
await expect(page.locator("main").first()).toBeVisible();
await expect(page.locator("body")).not.toContainText("Application error");
});
test("staff page is available", async ({ page }) => {
const response = await page.goto("/staff");
expect(response?.ok()).toBe(true);
await expect(page.locator("main").first()).toBeVisible();
});
+3 -1
View File
@@ -35,7 +35,8 @@
"deps:audit": "pnpm audit --audit-level=high", "deps:audit": "pnpm audit --audit-level=high",
"analyze": "next experimental-analyze", "analyze": "next experimental-analyze",
"i18n:check": "node scripts/audit-cms-translations.mjs --check", "i18n:check": "node scripts/audit-cms-translations.mjs --check",
"i18n:audit": "node scripts/audit-cms-translations.mjs" "i18n:audit": "node scripts/audit-cms-translations.mjs",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "1.8.0", "@base-ui/react": "1.8.0",
@@ -80,6 +81,7 @@
"devDependencies": { "devDependencies": {
"@babel/parser": "7.29.8", "@babel/parser": "7.29.8",
"@biomejs/biome": "2.5.12", "@biomejs/biome": "2.5.12",
"@playwright/test": "1.62.1",
"@tailwindcss/forms": "0.5.11", "@tailwindcss/forms": "0.5.11",
"@tailwindcss/postcss": "4.3.3", "@tailwindcss/postcss": "4.3.3",
"@tailwindcss/typography": "0.5.20", "@tailwindcss/typography": "0.5.20",
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
retries: 1,
timeout: 30000,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3002",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+47 -8
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -93,6 +93,7 @@ for managed_name in epicnext-cms epicnext-cms-app; do
done done
cp "$deploy_dir/.env" .env cp "$deploy_dir/.env" .env
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm exec playwright install chromium
echo "Building $image" echo "Building $image"
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \ DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
@@ -159,8 +160,11 @@ candidate_attempted=1
) )
healthy healthy
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha" node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
# Publish the latest alias only after health and release checks pass. PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
docker tag "$image" epicnext-cms:latest # Publish the latest alias only after HTTP and browser checks pass.
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
docker tag "$verified_image" "epicnext-cms:verified-$sha"
docker tag "$verified_image" epicnext-cms:latest
cutover_started=0 cutover_started=0
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi
@@ -168,7 +172,7 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" ||
echo "Deployment verified: $sha" echo "Deployment verified: $sha"
# Retain the current and previous releases; do not remove arbitrary named tags. # Retain the current and previous releases; do not remove arbitrary named tags.
while IFS= read -r tag; do while IFS= read -r tag; do
if [[ "$tag" =~ ^epicnext-cms:[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ]; then if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)" tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
fi fi
+11
View File
@@ -260,6 +260,13 @@ async function publishScheduledArticles(): Promise<void> {
} }
} }
async function reportWorkerHeartbeat(): Promise<void> {
if (!redis) return;
await redis
.set("cms:jobs-worker:heartbeat", new Date().toISOString(), "EX", 600)
.catch((error) => captureWorkerError(error, "WorkerHeartbeat"));
}
async function main() { async function main() {
new Cron("* * * * *", () => { new Cron("* * * * *", () => {
runCatalogExport().catch((e) => runCatalogExport().catch((e) =>
@@ -307,6 +314,10 @@ async function main() {
module: "jobs", module: "jobs",
}); });
new Cron("* * * * *", () => {
void reportWorkerHeartbeat();
});
await reportWorkerHeartbeat();
await Promise.all([ await Promise.all([
backupEmulatorJar(), backupEmulatorJar(),
cleanupOldLogs(), cleanupOldLogs(),
+11 -1
View File
@@ -21,7 +21,17 @@ trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT
git archive HEAD | tar -x -C "$context" git archive HEAD | tar -x -C "$context"
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
unset REGISTRY_TOKEN unset REGISTRY_TOKEN
docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context" # On the shared runner, publish the exact image already verified by deployment.
local_image="epicnext-cms:$sha"
local_revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$local_image" 2>/dev/null || true)"
local_id="$(docker image inspect --format '{{.Id}}' "$local_image" 2>/dev/null || true)"
verified_id="$(docker image inspect --format '{{.Id}}' "epicnext-cms:verified-$sha" 2>/dev/null || true)"
if [[ "$local_revision" = "$sha" && -n "$local_id" && "$local_id" = "$verified_id" ]]; then
docker tag "$verified_id" "$image"
echo "Reusing verified release image $local_image"
else
docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context"
fi
docker build --network=host --target migrations -t "$image-migrations" "$context" docker build --network=host --target migrations -t "$image-migrations" "$context"
node scripts/verify-portable-image.mjs "$image" "$sha" node scripts/verify-portable-image.mjs "$image" "$sha"
# Publish only after the same application image passed both runtime configurations. # Publish only after the same application image passed both runtime configurations.
+2 -24
View File
@@ -2,6 +2,7 @@
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createServer } from "node:http"; import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import { verifyRuntimeMetadata } from "./verify-runtime-metadata.mjs";
const [image, release] = process.argv.slice(2); const [image, release] = process.argv.slice(2);
if (!image || !/^[0-9a-f]{40}$/.test(release ?? "")) if (!image || !/^[0-9a-f]{40}$/.test(release ?? ""))
@@ -95,30 +96,7 @@ try {
await delay(1000); await delay(1000);
} }
if (!ready) throw new Error(`${hotel}: expected HTTP release not served`); if (!ready) throw new Error(`${hotel}: expected HTTP release not served`);
const page = await fetch(`${base}/login`, { await verifyRuntimeMetadata(base, hotel);
signal: AbortSignal.timeout(30000),
});
const html = await page.text();
if (
!page.ok ||
!html.includes(`Fixture ${hotel}`) ||
!html.includes(base)
)
throw new Error(
`${hotel}: hotel name/domain were not resolved at runtime`,
);
const manifest = await fetch(`${base}/manifest.webmanifest`, {
signal: AbortSignal.timeout(15000),
});
if ((await manifest.json()).name !== `Fixture ${hotel}`)
throw new Error(`${hotel}: manifest contains build-time settings`);
for (const path of ["/robots.txt", "/sitemap.xml"]) {
const response = await fetch(base + path, {
signal: AbortSignal.timeout(15000),
});
if (!response.ok || !(await response.text()).includes(base))
throw new Error(`${hotel}: ${path} contains build-time domain`);
}
const avatar = await fetch( const avatar = await fetch(
`${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`, `${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`,
{ signal: AbortSignal.timeout(15000) }, { signal: AbortSignal.timeout(15000) },
+19
View File
@@ -0,0 +1,19 @@
// Metadata handlers can resolve runtime settings without a working application DB.
export async function verifyRuntimeMetadata(base, hotel) {
const manifest = await fetch(`${base}/manifest.webmanifest`, {
signal: AbortSignal.timeout(15000),
});
if (!manifest.ok)
throw new Error(`${hotel}: manifest HTTP ${manifest.status}`);
if ((await manifest.json()).name !== `Fixture ${hotel}`)
throw new Error(`${hotel}: manifest contains build-time settings`);
for (const path of ["/robots.txt", "/sitemap.xml"]) {
const response = await fetch(base + path, {
signal: AbortSignal.timeout(15000),
});
if (!response.ok)
throw new Error(`${hotel}: ${path} HTTP ${response.status}`);
if (!(await response.text()).includes(base))
throw new Error(`${hotel}: ${path} contains build-time domain`);
}
}
+51 -8
View File
@@ -27,12 +27,16 @@ vi.mock("next/navigation", () => ({
throw Error(`redirect ${url}`); throw Error(`redirect ${url}`);
}, },
})); }));
vi.mock("@/lib/db", async () => ({ vi.mock("@/lib/db", async () => {
...(await import("@/db/schema")), const database = {
db: {
select: () => ({ select: () => ({
from: () => ({ from: () => ({
where: () => ({ limit: async () => state.rows.shift() ?? [] }), where: () => ({
limit: () => {
const result = Promise.resolve(state.rows.shift() ?? []);
return Object.assign(result, { for: () => result });
},
}),
}), }),
}), }),
insert: () => ({ values: state.insert }), insert: () => ({ values: state.insert }),
@@ -42,9 +46,13 @@ vi.mock("@/lib/db", async () => ({
return { where: async () => [{ affectedRows: 1 }] }; return { where: async () => [{ affectedRows: 1 }] };
}, },
}), }),
}, transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
})); fn(database),
};
return { ...(await import("@/db/schema")), db: database };
});
import { articleEditToken } from "@/lib/article-edit-token";
import { createArticle, updateArticle } from "./admin-articles"; import { createArticle, updateArticle } from "./admin-articles";
function form(extra: Record<string, string> = {}) { function form(extra: Record<string, string> = {}) {
@@ -89,11 +97,25 @@ describe("news saves", () => {
expect(state.invalidate).toHaveBeenCalledOnce(); expect(state.invalidate).toHaveBeenCalledOnce();
}); });
it("preserves the slug and clears old scheduling for immediate publication", async () => { it("preserves the slug and clears old scheduling for immediate publication", async () => {
state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]]; const existing = {
title: "Old",
slug: "test-news",
image: "",
shortStory: "",
fullStory: "",
status: "draft",
publishAt: null,
publishedAt: null,
};
state.rows = [[existing]];
expect( expect(
( (
await updateArticle( await updateArticle(
form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }), form({
id: "1",
baseToken: articleEditToken(existing),
publishAt: "2099-01-01T00:00:00Z",
}),
) )
).ok, ).ok,
).toBe(true); ).toBe(true);
@@ -106,6 +128,27 @@ describe("news saves", () => {
); );
expect(state.invalidate).toHaveBeenCalledOnce(); expect(state.invalidate).toHaveBeenCalledOnce();
}); });
it("rejects an outdated editor without inserting a revision or overwriting the article", async () => {
state.rows = [
[
{
title: "Changed by another editor",
slug: "test-news",
image: "",
shortStory: "",
fullStory: "",
status: "published",
publishAt: null,
},
],
];
expect(
await updateArticle(form({ id: "1", baseToken: "outdated" })),
).toEqual({ ok: false, error: "editConflict" });
expect(state.insert).not.toHaveBeenCalled();
expect(state.update).not.toHaveBeenCalled();
expect(state.invalidate).not.toHaveBeenCalled();
});
it("rejects invalid scheduled dates before writing", async () => { it("rejects invalid scheduled dates before writing", async () => {
expect( expect(
(await createArticle(form({ status: "scheduled", publishAt: "invalid" }))) (await createArticle(form({ status: "scheduled", publishAt: "invalid" })))
+40 -13
View File
@@ -4,7 +4,9 @@ import { and, eq, ne } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { articleEditToken } from "@/lib/article-edit-token";
import { import {
ArticleInputError, ArticleInputError,
type ArticleSaveResult, type ArticleSaveResult,
@@ -23,11 +25,15 @@ import { PERMS } from "@/lib/permissions";
import { invalidateNewsCache } from "@/lib/services/news-cache"; import { invalidateNewsCache } from "@/lib/services/news-cache";
import { notify } from "@/lib/services/webhook"; import { notify } from "@/lib/services/webhook";
async function uniqueSlug(title: string, excludeId?: bigint): Promise<string> { async function uniqueSlug(
title: string,
excludeId?: bigint,
connection: Pick<typeof db, "select"> = db,
): Promise<string> {
const base = slugify(title); const base = slugify(title);
let slug = base; let slug = base;
for (let n = 2; ; n++) { for (let n = 2; ; n++) {
const [existing] = await db const [existing] = await connection
.select({ id: WebsiteArticles.id }) .select({ id: WebsiteArticles.id })
.from(WebsiteArticles) .from(WebsiteArticles)
.where( .where(
@@ -105,28 +111,42 @@ export async function updateArticle(
if (!id) return { ok: false, error: t("articleNotFound") }; if (!id) return { ok: false, error: t("articleNotFound") };
let input: ReturnType<typeof readArticleInput>; let input: ReturnType<typeof readArticleInput>;
let becamePublished = false; let becamePublished = false;
let slug: string; let slug = "";
try { try {
input = readArticleInput(formData); input = readArticleInput(formData);
const [existing] = await db await db.transaction(async (tx) => {
.select({ const [existing] = await tx
slug: WebsiteArticles.slug, .select()
status: WebsiteArticles.status,
publishedAt: WebsiteArticles.publishedAt,
})
.from(WebsiteArticles) .from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id)) .where(eq(WebsiteArticles.id, id))
.limit(1); .limit(1)
if (!existing) return { ok: false, error: t("articleNotFound") }; .for("update");
if (!existing) throw new ArticleInputError("articleNotFound");
if (formData.get("baseToken") !== articleEditToken(existing))
throw new ArticleInputError("editConflict");
await tx.insert(ArticleRevisions).values({
articleId: id,
userId: staff.id,
payload: JSON.stringify({
title: existing.title,
slug: existing.slug,
image: existing.image,
shortStory: existing.shortStory,
fullStory: existing.fullStory,
status: existing.status,
publishAt: existing.publishAt?.toISOString() ?? "",
baseToken: "",
}),
});
const { rawSlug, ...fields } = input; const { rawSlug, ...fields } = input;
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug; const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
slug = slug =
requestedSlug === existing.slug requestedSlug === existing.slug
? existing.slug ? existing.slug
: await uniqueSlug(requestedSlug, id); : await uniqueSlug(requestedSlug, id, tx);
becamePublished = becamePublished =
fields.status === "published" && existing.status !== "published"; fields.status === "published" && existing.status !== "published";
await db await tx
.update(WebsiteArticles) .update(WebsiteArticles)
.set({ .set({
...fields, ...fields,
@@ -138,6 +158,7 @@ export async function updateArticle(
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(WebsiteArticles.id, id)); .where(eq(WebsiteArticles.id, id));
});
} catch (error) { } catch (error) {
return saveFailure(error, "update"); return saveFailure(error, "update");
} }
@@ -170,6 +191,12 @@ export async function deleteArticle(formData: FormData): Promise<void> {
await tx await tx
.delete(WebsiteArticleComments) .delete(WebsiteArticleComments)
.where(eq(WebsiteArticleComments.articleId, id)); .where(eq(WebsiteArticleComments.articleId, id));
await tx
.delete(ArticleRevisions)
.where(eq(ArticleRevisions.articleId, id));
await tx
.delete(ArticleDrafts)
.where(eq(ArticleDrafts.articleKey, String(id)));
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id)); await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
}); });
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
version: 0,
update: vi.fn(),
insert: vi.fn(),
permission: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } }));
vi.mock("@/lib/db", async () => {
const tx = {
insert: (table: unknown) => ({
values: (value: unknown) => {
state.insert(table, value);
return { onDuplicateKeyUpdate: async () => {} };
},
}),
select: () => ({
from: () => ({
where: () => ({ for: async () => [{ version: state.version }] }),
}),
}),
update: (table: unknown) => ({
set: (value: unknown) => {
state.update(table, value);
return { where: async () => {} };
},
}),
};
return {
...(await import("@/db/schema")),
db: { transaction: async (fn: (value: typeof tx) => unknown) => fn(tx) },
};
});
import { ArticleDrafts } from "@/db/article-editor";
import { autosaveArticle } from "./article-recovery";
function draft() {
const form = new FormData();
form.set("title", "Incomplete title");
form.set("status", "published");
form.set("fullStory", "Work in progress");
return form;
}
beforeEach(() => {
vi.clearAllMocks();
state.version = 0;
state.permission.mockResolvedValue({ id: 17 });
});
it("stores incomplete editor content only in a private draft table even when publication is selected", async () => {
expect(await autosaveArticle("new", 0, draft())).toEqual({
ok: true,
version: 1,
});
expect(state.permission).toHaveBeenCalledWith("news.edit");
expect(state.insert).toHaveBeenCalledWith(
ArticleDrafts,
expect.objectContaining({ userId: 17, articleKey: "new" }),
);
expect(state.update).toHaveBeenCalledOnce();
expect(state.update).toHaveBeenCalledWith(
ArticleDrafts,
expect.objectContaining({ version: 1 }),
);
});
it("does not replace a more recent draft from another tab", async () => {
state.version = 4;
expect(await autosaveArticle("new", 3, draft())).toEqual({ ok: false });
expect(state.update).not.toHaveBeenCalled();
});
it("does not access storage when the editor permission is denied", async () => {
state.permission.mockRejectedValue(new Error("denied"));
await expect(autosaveArticle("new", 0, draft())).rejects.toThrow("denied");
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects excessive article content before writing", async () => {
const form = draft();
form.set("fullStory", "x".repeat(500_001));
await expect(autosaveArticle("new", 0, form)).rejects.toThrow("draftInvalid");
expect(state.insert).not.toHaveBeenCalled();
});
File diff suppressed because it is too large. Load diff
+78
View File
@@ -247,3 +247,81 @@
background: var(--color-primary); background: var(--color-primary);
border-radius: 999px; border-radius: 999px;
} }
.shortcutHint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
font-weight: 400;
color: var(--color-text-muted);
}
.shortcut {
min-height: 64px;
text-align: center;
overflow-wrap: anywhere;
}
.shortcut:focus-visible,
.friend:focus-visible,
.room:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
}
.event {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.eventDetails {
min-width: 0;
flex: 1 1 220px;
overflow-wrap: anywhere;
}
.eventDetails h2 {
margin: 8px 0;
font-size: 1.15rem;
}
.eventDetails p {
margin: 0;
font-size: 0.85rem;
}
.eventPhase {
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
color: var(--color-text-readable);
background: color-mix(
in srgb,
var(--color-primary) 14%,
var(--color-surface)
);
font-size: 0.75rem;
font-weight: 700;
}
@media (max-width: 400px) {
.hero {
gap: 12px;
padding: 14px;
}
.avatar {
width: 64px;
height: 100px;
}
.identity {
min-width: 0;
}
.identity h1 {
font-size: 1.4rem;
}
.wallet {
gap: 8px;
}
.currency {
padding: 10px;
}
.event > a {
width: 100%;
text-align: center;
}
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -6,6 +6,7 @@ import { deleteArticle, updateArticle } from "@/actions/admin-articles";
import { ArticleForm } from "@/components/admin/article-form"; import { ArticleForm } from "@/components/admin/article-form";
import Link from "@/components/link"; import Link from "@/components/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { articleEditToken } from "@/lib/article-edit-token";
import { db, WebsiteArticles } from "@/lib/db"; import { db, WebsiteArticles } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -62,6 +63,8 @@ export default async function EditArticle({
</p> </p>
) : null} ) : null}
<ArticleForm <ArticleForm
articleKey={String(article.id)}
baseToken={articleEditToken(article)}
action={async (fd) => { action={async (fd) => {
"use server"; "use server";
fd.set("id", String(article.id)); fd.set("id", String(article.id));
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useTranslations } from "next-intl";
import { useFormStatus } from "react-dom";
import { Button } from "@/components/ui/button";
export function ArticleListSubmit() {
const { pending } = useFormStatus();
const t = useTranslations("pages.admin.articles");
return (
<Button type="submit" disabled={pending} aria-busy={pending}>
{pending ? t("listLoading") : t("listApply")}
</Button>
);
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -10,6 +10,7 @@ import {
OnlineUsersWidget, OnlineUsersWidget,
StatusCard, StatusCard,
} from "@/components/admin/dashboard"; } from "@/components/admin/dashboard";
import { OperationsInbox } from "@/components/admin/operations-inbox";
import Link from "@/components/link"; import Link from "@/components/link";
import { env } from "@/env"; import { env } from "@/env";
import { fetchOpsHealth } from "@/lib/admin/ops-health"; import { fetchOpsHealth } from "@/lib/admin/ops-health";
@@ -126,6 +127,7 @@ export default async function CommandoCentrum() {
</Link> </Link>
) : null} ) : null}
</div> </div>
<OperationsInbox />
{/* ── Live status ────────────────────────────────────────── */} {/* ── Live status ────────────────────────────────────────── */}
<div <div
className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6" className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"
@@ -1,14 +1,16 @@
import { expect, it, vi } from "vitest"; import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
guard: vi.fn(), guard: vi.fn(),
resolve: vi.fn(), resolve: vi.fn(),
assign: vi.fn(),
audit: vi.fn(), audit: vi.fn(),
})); }));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
vi.mock("@/lib/error-monitor", () => ({ vi.mock("@/lib/error-monitor", () => ({
ErrorStore: class { ErrorStore: class {
resolve = mocks.resolve; resolve = mocks.resolve;
assign = mocks.assign;
}, },
})); }));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
@@ -17,7 +19,7 @@ vi.mock("@/lib/permissions", () => ({
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit })); vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { resolveCmsError } from "./actions"; import { assignCmsError, resolveCmsError } from "./actions";
it("requires edit permission before resolving or auditing", async () => { it("requires edit permission before resolving or auditing", async () => {
mocks.guard.mockRejectedValue(new Error("denied")); mocks.guard.mockRejectedValue(new Error("denied"));
@@ -37,3 +39,33 @@ it("records who resolved the problem", async () => {
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }), expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
); );
}); });
beforeEach(() => vi.clearAllMocks());
it("requires edit permission before assigning", async () => {
mocks.guard.mockRejectedValue(new Error("denied"));
await expect(assignCmsError(new FormData())).rejects.toThrow("denied");
expect(mocks.assign).not.toHaveBeenCalled();
});
it("assigns only the authenticated staff member, ignoring submitted user IDs", async () => {
mocks.guard.mockResolvedValue({ id: 42 });
const data = new FormData();
data.set("fingerprint", "aabbccddeeff0011");
data.set("assignment", "self");
data.set("userId", "999");
await assignCmsError(data);
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
expect(mocks.assign).toHaveBeenCalledWith("aabbccddeeff0011", 42);
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, action: "cms.error.assign" }),
);
data.set("assignment", "clear");
await assignCmsError(data);
expect(mocks.assign).toHaveBeenLastCalledWith("aabbccddeeff0011", null);
});
it("rejects unsupported assignment commands", async () => {
mocks.guard.mockResolvedValue({ id: 42 });
const data = new FormData();
data.set("assignment", "other");
await expect(assignCmsError(data)).rejects.toThrow("Invalid assignment");
expect(mocks.assign).not.toHaveBeenCalled();
});
@@ -15,3 +15,20 @@ export async function resolveCmsError(data: FormData) {
}); });
revalidatePath("/admin/devops/cms-errors"); revalidatePath("/admin/devops/cms-errors");
} }
export async function assignCmsError(data: FormData) {
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
const mode = data.get("assignment");
if (mode !== "self" && mode !== "clear")
throw new Error("Invalid assignment operation");
const fingerprint = String(data.get("fingerprint") ?? "");
const userId = mode === "self" ? staff.id : null;
await new ErrorStore().assign(fingerprint, userId);
await logAudit({
userId: staff.id,
action: "cms.error.assign",
target: "cms-error",
after: { fingerprint, assignedUserId: userId },
});
revalidatePath("/admin/devops/cms-errors");
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,30 @@
import { expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ inspect: vi.fn(), access: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
getAdminContext: async () => ({
session: { user: { rank: 1 } },
permissions: {},
}),
canAccess: mocks.access,
PERMS: { DEVOPS_VIEW: "devops.view" },
}));
vi.mock("next/navigation", () => ({
redirect: () => {
throw Error("redirect");
},
}));
vi.mock("@/lib/admin/installation", () => ({
inspectInstallation: mocks.inspect,
}));
vi.mock("../../commandocentrum/refresh-status", () => ({
RefreshStatus: () => null,
}));
import Page from "./page";
it("rejects unauthorized access before checking database, filesystem or worker", async () => {
mocks.access.mockReturnValue(false);
await expect(Page()).rejects.toThrow("redirect");
expect(mocks.inspect).not.toHaveBeenCalled();
});
@@ -0,0 +1,50 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { inspectInstallation } from "@/lib/admin/installation";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { RefreshStatus } from "../../commandocentrum/refresh-status";
export default async function InstallationPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin");
const t = await getTranslations("pages.admin.installation");
const result = await inspectInstallation();
return (
<div className="space-y-5">
<h1 className="text-2xl font-semibold">{t("title")}</h1>
<p>{t("hint")}</p>
<p className="text-sm text-[var(--admin-text-muted)]">
{t("checked", {
time: formatDate(new Date(result.checkedAt), "datetime-seconds"),
ms: result.durationMs,
})}
</p>
<RefreshStatus label={t("recheck")} />
<div className="grid gap-3 md:grid-cols-2">
{result.rows.map((row) => (
<section key={row.key} className="admin-card space-y-2 min-w-0">
<h2 className="font-semibold">{t(row.key)}</h2>
<p
className={
row.state === "failed"
? "text-destructive"
: row.state === "ok"
? "text-[var(--admin-success)]"
: "text-[var(--admin-text-muted)]"
}
>
{t(row.state)}
</p>
{row.detail ? (
<code className="block break-all text-xs">{row.detail}</code>
) : null}
<p className="text-sm text-[var(--admin-text-muted)]">
{t(`${row.key}Hint`)}
</p>
</section>
))}
</div>
</div>
);
}
+4
View File
@@ -53,12 +53,16 @@ export default async function DevOpsPage() {
const t = await getTranslations("pages.admin.devops"); const t = await getTranslations("pages.admin.devops");
const data = await getDevOpsData(); const data = await getDevOpsData();
const installation = await getTranslations("pages.admin.installation");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<a href="/admin/devops/cms-errors" className="btn btn-outline"> <a href="/admin/devops/cms-errors" className="btn btn-outline">
{t("cmsErrors")} {t("cmsErrors")}
</a> </a>
<Link href="/admin/devops/installation" className="btn btn-outline">
{installation("title")}
</Link>
{/* Status cards */} {/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card> <Card>
@@ -0,0 +1,68 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { type FormEvent, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const keys = ["actor", "action", "from", "to"] as const;
export function AuditFilters() {
const t = useTranslations("pages.admin.logs.audit");
const params = useSearchParams();
const router = useRouter();
const [pending, startTransition] = useTransition();
function apply(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const values = new FormData(event.currentTarget);
const next = new URLSearchParams(params.toString());
for (const key of keys) {
const value = String(values.get(key) ?? "").trim();
if (value) next.set(key, value);
else next.delete(key);
}
next.set("page", "1");
startTransition(() => router.replace(`?${next}`, { scroll: false }));
}
function clear() {
const next = new URLSearchParams(params.toString());
for (const key of keys) next.delete(key);
next.set("page", "1");
startTransition(() => router.replace(`?${next}`, { scroll: false }));
}
return (
<form onSubmit={apply} key={params.toString()} aria-busy={pending}>
<fieldset disabled={pending} className="space-y-3 rounded-xl border p-4">
<legend className="px-1 text-sm font-medium">
{t("filtersTitle")}
</legend>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{keys.map((key) => (
<label
htmlFor={`audit-${key}`}
key={key}
className="space-y-1 text-sm"
>
<span>{t(`filter${key[0].toUpperCase()}${key.slice(1)}`)}</span>
<Input
id={`audit-${key}`}
name={key}
type={key === "from" || key === "to" ? "date" : "text"}
maxLength={191}
defaultValue={params.get(key) ?? ""}
/>
</label>
))}
</div>
<p className="text-xs text-muted-foreground">{t("filtersHint")}</p>
<div className="flex flex-wrap gap-2">
<Button type="submit" size="sm">
{t("applyFilters")}
</Button>
<Button type="button" variant="outline" size="sm" onClick={clear}>
{t("resetFilters")}
</Button>
</div>
</fieldset>
</form>
);
}
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -21,7 +21,15 @@ export default async function AuditLogsPage({
const sp = new URLSearchParams(params); const sp = new URLSearchParams(params);
const { search, perPage, page } = parseListParams(sp); const { search, perPage, page } = parseListParams(sp);
const result = await getAuditLogs({ search, page, perPage }); const result = await getAuditLogs({
search,
page,
perPage,
actor: sp.get("actor") ?? undefined,
action: sp.get("action") ?? undefined,
from: sp.get("from") ?? undefined,
to: sp.get("to") ?? undefined,
});
const pagination = calcPagination(result.total, result.page, result.perPage); const pagination = calcPagination(result.total, result.page, result.perPage);
@@ -32,6 +40,8 @@ export default async function AuditLogsPage({
target: r.target, target: r.target,
targetId: r.targetId, targetId: r.targetId,
diff: r.diff, diff: r.diff,
before: r.before,
after: r.after,
createdAt: r.createdAt, createdAt: r.createdAt,
})); }));
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
options: {} as Record<string, unknown>,
rows: [
{
id: 1,
userId: 2,
username: '=HYPERLINK("bad")',
action: "update",
target: "user",
targetId: 3,
createdAt: "2026-09-09",
diff: '{"password":{"from":"oldsecret","to":"newsecret"}}',
before: null,
after: null,
},
],
}));
vi.mock("@/lib/api-handler", () => ({
withAdmin: (options: Record<string, unknown>, handler: unknown) => {
state.options = options;
return handler;
},
}));
vi.mock("@/lib/permissions", () => ({ PERMS: { LOGS_VIEW: "logs.view" } }));
vi.mock("@/lib/services/audit", () => ({
getAuditLogs: vi.fn(async () => ({ rows: state.rows })),
}));
import { getAuditLogs } from "@/lib/services/audit";
import { GET } from "./route";
describe("audit CSV", () => {
beforeEach(() => {
vi.mocked(getAuditLogs).mockClear();
});
it("requires the audit viewing permission and passes the same filters and page", async () => {
expect(state.options).toMatchObject({ permission: "logs.view" });
const response = await GET(
new Request(
"https://example.test/api/admin/logs/audit/export?actor=Alice&action=update&from=2026-09-09&to=2026-09-10&search=user&page=2&perPage=10",
) as never,
{} as never,
);
expect(getAuditLogs).toHaveBeenCalledWith({
actor: "Alice",
action: "update",
from: "2026-09-09",
to: "2026-09-10",
search: "user",
page: 2,
perPage: 10,
});
expect(response.headers.get("Cache-Control")).toBe("no-store");
});
it("escapes CSV formula cells and redacts historical secrets", async () => {
const response = await GET(
new Request("https://example.test/api/admin/logs/audit/export") as never,
{} as never,
);
const csv = await response.text();
expect(csv).toContain("'=HYPERLINK");
expect(csv).not.toContain("oldsecret");
expect(csv).not.toContain("newsecret");
expect(csv).toContain("[Redacted]");
});
});
@@ -0,0 +1,69 @@
import { parseListParams } from "@/lib/admin-helpers";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { getAuditLogs } from "@/lib/services/audit";
import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff";
function cell(value: unknown) {
const raw = value == null ? "" : String(value);
const safe = /^[\s]*[=+@-]/.test(raw) ? `'${raw}` : raw;
return `"${safe.replace(/"/g, '""')}"`;
}
export const GET = withAdmin(
{ permission: PERMS.LOGS_VIEW },
async (request) => {
const params = new URL(request.url).searchParams;
const { search, page, perPage } = parseListParams(params);
const result = await getAuditLogs({
search,
page,
perPage,
actor: params.get("actor") ?? undefined,
action: params.get("action") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
});
const rows: unknown[][] = [
[
"id",
"user_id",
"username",
"action",
"target",
"target_id",
"created_at",
"changes",
],
];
for (const row of result.rows) {
const details = readAuditChanges(row.diff, row.before, row.after);
rows.push([
row.id,
row.userId,
row.username,
row.action,
row.target,
row.targetId,
row.createdAt,
details.invalid
? "[Unavailable legacy details]"
: details.changes
.map(
(change) =>
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
)
.join("\n"),
]);
}
return new Response(
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
{
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="audit-page.csv"',
"Cache-Control": "no-store",
},
},
);
},
);
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
guard: vi.fn(), guard: vi.fn(),
create: vi.fn(), create: vi.fn(),
list: vi.fn(), list: vi.fn(),
cancel: vi.fn(),
after: vi.fn(), after: vi.fn(),
ping: vi.fn(), ping: vi.fn(),
source: vi.fn(), source: vi.fn(),
@@ -25,15 +26,17 @@ vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } }));
vi.mock("@/lib/services/furni-job-worker", () => ({ vi.mock("@/lib/services/furni-job-worker", () => ({
drainFurnitureImports: vi.fn(), drainFurnitureImports: vi.fn(),
})); }));
vi.mock("@/lib/services/furni-job-store", () => ({ vi.mock("@/lib/services/furni-job-store", async (original) => ({
...(await original<typeof import("@/lib/services/furni-job-store")>()),
ImportJobStore: class { ImportJobStore: class {
create = mocks.create; create = mocks.create;
list = mocks.list; list = mocks.list;
requestCancellation = mocks.cancel;
}, },
})); }));
vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source })); vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source }));
import { GET, POST } from "./route"; import { GET, PATCH, POST } from "./route";
const item = { const item = {
id: 1, id: 1,
@@ -106,11 +109,23 @@ it("rejects a missing configured source", async () => {
).status, ).status,
).toBe(404); ).toBe(404);
}); });
it("only returns the current operators history", async () => { it("requests bounded owner-scoped history from the store", async () => {
mocks.list.mockResolvedValue([ mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]);
{ id: "a", userId: 7 },
{ id: "b", userId: 9 },
]);
const response = await GET(request({}), ctx); const response = await GET(request({}), ctx);
expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 });
expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]); expect((await response.json()).jobs).toEqual([{ id: "a", userId: 7 }]);
}); });
it("requests cancellation with the authenticated owner", async () => {
const id = randomUUID();
mocks.cancel.mockResolvedValue({ id, cancelRequested: true });
const response = await PATCH(request({ id }), ctx);
expect(response.status).toBe(200);
expect(mocks.cancel).toHaveBeenCalledWith(id, 7);
});
it("does not reveal other owners' jobs", async () => {
mocks.cancel.mockResolvedValue(null);
expect((await PATCH(request({ id: randomUUID() }), ctx)).status).toBe(404);
});
it("rejects unsafe cancellation IDs", async () => {
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
});
+20 -5
View File
@@ -6,7 +6,7 @@ import { validateClassnames } from "@/lib/furni/studio-inspection";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
import { getSource } from "@/lib/services/clone-sources"; import { getSource } from "@/lib/services/clone-sources";
import { ImportJobStore } from "@/lib/services/furni-job-store"; import { ImportJobStore, validJobId } from "@/lib/services/furni-job-store";
import { drainFurnitureImports } from "@/lib/services/furni-job-worker"; import { drainFurnitureImports } from "@/lib/services/furni-job-worker";
const schema = z.object({ const schema = z.object({
@@ -38,10 +38,10 @@ export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
async (_request, ctx) => { async (_request, ctx) => {
after(drainFurnitureImports); after(drainFurnitureImports);
const jobs = (await new ImportJobStore().list()) const jobs = await new ImportJobStore().list({
.filter((job) => job.userId === ctx.session.user.id) userId: ctx.session.user.id,
.reverse() limit: 30,
.slice(0, 30); });
return apiOk({ jobs }); return apiOk({ jobs });
}, },
); );
@@ -82,3 +82,18 @@ export const POST = withAdmin(
return apiOk({ job }); return apiOk({ job });
}, },
); );
export const PATCH = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
const body = await request.json().catch(() => null);
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
const job = await new ImportJobStore().requestCancellation(
body.id,
ctx.session.user.id,
);
if (!job) return apiError("Import job not found", 404);
after(drainFurnitureImports);
return apiOk({ job });
},
);
+39 -2
View File
@@ -4,18 +4,24 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input"; import type { ArticleSaveResult } from "@/lib/article-input";
import { slugify } from "@/lib/format"; import { slugify } from "@/lib/format";
import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
import { useArticleRecovery } from "./article-recovery";
import { MediaPicker } from "./media-picker"; import { MediaPicker } from "./media-picker";
import { RichText } from "./rich-text"; import { RichText } from "./rich-text";
export function ArticleForm({ export function ArticleForm({
action, action,
defaultValues, defaultValues,
articleKey = "new",
baseToken = "",
}: { }: {
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>; action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
edit?: boolean; edit?: boolean;
articleKey?: string;
baseToken?: string;
defaultValues?: { defaultValues?: {
title?: string; title?: string;
slug?: string; slug?: string;
@@ -55,6 +61,31 @@ export function ArticleForm({
}, [defaultValues?.publishAt]); }, [defaultValues?.publishAt]);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const [token, setToken] = useState(baseToken);
const [summary, setSummary] = useState(defaultValues?.shortStory ?? "");
const [body, setBody] = useState(defaultValues?.fullStory ?? "");
const [bodyVersion, setBodyVersion] = useState(0);
function restoreDraft(draft: ArticleDraft, revision: boolean) {
setTitle(draft.title);
setSlug(draft.slug);
setSlugTouched(true);
setImage(draft.image);
setImageError(false);
setSummary(draft.shortStory);
setBody(draft.fullStory);
setBodyVersion((v) => v + 1);
setStatus("draft");
setPublishAt("");
setToken(revision ? baseToken : draft.baseToken);
markDirty();
}
const recovery = useArticleRecovery(
articleKey,
formRef,
saving,
dirty,
restoreDraft,
);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null); const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]); const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -76,12 +107,14 @@ export function ArticleForm({
setSaveError(null); setSaveError(null);
startTransition(async () => { startTransition(async () => {
try { try {
await recovery.wait();
const result = await action(data); const result = await action(data);
if (result && !result.ok) { if (result && !result.ok) {
setSaveError(result.error); setSaveError(result.error);
return; return;
} }
if (editVersion.current === submittedVersion) setDirty(false); if (editVersion.current === submittedVersion) setDirty(false);
if (result?.ok) await recovery.clear().catch(() => {});
if (result?.ok && result.data?.redirectTo) if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo); router.push(result.data.redirectTo);
} catch (error) { } catch (error) {
@@ -104,6 +137,8 @@ export function ArticleForm({
style={{ display: "grid", gap: "1rem" }} style={{ display: "grid", gap: "1rem" }}
> >
<fieldset disabled={pending} className="contents"> <fieldset disabled={pending} className="contents">
<input type="hidden" name="baseToken" value={token} />
{recovery.panel}
{saveError && ( {saveError && (
<p role="alert" className="text-sm"> <p role="alert" className="text-sm">
{saveError} {saveError}
@@ -193,7 +228,8 @@ export function ArticleForm({
<textarea <textarea
name="shortStory" name="shortStory"
maxLength={255} maxLength={255}
defaultValue={defaultValues?.shortStory ?? ""} value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder={t("bodyPlaceholder")} placeholder={t("bodyPlaceholder")}
rows={2} rows={2}
className="input input-bordered mt-1 w-full resize-y" className="input input-bordered mt-1 w-full resize-y"
@@ -205,9 +241,10 @@ export function ArticleForm({
{t("body")} {t("body")}
</span> </span>
<RichText <RichText
key={bodyVersion}
onChange={markDirty} onChange={markDirty}
name="fullStory" name="fullStory"
defaultValue={defaultValues?.fullStory ?? ""} defaultValue={body}
placeholder={t("bodyPlaceholder")} placeholder={t("bodyPlaceholder")}
rows={12} rows={12}
/> />
File diff suppressed because it is too large. Load diff
+36 -1
View File
@@ -106,7 +106,42 @@ export function DataTable<T extends { id: number | string }>({
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const bulkLock = useRef(false); const bulkLock = useRef(false);
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set()); const [columnPreference, setColumnPreference] = useState<{
path: string;
hidden: Set<string>;
}>(() => ({ path: pathname, hidden: new Set() }));
const hiddenColumns =
columnPreference.path === pathname &&
!columns.every((column) => columnPreference.hidden.has(column.key))
? columnPreference.hidden
: new Set<string>();
useEffect(() => {
try {
const raw: unknown = JSON.parse(
sessionStorage.getItem(`cms-table-columns:${pathname}`) ?? "[]",
);
const hidden = Array.isArray(raw)
? raw.filter((key): key is string => typeof key === "string")
: [];
setColumnPreference({ path: pathname, hidden: new Set(hidden) });
} catch {
setColumnPreference({ path: pathname, hidden: new Set() });
}
}, [pathname]);
function setHiddenColumns(update: (previous: Set<string>) => Set<string>) {
const next = update(hiddenColumns);
// Leave at least one data column available, even after a stale saved preference.
if (columns.every((column) => next.has(column.key))) return;
setColumnPreference({ path: pathname, hidden: next });
try {
sessionStorage.setItem(
`cms-table-columns:${pathname}`,
JSON.stringify([...next]),
);
} catch {
/* Storage can be disabled by the browser. */
}
}
const currentSort = searchParams.get("sort") || ""; const currentSort = searchParams.get("sort") || "";
const currentOrder = searchParams.get("order") || "desc"; const currentOrder = searchParams.get("order") || "desc";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,51 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import { FurnitureCompleteness } from "./furniture-completeness";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
const empty: FurnitureInspection = {
classname: "chair",
sql: [],
catalog: [],
furnidata: [],
furnidataReadable: true,
nitro: { exists: false, bytes: 0 },
icon: { exists: false, bytes: 0 },
};
describe("completeness panel", () => {
it("renders five separately named parts with useful missing-data guidance", () => {
const html = renderToStaticMarkup(<FurnitureCompleteness local={empty} />);
for (const part of ["sql", "catalog", "furnidata", "icon", "nitro"]) {
expect(html).toContain(`parts.${part}`);
expect(html).toContain(`actions.${part}`);
}
expect(html).not.toContain('href="/admin/studio/audit"');
});
it("gives conflicts an accessible action to open the real audit page", () => {
const local = {
...empty,
sql: [
{ id: 1, spriteId: 1, type: "s", name: "Chair" },
{ id: 2, spriteId: 2, type: "s", name: "Chair" },
],
};
const html = renderToStaticMarkup(<FurnitureCompleteness local={local} />);
expect(html).toContain('role="alert"');
expect(html).toContain('href="/admin/studio/audit"');
expect(html).toContain("actions.audit");
});
it("renders unavailable checks as unverified with a recheck instruction", () => {
const html = renderToStaticMarkup(
<FurnitureCompleteness
local={{
...empty,
furnidataReadable: false,
icon: { exists: null, bytes: 0 },
}}
/>,
);
expect(html.match(/statuses.unknown/g)).toHaveLength(2);
expect(html.match(/actions.recheck/g)).toHaveLength(2);
});
});
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { furnitureCompleteness } from "@/lib/furni/studio-completeness";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
export function FurnitureCompleteness({
local,
}: {
local: FurnitureInspection;
}) {
const t = useTranslations("admin.studio.completeness");
const parts = furnitureCompleteness(local);
return (
<section aria-label={t("title")} className="space-y-3">
<h3 className="font-semibold">{t("title")}</h3>
<dl className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{parts.map((part) => (
<div
key={part.id}
className="rounded-md border border-[var(--admin-border)] p-3"
>
<dt className="text-xs text-[var(--admin-text-muted)]">
{t(`parts.${part.id}`)}
</dt>
<dd className="mt-1 space-y-1">
<p
className={
part.status === "present"
? "font-medium text-[var(--admin-success)]"
: part.status === "unknown"
? "font-medium text-[var(--admin-text-muted)]"
: "font-medium text-[var(--admin-warning)]"
}
>
{t(`statuses.${part.status}`)}
</p>
{part.action && (
<p className="text-xs text-[var(--admin-text-muted)]">
{t(`actions.${part.action}`)}
</p>
)}
</dd>
</div>
))}
</dl>
{parts.some((part) => part.status === "conflict") && (
<p role="alert" className="text-[var(--admin-warning)]">
<Link
href="/admin/studio/audit"
className="font-medium underline underline-offset-4"
>
{t("openAudit")}
</Link>
</p>
)}
</section>
);
}
File diff suppressed because it is too large. Load diff
@@ -5,11 +5,10 @@ import {
type FurnitureInspection, type FurnitureInspection,
furnitureHealth, furnitureHealth,
} from "@/lib/furni/studio-inspection"; } from "@/lib/furni/studio-inspection";
import { FurnitureCompleteness } from "./furniture-completeness";
import type { FurniItem } from "./studio-types"; import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection"; import { useFurnitureInspection } from "./use-furniture-inspection";
const status = (value: boolean | null | undefined) =>
value == null ? "Not verified" : value ? "Present" : "Missing";
export function FurnitureComparison({ export function FurnitureComparison({
item, item,
local, local,
@@ -20,42 +19,7 @@ export function FurnitureComparison({
const comparison = compareFurniture(item, local); const comparison = compareFurniture(item, local);
return ( return (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<dl className="grid grid-cols-2 gap-2"> <FurnitureCompleteness local={local} />
{(
[
[".nitro", local.nitro.exists],
["Local icon", local.icon.exists],
[
"Furnidata",
local.furnidataReadable ? local.furnidata.length > 0 : null,
],
["SQL item", local.sql.length > 0],
["Catalog offer", local.catalog.length > 0],
] as const
).map(([label, present]) => (
<div
key={label}
className="rounded-md border border-[var(--admin-border)] p-2"
>
<dt className="text-xs text-[var(--admin-text-muted)]">{label}</dt>
<dd
className={
present
? "text-[var(--admin-success)]"
: "text-[var(--admin-warning)]"
}
>
{status(present)}
</dd>
</div>
))}
</dl>
{comparison.state === "conflict" && (
<p role="alert" className="text-[var(--admin-warning)]">
Multiple local records use this classname. Review them in Catalog
audit before importing.
</p>
)}
{!local.furnidataReadable && ( {!local.furnidataReadable && (
<p className="text-[var(--admin-warning)]"> <p className="text-[var(--admin-warning)]">
Local furnidata could not be read. Its values are not verified. Local furnidata could not be read. Its values are not verified.
+51 -2
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import Link from "next/link";
import { useFormatter, useTranslations } from "next-intl"; import { useFormatter, useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry"; import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs"; import type { useFurnitureJobs } from "./use-furniture-jobs";
@@ -10,6 +12,7 @@ export function FurnitureJobHistory({
}) { }) {
const t = useTranslations("pages.admin.importHistory"); const t = useTranslations("pages.admin.importHistory");
const format = useFormatter(); const format = useFormatter();
const [limits, setLimits] = useState<Record<string, number>>({});
return ( return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"> <details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer"> <summary className="cursor-pointer">
@@ -21,6 +24,12 @@ export function FurnitureJobHistory({
</summary> </summary>
<div className="max-h-72 space-y-2 overflow-auto py-2"> <div className="max-h-72 space-y-2 overflow-auto py-2">
{state.error && <p role="alert">{state.error}</p>} {state.error && <p role="alert">{state.error}</p>}
<p className="text-xs text-[var(--admin-text-muted)]">
{t("historyLimit")}
</p>
<Button variant="outline" onClick={() => void state.refresh()}>
{t("refreshHistory")}
</Button>
{!state.jobs.length && <p>{t("empty")}</p>} {!state.jobs.length && <p>{t("empty")}</p>}
{state.jobs.map((job) => { {state.jobs.map((job) => {
const retryItems = retryableJobItems(job); const retryItems = retryableJobItems(job);
@@ -50,7 +59,7 @@ export function FurnitureJobHistory({
aria-label={t("progress")} aria-label={t("progress")}
/> />
<ul className="space-y-1 py-2"> <ul className="space-y-1 py-2">
{job.items.map((item) => ( {job.items.slice(0, limits[job.id] ?? 50).map((item) => (
<li key={item.classname}> <li key={item.classname}>
<strong>{item.classname}</strong> · {t(item.state)} <strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""} {item.itemId ? ` · ID ${item.itemId}` : ""}
@@ -70,6 +79,46 @@ export function FurnitureJobHistory({
</li> </li>
))} ))}
</ul> </ul>
{job.items.length > (limits[job.id] ?? 50) && (
<Button
variant="outline"
onClick={() =>
setLimits((current) => ({
...current,
[job.id]: (current[job.id] ?? 50) + 50,
}))
}
>
{t("showMoreItems")}
</Button>
)}
{(job.state === "queued" || job.state === "running") && (
<div className="space-y-2 py-2">
<p className="text-xs text-[var(--admin-text-muted)]">
{t(job.cancelRequested ? "cancelRequested" : "cancelHint")}
</p>
<Button
variant="outline"
disabled={state.cancelling !== null || job.cancelRequested}
onClick={() => void state.cancel(job.id)}
>
{t("cancelRemaining")}
</Button>
</div>
)}
{job.items.some(
(item) =>
item.state === "interrupted" ||
item.error?.startsWith("Server restarted during import.") ||
(item.state === "running" && job.state === "interrupted"),
) && (
<p role="alert" className="py-2 text-[var(--admin-warning)]">
{t("uncertainOutcome")}{" "}
<Link href="/admin/studio/audit" className="underline">
{t("reviewLocalData")}
</Link>
</p>
)}
{retryItems.length > 0 && ( {retryItems.length > 0 && (
<Button <Button
variant="outline" variant="outline"
@@ -84,7 +133,7 @@ export function FurnitureJobHistory({
} }
> >
{t( {t(
job.state === "interrupted" job.state === "interrupted" || job.state === "cancelled"
? "retryUnfinished" ? "retryUnfinished"
: "retryFailed", : "retryFailed",
)} )}
@@ -1,5 +1,7 @@
"use client"; "use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -36,6 +38,7 @@ export function ImportReview({
busy?: boolean; busy?: boolean;
onConfirm: (items: FurniItem[]) => void; onConfirm: (items: FurniItem[]) => void;
}) { }) {
const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({}); const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null); const [uploading, setUploading] = useState<string | null>(null);
async function attach(item: FurniItem, file: File) { async function attach(item: FurniItem, file: File) {
@@ -186,6 +189,22 @@ export function ImportReview({
(complete missing data) · {blocked} conflicts ·{" "} (complete missing data) · {blocked} conflicts ·{" "}
{unavailable.length} source file warnings {unavailable.length} source file warnings
</p> </p>
{blocked > 0 && (
<div
role="alert"
className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-3 text-sm"
>
<p>
{completenessT("excludedConflicts", { count: blocked })}
</p>
<Link
href="/admin/studio/audit"
className="font-medium underline underline-offset-4"
>
{completenessT("openAudit")}
</Link>
</div>
)}
{inspection.items.some( {inspection.items.some(
(item) => (item) =>
!item.furnidataReadable || !item.furnidataReadable ||
File diff suppressed because it is too large. Load diff
@@ -1,10 +1,14 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useVisiblePolling } from "@/hooks/use-visible-polling"; import { useVisiblePolling } from "@/hooks/use-visible-polling";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job"; import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
export function useFurnitureJobs(onComplete: () => void) { export function useFurnitureJobs(onComplete: () => void) {
const t = useTranslations("pages.admin.importHistory");
const [cancelling, setCancelling] = useState<string | null>(null);
const cancelInFlight = useRef(false);
const [jobs, setJobs] = useState<ImportJob[]>([]), const [jobs, setJobs] = useState<ImportJob[]>([]),
[busy, setBusy] = useState(false), [busy, setBusy] = useState(false),
[error, setError] = useState(""); [error, setError] = useState("");
@@ -29,7 +33,9 @@ export function useFurnitureJobs(onComplete: () => void) {
let changed = false; let changed = false;
for (const job of data.jobs as ImportJob[]) for (const job of data.jobs as ImportJob[])
if ( if (
(job.state === "completed" || job.state === "interrupted") && (job.state === "completed" ||
job.state === "interrupted" ||
job.state === "cancelled") &&
!completed.current.has(job.id) !completed.current.has(job.id)
) { ) {
completed.current.add(job.id); completed.current.add(job.id);
@@ -44,7 +50,33 @@ export function useFurnitureJobs(onComplete: () => void) {
refreshing.current = false; refreshing.current = false;
} }
}, []); }, []);
useVisiblePolling(refresh, 5000); const active = jobs.some(
(job) => job.state === "queued" || job.state === "running",
);
useVisiblePolling(refresh, active ? 5000 : 30000);
const cancel = async (id: string) => {
if (cancelInFlight.current) return;
cancelInFlight.current = true;
setCancelling(id);
try {
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
const data = await response.json();
if (!response.ok) throw Error(data.error || t("cancelFailed"));
setJobs((current) =>
current.map((job) => (job.id === id ? data.job : job)),
);
await refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
} finally {
cancelInFlight.current = false;
setCancelling(null);
}
};
const submit = async ( const submit = async (
items: ImportJobItem[], items: ImportJobItem[],
options: { options: {
@@ -88,5 +120,5 @@ export function useFurnitureJobs(onComplete: () => void) {
setBusy(false); setBusy(false);
} }
}; };
return { jobs, busy, error, submit, refresh }; return { jobs, busy, error, submit, refresh, cancel, cancelling };
} }
+1 -1
View File
@@ -66,7 +66,7 @@ function DialogContent({
<DialogPrimitive.Popup <DialogPrimitive.Popup
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className, className,
)} )}
{...props} {...props}
+30
View File
@@ -0,0 +1,30 @@
import {
bigint,
int,
longtext,
mysqlTable,
primaryKey,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
export const ArticleDrafts = mysqlTable(
"website_article_drafts",
{
userId: bigint("user_id", { mode: "number", unsigned: true }).notNull(),
articleKey: varchar("article_key", { length: 32 }).notNull(),
version: int("version", { unsigned: true }).notNull(),
payload: longtext("payload").notNull(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
},
(table) => [primaryKey({ columns: [table.userId, table.articleKey] })],
);
export const ArticleRevisions = mysqlTable("website_article_revisions", {
id: bigint("id", { mode: "number", unsigned: true })
.autoincrement()
.primaryKey(),
articleId: bigint("article_id", { mode: "bigint", unsigned: true }).notNull(),
userId: bigint("user_id", { mode: "number", unsigned: true }).notNull(),
payload: longtext("payload").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
@@ -605,15 +605,15 @@ describe("housekeeping foundation completion contracts", () => {
expect(html).toContain(`>${sentinel}</button>`); expect(html).toContain(`>${sentinel}</button>`);
}); });
it("validates the complete 137-row migration matrix without issues", () => { it("validates the complete 139-row migration matrix without issues", () => {
const discovered = discoverLegacyPages(); const discovered = discoverLegacyPages();
const issues = validateMigrationEntries( const issues = validateMigrationEntries(
discovered, discovered,
HOUSEKEEPING_MIGRATION_MATRIX, HOUSEKEEPING_MIGRATION_MATRIX,
); );
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138); expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
expect(discovered).toHaveLength(138); expect(discovered).toHaveLength(139);
expect(issues).toEqual([]); expect(issues).toEqual([]);
}); });
}); });
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => { it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages(); const pages = discoverLegacyPages();
expect(pages).toHaveLength(138); expect(pages).toHaveLength(139);
expect(pages).toContainEqual({ expect(pages).toContainEqual({
surface: "admin", surface: "admin",
legacyPath: "/admin/users/:id/edit", legacyPath: "/admin/users/:id/edit",
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix"; import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => { describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
it("covers all 138 legacy pages exactly once", () => { it("covers all 139 legacy pages exactly once", () => {
const discovered = discoverLegacyPages(); const discovered = discoverLegacyPages();
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138); expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
expect( expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX), validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]); ).toEqual([]);
@@ -18,8 +18,8 @@ const SYSTEM_PREFIXES = [
] as const; ] as const;
describe("systemMigrationEntries", () => { describe("systemMigrationEntries", () => {
it("covers all 19 System pages exactly once", () => { it("covers all 20 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(19); expect(systemMigrationEntries).toHaveLength(20);
expect( expect(
validateMigrationEntries( validateMigrationEntries(
ownedLegacyPages(SYSTEM_PREFIXES), ownedLegacyPages(SYSTEM_PREFIXES),
@@ -21,6 +21,21 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
} }
export const systemMigrationEntries: readonly MigrationEntry[] = [ export const systemMigrationEntries: readonly MigrationEntry[] = [
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/devops/installation",
sourceFile: "src/app/admin/devops/installation/page.tsx",
targetPath: "/admin/system/observability/installation",
decision: "REBUILD",
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
dependencies: {
queries: ["inspectInstallation", "cms_migrations", "worker heartbeat"],
mutations: [],
},
auditRequirement: "NONE",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
// Operator alerts and read-only analytics. // Operator alerts and read-only analytics.
plannedSystemEntry({ plannedSystemEntry({
surface: "admin", surface: "admin",
+24
View File
@@ -0,0 +1,24 @@
import { expect, it } from "vitest";
import { migrationState, workerState } from "./installation-state";
it("distinguishes missing, malformed and stale worker evidence", () => {
const now = Date.parse("2026-09-09T12:00:00Z");
expect(workerState(null, now)).toBe("unknown");
expect(workerState("bad", now)).toBe("unknown");
expect(workerState("2026-09-09T11:59:30Z", now)).toBe("ok");
expect(workerState("2026-09-09T11:55:00Z", now)).toBe("failed");
});
it("never treats missing migration history as up to date", () => {
expect(migrationState(["0026"], null)).toEqual({
state: "unknown",
pending: null,
});
expect(migrationState(["0025", "0026"], ["0025"])).toEqual({
state: "failed",
pending: 1,
});
expect(migrationState(["0026"], ["0026"])).toEqual({
state: "ok",
pending: 0,
});
});
+20
View File
@@ -0,0 +1,20 @@
export type DiagnosticState = "ok" | "failed" | "unknown" | "missing";
export function workerState(
value: string | null,
now = Date.now(),
): DiagnosticState {
if (!value) return "unknown";
const time = Date.parse(value);
if (!Number.isFinite(time) || time > now + 60000) return "unknown";
return now - time <= 120000 ? "ok" : "failed";
}
export function migrationState(
expected: string[],
applied: string[] | null,
): { state: DiagnosticState; pending: number | null } {
if (!expected.length || applied === null)
return { state: "unknown", pending: null };
const done = new Set(applied);
const pending = expected.filter((name) => !done.has(name)).length;
return { state: pending ? "failed" : "ok", pending };
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -0,0 +1,35 @@
import { expect, it, vi } from "vitest";
import { collectOperations } from "./operations-inbox";
it("does not load unauthorized sources and reports failed sources separately", async () => {
const denied = vi.fn();
const result = await collectOperations([
{ source: "errors", allowed: false, load: denied },
{
source: "imports",
allowed: true,
load: async () => {
throw Error("offline");
},
},
]);
expect(denied).not.toHaveBeenCalled();
expect(result).toEqual({ items: [], unavailable: ["imports"] });
});
it("deduplicates, prioritizes and excludes empty work", async () => {
const item = {
id: "one",
source: "errors" as const,
href: "/admin/devops/cms-errors",
count: 2,
severity: 3,
};
const result = await collectOperations([
{
source: "errors",
allowed: true,
load: async () => [item, item, { ...item, id: "empty", count: 0 }],
},
]);
expect(result.items).toEqual([item]);
});
+42
View File
@@ -0,0 +1,42 @@
export type OperationsSource =
| "errors"
| "imports"
| "tickets"
| "publications";
export interface OperationsItem {
id: string;
source: OperationsSource;
href: string;
count: number;
severity: number;
}
export interface OperationsLoader {
source: OperationsSource;
allowed: boolean;
load: () => Promise<OperationsItem[]>;
}
/** Authorization precedes loading; a failed source is never reported as empty. */
export async function collectOperations(loaders: OperationsLoader[]) {
const available = loaders.filter((loader) => loader.allowed);
const results = await Promise.allSettled(
available.map((loader) => loader.load()),
);
const unique = new Map<string, OperationsItem>();
const unavailable: OperationsSource[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "rejected") {
unavailable.push(available[index].source);
continue;
}
for (const item of result.value) {
const key = `${item.source}:${item.id}`;
if (item.count > 0 && !unique.has(key)) unique.set(key, item);
}
}
return {
items: [...unique.values()]
.sort((a, b) => b.severity - a.severity || a.id.localeCompare(b.id))
.slice(0, 12),
unavailable,
};
}
+5 -2
View File
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { count, eq, sql } from "drizzle-orm"; import { count, eq, sql } from "drizzle-orm";
import { cache } from "react";
import { env } from "@/env"; import { env } from "@/env";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
@@ -19,7 +20,8 @@ export type OpsHealth = {
* Shared DB + Redis + emulator health probe for CommandoCentrum, DevOps, and APIs. * Shared DB + Redis + emulator health probe for CommandoCentrum, DevOps, and APIs.
* Semantics match `/api/health` for Redis (null when not configured). * Semantics match `/api/health` for Redis (null when not configured).
*/ */
export async function fetchOpsHealth(): Promise<OpsHealth> { export const fetchOpsHealth = cache(
async function fetchOpsHealth(): Promise<OpsHealth> {
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([ const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
(async () => { (async () => {
try { try {
@@ -55,4 +57,5 @@ export async function fetchOpsHealth(): Promise<OpsHealth> {
emulatorOk: Boolean(emulatorOk), emulatorOk: Boolean(emulatorOk),
onlineUsers, onlineUsers,
}; };
} },
);
+38
View File
@@ -0,0 +1,38 @@
import { expect, it } from "vitest";
import { articleKey, readArticleDraft } from "./article-draft";
import { articleEditToken } from "./article-edit-token";
it("allows incomplete drafts without requiring publication-ready fields", () => {
const form = new FormData();
form.set("status", "scheduled");
expect(readArticleDraft(form)).toMatchObject({
title: "",
fullStory: "",
publishAt: "",
status: "scheduled",
});
});
it("rejects unbounded or malformed recovery keys", () => {
for (const key of ["", "../1", "0", "-1", "1e5", "1".repeat(21)])
expect(() => articleKey(key)).toThrow();
expect(articleKey("new")).toBe("new");
expect(articleKey("123")).toBe("123");
});
it("detects body and scheduled instant changes even within the same clock second", () => {
const row = {
title: "News",
slug: "news",
image: "",
shortStory: "",
fullStory: "old",
status: "scheduled",
publishAt: new Date("2030-01-01T00:00:00Z"),
};
expect(articleEditToken(row)).not.toBe(
articleEditToken({ ...row, fullStory: "new" }),
);
expect(articleEditToken(row)).not.toBe(
articleEditToken({ ...row, publishAt: new Date("2030-01-01T01:00:00Z") }),
);
expect(articleEditToken(row)).toBe(articleEditToken({ ...row }));
});
+39
View File
@@ -0,0 +1,39 @@
export type ArticleDraft = {
title: string;
slug: string;
image: string;
shortStory: string;
fullStory: string;
status: string;
publishAt: string;
baseToken: string;
};
export function readArticleDraft(form: FormData): ArticleDraft {
const keys = [
"title",
"slug",
"image",
"shortStory",
"fullStory",
"status",
"publishAt",
"baseToken",
] as const;
const result = {} as ArticleDraft;
for (const key of keys) {
const value = form.get(key) ?? "";
if (
typeof value !== "string" ||
value.length > (key === "fullStory" ? 500_000 : 255)
)
throw new Error("draftInvalid");
result[key] = value;
}
if (!["draft", "scheduled", "published"].includes(result.status))
throw new Error("draftInvalid");
return result;
}
export function articleKey(value: string): string {
if (value === "new" || /^[1-9]\d{0,19}$/.test(value)) return value;
throw new Error("draftInvalid");
}
+24
View File
@@ -0,0 +1,24 @@
import { createHash } from "node:crypto";
export function articleEditToken(article: {
title: string;
slug: string;
image: string;
shortStory: string;
fullStory: string;
status: string;
publishAt: Date | null;
}) {
return createHash("sha256")
.update(
JSON.stringify([
article.title,
article.slug,
article.image,
article.shortStory,
article.fullStory,
article.status,
article.publishAt?.toISOString() ?? "",
]),
)
.digest("hex");
}
+2
View File
@@ -3,6 +3,8 @@ export type ArticleSaveResult = ActionResult<{ redirectTo: string }>;
export class ArticleInputError extends Error { export class ArticleInputError extends Error {
constructor( constructor(
public readonly code: public readonly code:
| "articleNotFound"
| "editConflict"
| "titleRequired" | "titleRequired"
| "titleTooLong" | "titleTooLong"
| "summaryTooLong" | "summaryTooLong"
+17 -2
View File
@@ -78,10 +78,22 @@ describe("deployment transaction", () => {
result.calls.indexOf("docker stop"), result.calls.indexOf("docker stop"),
); );
expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan( expect(result.calls.indexOf("verify-deployed-release.mjs")).toBeLessThan(
result.calls.indexOf("docker tag sha256:new epicnext-cms:latest"),
);
expect(result.calls.indexOf("pnpm test:e2e")).toBeLessThan(
result.calls.indexOf( result.calls.indexOf(
`docker tag epicnext-cms:${sha} epicnext-cms:latest`, `docker tag sha256:new epicnext-cms:verified-${sha}`,
), ),
); );
expect(result.calls).toContain(
`docker image rm epicnext-cms:verified-${"c".repeat(40)}`,
);
expect(result.calls).not.toContain(
`docker image rm epicnext-cms:verified-${"b".repeat(40)}`,
);
expect(result.calls).not.toContain(
`docker image rm epicnext-cms:verified-${sha}`,
);
expect(result.calls).toContain( expect(result.calls).toContain(
"docker tag sha256:old epicnext-cms:previous", "docker tag sha256:old epicnext-cms:previous",
); );
@@ -97,9 +109,12 @@ describe("deployment transaction", () => {
const result = simulate(scenario); const result = simulate(scenario);
expect(result.status).not.toBe(0); expect(result.status).not.toBe(0);
expect(result.app).toBe("old"); expect(result.app).toBe("old");
expect(result.calls).not.toContain(
`docker tag sha256:new epicnext-cms:verified-${sha}`,
);
expect(result.output).toContain("Rollback verified: sha256:old"); expect(result.output).toContain("Rollback verified: sha256:old");
expect(result.calls).not.toContain( expect(result.calls).not.toContain(
`docker tag epicnext-cms:${sha} epicnext-cms:latest`, "docker tag sha256:new epicnext-cms:latest",
); );
}, },
30000, 30000,
+60
View File
@@ -0,0 +1,60 @@
import { expect, it } from "vitest";
import { errorOperationLink, summarizeErrorGroup } from "./error-groups";
import { createErrorRecord } from "./error-monitor";
it("counts retained occurrences and identified users across releases without trusting browser identities", () => {
const base = createErrorRecord("server", "test", "failed", { userId: 12 });
const first = { ...base, at: "2026-09-08T10:00:00Z", release: "a" };
const latest = { ...base, at: "2026-09-09T10:00:00Z", release: "b" };
const browser = {
...latest,
source: "browser" as const,
context: { userId: 99 },
};
const summary = summarizeErrorGroup([
latest,
browser,
first,
{ ...latest, context: { userId: "12" } },
{ ...latest, context: { email: "redacted", userId: "[REDACTED]" } },
]);
expect(summary.count).toBe(5);
expect(summary.firstAt).toBe(first.at);
expect(summary.lastAt).toBe(latest.at);
expect(summary.affectedUsers).toBe(1);
expect(summary.unidentified).toBe(2);
expect(summary.releases.map((r) => [r.release, r.count])).toEqual([
["a", 1],
["b", 4],
]);
});
it("creates only recognized local operation links", () => {
const base = createErrorRecord("server", "test", "failed", {
module: "news",
articleId: 7,
});
expect(errorOperationLink(base)).toBe("/admin/articles/7");
expect(
errorOperationLink({
...base,
context: { module: "news", articleId: "../settings" },
}),
).toBe("/admin/articles");
expect(
errorOperationLink({
...base,
source: "browser",
context: { path: "/admin/media" },
}),
).toBe("/admin/media");
for (const path of [
"https://evil.invalid",
"//evil.invalid",
"/admin/articles/1?token=x",
"/admin/articles/../settings",
"/admin/users/42",
"javascript:alert(1)",
"/admin/%61rticles",
])
expect(errorOperationLink({ ...base, context: { path } })).toBeNull();
});
+73
View File
@@ -0,0 +1,73 @@
import type { CmsErrorRecord } from "./error-monitor";
export function errorOperationLink(record: CmsErrorRecord): string | null {
const context = record.context;
if (record.source === "server") {
if (context.module === "news") {
const id = String(context.articleId ?? "");
return /^[1-9]\d{0,18}$/.test(id)
? `/admin/articles/${id}`
: "/admin/articles";
}
const routes: Record<string, string> = {
catalog: "/admin/catalog",
media: "/admin/media",
users: "/admin/users",
settings: "/admin/settings",
studio: "/admin/studio",
};
if (
typeof context.module === "string" &&
Object.hasOwn(routes, context.module)
)
return routes[context.module];
}
// Never use arbitrary error-provided URLs as navigation targets.
const candidate = context.path;
if (typeof candidate !== "string") return null;
if (
/^\/admin\/(articles|users|catalog|media|settings|studio)$/.test(candidate)
)
return candidate;
if (/^\/admin\/articles\/[1-9]\d{0,18}$/.test(candidate)) return candidate;
return null;
}
export function summarizeErrorGroup(events: CmsErrorRecord[]) {
const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
const first = sorted[0];
const latest = sorted.at(-1);
const users = new Set<string>();
let unidentified = 0;
const releases = new Map<
string,
{ release: string; count: number; firstAt: string; lastAt: string }
>();
for (const event of sorted) {
const raw = event.source === "server" ? event.context.userId : null;
const id =
typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0
? String(raw)
: typeof raw === "string" && /^[1-9]\d{0,14}$/.test(raw)
? raw
: null;
if (id) users.add(id);
else unidentified++;
const prior = releases.get(event.release);
releases.set(event.release, {
release: event.release,
count: (prior?.count ?? 0) + 1,
firstAt: prior?.firstAt ?? event.at,
lastAt: event.at,
});
}
return {
count: events.length,
firstAt: first?.at ?? null,
lastAt: latest?.at ?? null,
affectedUsers: users.size,
unidentified,
releases: [...releases.values()],
latest,
operationLink: latest ? errorOperationLink(latest) : null,
};
}
+48 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { expect, it } from "vitest"; import { expect, it } from "vitest";
@@ -54,3 +54,50 @@ it("resolves a group and reopens it when a later occurrence arrives", async () =
await rm(dir, { recursive: true, force: true }); await rm(dir, { recursive: true, force: true });
} }
}); });
it("persists assignments across instances and clears them without modifying resolution", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
try {
const store = new ErrorStore(dir);
const record = createErrorRecord("server", "assign", "failed");
await store.append(record);
await store.resolve(record.fingerprint);
await store.assign(record.fingerprint, 42);
const persisted = (await new ErrorStore(dir).read()).records[0];
expect(persisted.assignment?.userId).toBe(42);
expect(persisted.resolved).toBe(true);
await new ErrorStore(dir).assign(record.fingerprint, null);
expect((await store.read()).records[0].assignment).toBeNull();
await expect(store.assign("../escape", 42)).rejects.toThrow(
"Invalid fingerprint",
);
await expect(store.assign(record.fingerprint, -1)).rejects.toThrow(
"Invalid assignee",
);
await expect(store.assign("0000000000000000", 42)).rejects.toThrow(
"no longer available",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("ignores expired assignment metadata and prunes it with error retention", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
try {
const record = createErrorRecord("server", "retention", "failed");
const expired = `2000-01-01.${record.fingerprint}.assignment`;
await writeFile(
path.join(dir, expired),
JSON.stringify({ userId: 99, at: "2000-01-01T00:00:00Z" }),
);
const store = new ErrorStore(dir);
await store.append(record);
expect((await store.read()).records[0].assignment).toBeNull();
expect(await readdir(dir)).not.toContain(expired);
const current = `${record.at.slice(0, 10)}.${record.fingerprint}.assignment`;
await writeFile(path.join(dir, current), "x".repeat(513));
expect((await store.read()).records[0].assignment).toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
File diff suppressed because it is too large. Load diff
+31 -3
View File
@@ -5,7 +5,14 @@ import { retryableJobItems } from "./import-job-retry";
const job = (state: ImportJob["state"]) => const job = (state: ImportJob["state"]) =>
({ ({
state, state,
items: ["done", "failed", "pending", "running"].map((state) => ({ items: [
"done",
"failed",
"pending",
"running",
"cancelled",
"interrupted",
].map((state) => ({
state, state,
classname: state, classname: state,
})), })),
@@ -14,12 +21,33 @@ it("only retries failures after a completed job", () =>
expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([ expect(retryableJobItems(job("completed")).map((i) => i.state)).toEqual([
"failed", "failed",
])); ]));
it("recovers all unfinished items from an interrupted job", () => it("recovers only safe unfinished items from an interrupted job", () =>
expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([ expect(retryableJobItems(job("interrupted")).map((i) => i.state)).toEqual([
"failed", "failed",
"pending", "pending",
"running", "cancelled",
])); ]));
it.each(["queued", "running"] as const)("never duplicates a %s job", (state) => it.each(["queued", "running"] as const)("never duplicates a %s job", (state) =>
expect(retryableJobItems(job(state))).toEqual([]), expect(retryableJobItems(job(state))).toEqual([]),
); );
it("retries cancelled remaining work without redoing completed or uncertain items", () => {
expect(retryableJobItems(job("cancelled")).map((i) => i.state)).toEqual([
"failed",
"pending",
"cancelled",
]);
});
it("does not blindly retry legacy interrupted mutations stored as failures", () => {
const interrupted = job("interrupted");
interrupted.items = [
{
...interrupted.items[0],
state: "failed",
error:
"Server restarted during import. Review local data and retry to complete missing parts.",
},
];
expect(retryableJobItems(interrupted)).toEqual([]);
});
+4 -2
View File
@@ -4,7 +4,9 @@ export function retryableJobItems(job: ImportJob): ImportJob["items"] {
if (job.state === "running" || job.state === "queued") return []; if (job.state === "running" || job.state === "queued") return [];
return job.items.filter( return job.items.filter(
(item) => (item) =>
item.state === "failed" || (item.state === "failed" &&
(job.state === "interrupted" && item.state !== "done"), !item.error?.startsWith("Server restarted during import.")) ||
((job.state === "interrupted" || job.state === "cancelled") &&
(item.state === "pending" || item.state === "cancelled")),
); );
} }
+9 -2
View File
@@ -14,13 +14,20 @@ export interface ImportJob {
userId: number; userId: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
state: "queued" | "running" | "completed" | "interrupted"; state: "queued" | "running" | "completed" | "interrupted" | "cancelled";
cancelRequested?: boolean;
sourceId?: string; sourceId?: string;
translate: boolean; translate: boolean;
langs?: string[]; langs?: string[];
items: Array< items: Array<
ImportJobItem & { ImportJobItem & {
state: "pending" | "running" | "done" | "failed"; state:
| "pending"
| "running"
| "done"
| "failed"
| "cancelled"
| "interrupted";
error?: string; error?: string;
warnings?: string[]; warnings?: string[];
itemId?: number; itemId?: number;
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { furnitureCompleteness } from "./studio-completeness";
import {
compareFurniture,
type FurnitureInspection,
} from "./studio-inspection";
const complete: FurnitureInspection = {
classname: "chair",
sql: [{ id: 100, spriteId: 200, type: "s", name: "Chair" }],
catalog: [{ id: 50, pageId: 1, credits: 3, points: 0 }],
furnidata: [
{ id: 200, type: "flooritem", name: "Chair", description: "", revision: 1 },
],
furnidataReadable: true,
nitro: { exists: true, bytes: 200 },
icon: { exists: true, bytes: 30 },
};
describe("furniture completeness", () => {
it("checks all five independent parts and accepts locally remapped IDs", () => {
expect(furnitureCompleteness(complete).map((p) => p.status)).toEqual(
Array(5).fill("present"),
);
});
it("does not infer a catalog offer or icon from SQL and Nitro", () => {
const parts = furnitureCompleteness({
...complete,
catalog: [],
icon: { exists: false, bytes: 0 },
});
expect(parts.find((p) => p.id === "catalog")).toMatchObject({
status: "missing",
action: "catalog",
});
expect(parts.find((p) => p.id === "icon")).toMatchObject({
status: "missing",
action: "icon",
});
expect(parts.find((p) => p.id === "sql")?.status).toBe("present");
});
it("keeps unreadable assets unknown and directs operators to recheck", () => {
const parts = furnitureCompleteness({
...complete,
furnidataReadable: false,
icon: { exists: null, bytes: 0 },
});
expect(parts.find((p) => p.id === "furnidata")).toMatchObject({
status: "unknown",
action: "recheck",
});
expect(parts.find((p) => p.id === "icon")).toMatchObject({
status: "unknown",
action: "recheck",
});
});
it.each([
{ id: 201, type: "flooritem" },
{ id: 200, type: "wallitem" },
])(
"flags ID or type mismatches in both review and completeness: %j",
(change) => {
const local = {
...complete,
furnidata: [{ ...complete.furnidata[0], ...change }],
};
expect(
furnitureCompleteness(local)
.filter((p) => p.status === "conflict")
.map((p) => p.id),
).toEqual(["sql", "furnidata"]);
expect(
compareFurniture(
{ name: "Chair", description: "", revision: 1, type: "flooritem" },
local,
).state,
).toBe("conflict");
},
);
it("allows multiple legitimate catalog offers but surfaces duplicate SQL records", () => {
expect(
furnitureCompleteness({
...complete,
catalog: [...complete.catalog, { ...complete.catalog[0], id: 51 }],
}).find((p) => p.id === "catalog")?.status,
).toBe("present");
expect(
furnitureCompleteness({
...complete,
sql: [...complete.sql, { ...complete.sql[0], id: 101 }],
}).find((p) => p.id === "sql"),
).toMatchObject({ status: "conflict", action: "audit" });
});
});
+38
View File
@@ -0,0 +1,38 @@
import {
type FurnitureInspection,
hasFurnitureConflict,
} from "./studio-inspection";
export type FurniturePartId =
| "sql"
| "catalog"
| "furnidata"
| "icon"
| "nitro";
export interface FurniturePart {
id: FurniturePartId;
status: "present" | "missing" | "unknown" | "conflict";
action: FurniturePartId | "recheck" | "audit" | null;
}
/** Report each required part without treating unavailable checks as missing data. */
export function furnitureCompleteness(
local: FurnitureInspection,
): FurniturePart[] {
const conflict = hasFurnitureConflict(local);
const evidence: Array<[FurniturePartId, boolean | null]> = [
["sql", local.sql.length > 0],
["catalog", local.catalog.length > 0],
["furnidata", local.furnidataReadable ? local.furnidata.length > 0 : null],
["icon", local.icon.exists],
["nitro", local.nitro.exists],
];
return evidence.map(([id, present]) => {
if (conflict && (id === "sql" || id === "furnidata"))
return { id, status: "conflict", action: "audit" };
if (present === null) return { id, status: "unknown", action: "recheck" };
return {
id,
status: present ? "present" : "missing",
action: present ? null : id,
};
});
}
+17 -13
View File
@@ -56,8 +56,7 @@ export function compareFurniture(
after !== undefined && after !== undefined &&
String(before) !== String(after), String(before) !== String(after),
})); }));
const state = const state = hasFurnitureConflict(local)
local.sql.length > 1 || local.furnidata.length > 1
? "conflict" ? "conflict"
: !local.furnidataReadable : !local.furnidataReadable
? "unknown" ? "unknown"
@@ -76,17 +75,7 @@ export function furnitureHealth(local: FurnitureInspection) {
missing.push("Furnidata"); missing.push("Furnidata");
if (!local.sql.length) missing.push("SQL item"); if (!local.sql.length) missing.push("SQL item");
if (!local.catalog.length) missing.push("Catalog offer"); if (!local.catalog.length) missing.push("Catalog offer");
const sql = local.sql[0], const conflict = hasFurnitureConflict(local);
fd = local.furnidata[0];
const conflict =
local.sql.length > 1 ||
local.furnidata.length > 1 ||
!!(
sql &&
fd &&
(sql.spriteId !== fd.id ||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
);
const unknown = const unknown =
!local.furnidataReadable || !local.furnidataReadable ||
local.nitro.exists === null || local.nitro.exists === null ||
@@ -102,3 +91,18 @@ export function furnitureHealth(local: FurnitureInspection) {
: "incomplete"; : "incomplete";
return { state, missing } as const; return { state, missing } as const;
} }
export function hasFurnitureConflict(local: FurnitureInspection): boolean {
const sql = local.sql[0],
fd = local.furnidata[0];
return (
local.sql.length > 1 ||
local.furnidata.length > 1 ||
!!(
sql &&
fd &&
(sql.spriteId !== fd.id ||
(sql.type === "i" ? "wallitem" : "flooritem") !== fd.type)
)
);
}
+69
View File
@@ -0,0 +1,69 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const bash =
process.platform === "win32"
? ((process.env.PATH ?? "")
.split(delimiter)
.flatMap((dir) => [
join(dir, "bash.exe"),
join(dirname(dir), "bin", "bash.exe"),
join(dirname(dirname(dir)), "bin", "bash.exe"),
])
.find((path) => existsSync(path)) ?? "bash")
: "bash";
const sha = "a".repeat(40);
function simulate(scenario: string) {
const dir = mkdtempSync(join(tmpdir(), "cms-publish-test-"));
try {
const result = spawnSync(
bash,
[resolve(root, "scripts/publish-container.sh")],
{
cwd: dir,
encoding: "utf8",
timeout: 10000,
env: {
...process.env,
BASH_ENV: resolve(root, "src/test/publish-container-harness.sh"),
TEST_DIR: dir.replaceAll("\\", "/"),
TEST_SHA: sha,
SCENARIO: scenario,
REGISTRY_SERVER: "https://registry.invalid",
REGISTRY_REPOSITORY: "owner/cms",
REGISTRY_USER: "fixture",
REGISTRY_TOKEN: "fixture-only",
},
},
);
if (result.error) throw result.error;
expect(result.status, result.stdout + result.stderr).toBe(0);
return readFileSync(join(dir, "calls"), "utf8");
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("verified application image reuse", () => {
it("reuses only the exact image digest that passed deployment checks", () => {
const calls = simulate("verified");
expect(calls).toContain(
`docker tag sha256:candidate registry.invalid/owner/cms:${sha}`,
);
expect(calls).not.toContain("docker build --network=host --build-arg");
expect(
calls.indexOf("verify scripts/verify-portable-image.mjs"),
).toBeLessThan(calls.indexOf("docker push"));
});
it.each(["missing", "mismatch"])(
"builds committed source when verification marker is %s",
(scenario) => {
const calls = simulate(scenario);
expect(calls).toContain("docker build --network=host --build-arg");
expect(calls).not.toContain("docker tag sha256:candidate");
},
);
});
+62
View File
@@ -0,0 +1,62 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
total: 55,
queries: [] as { sql: string; params: unknown[] }[],
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
return {
...schema,
db: drizzle(async (sql, params) => {
state.queries.push({ sql, params });
return { rows: sql.includes("count(*)") ? [[state.total]] : [] };
}),
};
});
import { loadArticleList } from "./article-list";
describe("article listing", () => {
beforeEach(() => {
state.queries = [];
state.total = 55;
});
it("filters drafts before pagination and binds search text", async () => {
const result = await loadArticleList({
status: "draft",
search: "needle'",
page: 2,
});
expect(result.page).toBe(2);
for (const query of state.queries) {
expect(query.sql).toContain("`website_articles`.`status` = ?");
expect(query.sql).not.toContain("needle");
expect(query.params.slice(0, 3)).toEqual([
"draft",
"%needle'%",
"%needle'%",
]);
}
expect(state.queries[1].params.slice(-2)).toEqual([24, 24]);
});
it("clamps an out-of-range page to the last filtered page", async () => {
const result = await loadArticleList({ status: "scheduled", page: 999 });
expect(result).toMatchObject({ page: 3, lastPage: 3, total: 55 });
expect(state.queries[1].params.slice(-2)).toEqual([24, 48]);
});
it("bounds invalid input and keeps an empty collection on page one", async () => {
state.total = 0;
const result = await loadArticleList({ status: "unexpected", page: NaN });
expect(result).toMatchObject({
status: "all",
page: 1,
lastPage: 1,
total: 0,
});
expect(state.queries[1].sql).not.toContain(
"`website_articles`.`status` = ?",
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import "server-only";
import { and, count, desc, eq, like, or } from "drizzle-orm";
import { db, User, WebsiteArticles } from "@/lib/db";
export async function loadArticleList(
options: { status?: string; search?: string; page?: number } = {},
) {
const status = ["draft", "published", "scheduled"].includes(
options.status ?? "",
)
? options.status
: "all";
const search = (options.search ?? "").trim().slice(0, 191);
const where = and(
status !== "all" ? eq(WebsiteArticles.status, status ?? "all") : undefined,
search
? or(
like(WebsiteArticles.title, `%${search}%`),
like(WebsiteArticles.shortStory, `%${search}%`),
)
: undefined,
);
const totals = await db
.select({ value: count() })
.from(WebsiteArticles)
.where(where);
const total = Number(totals[0]?.value ?? 0);
const perPage = 24;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const page = Math.min(
lastPage,
Number.isFinite(options.page)
? Math.max(1, Math.trunc(options.page ?? 1))
: 1,
);
const rows = await db
.select({
id: WebsiteArticles.id,
title: WebsiteArticles.title,
image: WebsiteArticles.image,
createdAt: WebsiteArticles.createdAt,
status: WebsiteArticles.status,
author: User.username,
})
.from(WebsiteArticles)
.leftJoin(User, eq(User.id, WebsiteArticles.userId))
.where(where)
.orderBy(desc(WebsiteArticles.createdAt), desc(WebsiteArticles.id))
.limit(perPage)
.offset((page - 1) * perPage);
return { rows, page, perPage, total, lastPage, status, search };
}
+67
View File
@@ -0,0 +1,67 @@
type Change = { key: string; from: unknown; to: unknown };
const sensitive =
/password|secret|token|otp|recovery|authTicket|two_factor|api_key/i;
function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function redact(value: unknown, depth = 0): unknown {
if (depth > 10) return "[…]";
if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1));
if (!record(value)) return value;
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
sensitive.test(key) ? "[Redacted]" : redact(item, depth + 1),
]),
);
}
function parse(value: string) {
if (value.length > 100000) throw new Error("oversize");
const parsed: unknown = JSON.parse(value);
if (!record(parsed)) throw new Error("invalid");
return parsed;
}
export function readAuditChanges(
diff: string | null,
before?: string | null,
after?: string | null,
) {
try {
let changes: Change[];
if (diff) {
const parsed = parse(diff);
changes = Object.entries(parsed).map(([key, value]) => {
if (!record(value) || !("from" in value || "to" in value))
throw new Error("invalid");
return { key, from: value.from, to: value.to };
});
} else {
const previous = before ? parse(before) : {};
const next = after ? parse(after) : {};
changes = [...new Set([...Object.keys(previous), ...Object.keys(next)])]
.filter(
(key) => JSON.stringify(previous[key]) !== JSON.stringify(next[key]),
)
.map((key) => ({ key, from: previous[key], to: next[key] }));
}
return {
invalid: false,
changes: changes.map(({ key, from, to }) => ({
key,
from: sensitive.test(key) ? "[Redacted]" : redact(from),
to: sensitive.test(key) ? "[Redacted]" : redact(to),
})),
};
} catch {
return { invalid: true, changes: [] as Change[] };
}
}
export function formatAuditValue(value: unknown) {
const text =
value === undefined
? "∅"
: typeof value === "string"
? value
: JSON.stringify(value, null, 2);
return text.length > 4000 ? `${text.slice(0, 4000)}…` : text;
}
+39
View File
@@ -0,0 +1,39 @@
export interface AuditFilters {
search?: string;
actor?: string;
action?: string;
from?: string;
to?: string;
page?: number;
perPage?: number;
}
function day(value?: string) {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return "";
const date = new Date(`${value}T00:00:00Z`);
return Number.isFinite(date.getTime()) &&
date.toISOString().slice(0, 10) === value
? value
: "";
}
export function normalizeAuditFilters(options: AuditFilters = {}) {
const from = day(options.from);
const to = day(options.to);
return {
search: (options.search ?? "").trim().slice(0, 191),
actor: (options.actor ?? "").trim().slice(0, 191),
action: (options.action ?? "").trim().slice(0, 191),
from,
to,
until: to
? new Date(new Date(`${to}T00:00:00Z`).getTime() + 86400000)
.toISOString()
.slice(0, 10)
: "",
page: Number.isFinite(options.page)
? Math.min(1000000, Math.max(1, Math.trunc(options.page ?? 1)))
: 1,
perPage: Number.isFinite(options.perPage)
? Math.min(100, Math.max(1, Math.trunc(options.perPage ?? 20)))
: 20,
};
}
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
queries: [] as { sql: string; params: unknown[] }[],
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
return {
...schema,
db: drizzle(async (sql, params) => {
state.queries.push({ sql, params });
return { rows: [] };
}),
};
});
import { getAuditLogs } from "./audit";
describe("audit query filters", () => {
beforeEach(() => {
state.queries = [];
});
it("binds actor/action/date/search values in both list and count queries", async () => {
await getAuditLogs({
actor: "Alice' OR 1=1 --",
action: "update",
search: "user",
from: "2026-09-09",
to: "2026-09-09",
perPage: 10,
page: 2,
});
expect(state.queries).toHaveLength(2);
for (const query of state.queries) {
expect(query.sql).not.toContain("Alice");
expect(query.sql).toContain("`admin_audit_log`.`user_id` in (select");
expect(query.sql).toContain("`admin_audit_log`.`created_at` >= ?");
expect(query.sql).toContain("`admin_audit_log`.`created_at` < ?");
expect(query.params.slice(0, 6)).toEqual([
"%user%",
"%user%",
"update",
"Alice' OR 1=1 --",
"2026-09-09",
"2026-09-10",
]);
}
expect(state.queries[0].params.slice(-2)).toEqual([10, 10]);
});
it("uses an exact numeric actor ID and caps row count", async () => {
await getAuditLogs({ actor: "42", perPage: 1000, page: -2 });
expect(state.queries[0].params).toEqual([42, 100]);
expect(state.queries[0].sql).not.toContain("in (select");
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { formatAuditValue, readAuditChanges } from "./audit-diff";
import { normalizeAuditFilters } from "./audit-filters";
describe("audit filter bounds", () => {
it("normalizes pagination and rejects invalid calendar dates", () => {
expect(
normalizeAuditFilters({
page: NaN,
perPage: Infinity,
from: "2026-02-30",
to: "x",
}),
).toMatchObject({ page: 1, perPage: 20, from: "", to: "" });
expect(normalizeAuditFilters({ page: -3, perPage: 1000 })).toMatchObject({
page: 1,
perPage: 100,
});
});
it("caps strings and preserves exact actor/action and UTC day boundaries", () => {
expect(
normalizeAuditFilters({
actor: " Alice ",
action: " update ",
from: "2026-09-09",
to: "2026-09-09",
}),
).toMatchObject({
actor: "Alice",
action: "update",
from: "2026-09-09",
until: "2026-09-10",
});
expect(
normalizeAuditFilters({ search: "x".repeat(500) }).search,
).toHaveLength(191);
});
});
describe("legacy audit diffs", () => {
it.each(["null", "[]", "1", "bad JSON", '{"name":null}', '{"name":"value"}'])(
"handles %s safely",
(diff) => {
expect(readAuditChanges(diff).invalid).toBe(true);
},
);
it("keeps objects and explicit null distinct from absent values", () => {
const result = readAuditChanges(
'{"settings":{"from":{"enabled":false},"to":null},"added":{"to":3}}',
);
expect(result.changes).toHaveLength(2);
expect(formatAuditValue(result.changes[0].from)).toContain(
'"enabled": false',
);
expect(formatAuditValue(result.changes[0].to)).toBe("null");
expect(formatAuditValue(result.changes[1].from)).toBe("∅");
});
it("shows create/delete snapshots when a legacy record has no diff", () => {
expect(readAuditChanges(null, null, '{"name":"New"}').changes).toEqual([
{ key: "name", from: undefined, to: "New" },
]);
});
it("redacts historical sensitive nested fields before rendering", () => {
expect(
formatAuditValue(
readAuditChanges('{"settings":{"from":{"password":"secret"},"to":{}}}')
.changes[0].from,
),
).not.toContain("secret");
});
});
+21
View File
@@ -187,3 +187,24 @@ describe("getAuditLogs", () => {
expect(result.rows[0].username).toBe("User #99"); expect(result.rows[0].username).toBe("User #99");
}); });
}); });
describe("audit response privacy", () => {
it("does not serialize legacy snapshot secrets to the client", async () => {
selectRows.mockResolvedValue([
{
id: 1,
userId: 1,
diff: null,
before: null,
after: '{"name":"Alice","password":"secret-value"}',
},
]);
selectCount.mockResolvedValue([{ value: 1 }]);
selectUsers.mockResolvedValue([]);
const result = await getAuditLogs();
expect(JSON.stringify(result.rows)).not.toContain("secret-value");
expect(result.rows[0].before).toBeNull();
expect(result.rows[0].after).toBeNull();
expect(result.rows[0].diff).toContain("Alice");
});
});
+43 -15
View File
@@ -1,5 +1,7 @@
import { count, desc, inArray, like, or } from "drizzle-orm"; import { and, count, desc, eq, gte, inArray, like, lt, or } from "drizzle-orm";
import { AdminAuditLog, db, User } from "@/lib/db"; import { AdminAuditLog, db, User } from "@/lib/db";
import { readAuditChanges } from "./audit-diff";
import { type AuditFilters, normalizeAuditFilters } from "./audit-filters";
interface AuditEntry { interface AuditEntry {
userId: number; userId: number;
@@ -68,23 +70,32 @@ export async function logAudit(entry: AuditEntry): Promise<void> {
}); });
} }
interface GetLogsOptions { export async function getAuditLogs(options: AuditFilters = {}) {
search?: string; const { search, actor, action, from, until, page, perPage } =
page?: number; normalizeAuditFilters(options);
perPage?: number;
}
export async function getAuditLogs(options: GetLogsOptions = {}) {
const { search, page = 1, perPage = 20 } = options;
const skip = (page - 1) * perPage; const skip = (page - 1) * perPage;
const where = and(
const where = search search
? or( ? or(
like(AdminAuditLog.action, `%${search}%`), like(AdminAuditLog.action, `%${search}%`),
like(AdminAuditLog.target, `%${search}%`), like(AdminAuditLog.target, `%${search}%`),
) )
: undefined; : undefined,
action ? eq(AdminAuditLog.action, action) : undefined,
actor
? /^[1-9]\d*$/.test(actor) && Number.isSafeInteger(Number(actor))
? eq(AdminAuditLog.userId, Number(actor))
: inArray(
AdminAuditLog.userId,
db
.select({ id: User.id })
.from(User)
.where(eq(User.username, actor)),
)
: undefined,
from ? gte(AdminAuditLog.createdAt, from) : undefined,
until ? lt(AdminAuditLog.createdAt, until) : undefined,
);
const [rows, totalResult] = await Promise.all([ const [rows, totalResult] = await Promise.all([
db db
.select({ .select({
@@ -94,6 +105,8 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
target: AdminAuditLog.target, target: AdminAuditLog.target,
targetId: AdminAuditLog.targetId, targetId: AdminAuditLog.targetId,
diff: AdminAuditLog.diff, diff: AdminAuditLog.diff,
before: AdminAuditLog.before,
after: AdminAuditLog.after,
createdAt: AdminAuditLog.createdAt, createdAt: AdminAuditLog.createdAt,
}) })
.from(AdminAuditLog) .from(AdminAuditLog)
@@ -116,10 +129,25 @@ export async function getAuditLogs(options: GetLogsOptions = {}) {
: []; : [];
const userMap = new Map(users.map((u) => [u.id, u.username])); const userMap = new Map(users.map((u) => [u.id, u.username]));
const enrichedRows = rows.map((r) => ({ const enrichedRows = rows.map((r) => {
const details = readAuditChanges(r.diff, r.before, r.after);
return {
...r, ...r,
// Only sanitized changes may cross the server/client boundary.
before: null,
after: null,
diff: details.invalid
? "[Unavailable legacy details]"
: details.changes.length
? JSON.stringify(
Object.fromEntries(
details.changes.map(({ key, from, to }) => [key, { from, to }]),
),
)
: null,
username: userMap.get(r.userId) ?? `User #${r.userId}`, username: userMap.get(r.userId) ?? `User #${r.userId}`,
})); };
});
return { return {
rows: enrichedRows, rows: enrichedRows,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { getDashboardEventPhase } from "./dashboard-event-phase";
const now = new Date("2026-09-09T12:00:00Z");
const event = (
startsAt: string,
endsAt: string | null = null,
status = "published",
) => ({
startsAt: new Date(startsAt),
endsAt: endsAt ? new Date(endsAt) : null,
status,
});
describe("dashboard event timing", () => {
it("shows a future published event without requiring an end", () => {
expect(getDashboardEventPhase(event("2026-09-09T13:00:00Z"), now)).toBe(
"upcoming",
);
});
it("marks the start boundary and known duration as ongoing", () => {
expect(
getDashboardEventPhase(
event("2026-09-09T12:00:00Z", "2026-09-09T13:00:00Z"),
now,
),
).toBe("ongoing");
});
it("excludes ended events including the exact end boundary", () => {
expect(
getDashboardEventPhase(
event("2026-09-09T11:00:00Z", "2026-09-09T12:00:00Z"),
now,
),
).toBeNull();
});
it("does not invent a duration for old events without an end", () => {
expect(
getDashboardEventPhase(event("2026-09-08T12:00:00Z"), now),
).toBeNull();
});
it.each(["draft", "cancelled", "completed"])(
"excludes %s events",
(status) => {
expect(
getDashboardEventPhase(
event("2026-09-09T13:00:00Z", null, status),
now,
),
).toBeNull();
},
);
});
@@ -0,0 +1,8 @@
export function getDashboardEventPhase(
event: { status: string; startsAt: Date; endsAt: Date | null },
now: Date,
): "upcoming" | "ongoing" | null {
if (event.status !== "published") return null;
if (event.startsAt > now) return "upcoming";
return event.endsAt && event.endsAt > now ? "ongoing" : null;
}
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [] as unknown[],
fail: false,
query: "",
params: [] as unknown[],
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy");
return {
...schema,
db: drizzle(async (query, params) => {
state.query = query;
state.params = params;
if (state.fail) throw new Error("offline");
return { rows: state.rows };
}),
};
});
import { loadDashboardEvent } from "./dashboard-event";
describe("dashboard event loading", () => {
beforeEach(() => {
state.rows = [];
state.fail = false;
});
it("limits to one published ongoing or future event in date order", async () => {
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z"));
expect(state.query).toContain("`website_events`.`status` = ?");
expect(state.query).toContain(
"(`website_events`.`starts_at` > ? or `website_events`.`ends_at` > ?)",
);
expect(state.query).toContain(
"order by `website_events`.`starts_at` asc, `website_events`.`id` asc limit ?",
);
expect(state.params).toEqual([
"published",
"2026-09-09 12:00:00.000",
"2026-09-09 12:00:00.000",
1,
]);
});
it("distinguishes an empty calendar from a failed query", async () => {
expect(await loadDashboardEvent()).toEqual({
event: null,
unavailable: false,
});
state.fail = true;
expect(await loadDashboardEvent()).toEqual({
event: null,
unavailable: true,
});
});
it("returns the next event with the phase derived from its real dates", async () => {
state.rows = [
[3, "Quiz", "published", "2026-09-09 13:00:00.000", null, "Games"],
];
expect(
await loadDashboardEvent(new Date("2026-09-09T12:00:00Z")),
).toMatchObject({
unavailable: false,
event: { id: 3, title: "Quiz", phase: "upcoming" },
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import "server-only";
import { and, asc, eq, gt, or } from "drizzle-orm";
import { db, WebsiteEvent, WebsiteEventType } from "@/lib/db";
import { getDashboardEventPhase } from "./dashboard-event-phase";
export async function loadDashboardEvent(now = new Date()) {
try {
const rows = await db
.select({
id: WebsiteEvent.id,
title: WebsiteEvent.title,
status: WebsiteEvent.status,
startsAt: WebsiteEvent.startsAt,
endsAt: WebsiteEvent.endsAt,
typeName: WebsiteEventType.name,
})
.from(WebsiteEvent)
.innerJoin(WebsiteEventType, eq(WebsiteEvent.typeId, WebsiteEventType.id))
.where(
and(
eq(WebsiteEvent.status, "published"),
or(gt(WebsiteEvent.startsAt, now), gt(WebsiteEvent.endsAt, now)),
),
)
.orderBy(asc(WebsiteEvent.startsAt), asc(WebsiteEvent.id))
.limit(1);
const event = rows[0];
const phase = event ? getDashboardEventPhase(event, now) : null;
return {
event: event && phase ? { ...event, phase } : null,
unavailable: false,
};
} catch {
return { event: null, unavailable: true };
}
}
+41
View File
@@ -59,3 +59,44 @@ it("rejects unsafe job paths", async () => {
); );
await expect(s.read("../escape")).rejects.toThrow("Invalid import ID"); await expect(s.read("../escape")).rejects.toThrow("Invalid import ID");
}); });
it("cancellation survives a stale worker save", async () => {
const s = await store(),
j = job();
await s.create(j);
expect((await s.requestCancellation(j.id, j.userId))?.cancelRequested).toBe(
true,
);
await s.save({ ...j, state: "running" });
expect(await s.isCancellationRequested(j.id)).toBe(true);
expect((await s.read(j.id)).cancelRequested).toBe(true);
});
it("rejects cancellation by a different operator and is idempotent for the owner", async () => {
const s = await store(),
j = job();
await s.create(j);
expect(await s.requestCancellation(j.id, 99)).toBeNull();
expect(await s.isCancellationRequested(j.id)).toBe(false);
await Promise.all([
s.requestCancellation(j.id, j.userId),
s.requestCancellation(j.id, j.userId),
]);
expect(await s.isCancellationRequested(j.id)).toBe(true);
});
it("does not alter completed jobs when cancellation arrives late", async () => {
const s = await store(),
j = { ...job(), state: "completed" as const };
await s.create(j);
expect((await s.requestCancellation(j.id, j.userId))?.state).toBe(
"completed",
);
expect(await s.isCancellationRequested(j.id)).toBe(false);
});
it("loads bounded history for the requesting owner", async () => {
const s = await store();
for (let i = 0; i < 5; i++)
await s.create({ ...job(), userId: i % 2 === 0 ? 4 : 8 });
const result = await s.list({ userId: 4, limit: 2 });
expect(result).toHaveLength(2);
expect(result.every((job) => job.userId === 4)).toBe(true);
});
+55 -7
View File
@@ -39,20 +39,68 @@ export class ImportJobStore {
} }
async read(id: string): Promise<ImportJob> { async read(id: string): Promise<ImportJob> {
if (!validJobId(id)) throw Error("Invalid import ID"); if (!validJobId(id)) throw Error("Invalid import ID");
return JSON.parse( const job: ImportJob = JSON.parse(
await fs.readFile(path.join(this.root, `${id}.json`), "utf8"), await fs.readFile(path.join(this.root, `${id}.json`), "utf8"),
); );
if (await this.isCancellationRequested(id)) job.cancelRequested = true;
return job;
} }
async list(): Promise<ImportJob[]> { async isCancellationRequested(id: string): Promise<boolean> {
if (!validJobId(id)) throw Error("Invalid import ID");
try {
await fs.access(path.join(this.root, `${id}.cancel`));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
/** Cancellation has its own durable marker: a worker save cannot overwrite it. */
async requestCancellation(
id: string,
userId: number,
): Promise<ImportJob | null> {
const job = await this.read(id).catch((error) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!job || job.userId !== userId) return null;
if (job.state !== "queued" && job.state !== "running") return job;
await fs
.writeFile(path.join(this.root, `${id}.cancel`), "cancel", { flag: "wx" })
.catch((error) => {
if (error.code !== "EEXIST") throw error;
});
return this.read(id);
}
async list(options?: {
userId: number;
limit: number;
}): Promise<ImportJob[]> {
const files = await fs.readdir(this.root).catch((error) => { const files = await fs.readdir(this.root).catch((error) => {
if (error.code === "ENOENT") return []; if (error.code === "ENOENT") return [];
throw error; throw error;
}); });
const jobs = await Promise.all( const candidates: Array<{ id: string; modified: number }> = [];
files // Metadata and payload reads are sequential, avoiding thousands of concurrent opens.
.filter((f) => f.endsWith(".json") && validJobId(f.slice(0, -5))) for (const file of files) {
.map((f) => this.read(f.slice(0, -5))), if (!file.endsWith(".json") || !validJobId(file.slice(0, -5))) continue;
const stat = await fs.stat(path.join(this.root, file));
candidates.push({ id: file.slice(0, -5), modified: stat.mtimeMs });
}
candidates.sort((a, b) =>
options ? b.modified - a.modified : a.modified - b.modified,
); );
return jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); const jobs: ImportJob[] = [];
const limit = options ? Math.min(30, Math.max(1, options.limit)) : Infinity;
for (const candidate of candidates) {
const job = await this.read(candidate.id);
if (options && job.userId !== options.userId) continue;
jobs.push(job);
if (jobs.length >= limit) break;
}
return options
? jobs
: jobs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
} }
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -12,9 +12,11 @@ import {
UsersSettings, UsersSettings,
} from "@/lib/db"; } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { loadDashboardEvent } from "./dashboard-event";
import { siteSettings } from "./site-settings"; import { siteSettings } from "./site-settings";
export async function loadUserDashboard(userId: number) { export async function loadUserDashboard(userId: number) {
const [ const [
dashboardEvent,
userRows, userRows,
hotelName, hotelName,
neededRaw, neededRaw,
@@ -29,6 +31,7 @@ export async function loadUserDashboard(userId: number) {
friends, friends,
currencyRows, currencyRows,
] = await Promise.all([ ] = await Promise.all([
loadDashboardEvent(),
db db
.select({ .select({
id: User.id, id: User.id,
@@ -62,7 +65,7 @@ export async function loadUserDashboard(userId: number) {
.from(Rooms) .from(Rooms)
.orderBy(desc(Rooms.id)) .orderBy(desc(Rooms.id))
.limit(6) .limit(6)
.catch(() => []), .catch(() => null),
db db
.select({ .select({
badgeCode: UsersBadges.badgeCode, badgeCode: UsersBadges.badgeCode,
@@ -85,7 +88,7 @@ export async function loadUserDashboard(userId: number) {
.select({ value: count() }) .select({ value: count() })
.from(MessengerOffline) .from(MessengerOffline)
.where(eq(MessengerOffline.userId, userId)) .where(eq(MessengerOffline.userId, userId))
.catch(() => [{ value: 0 }]), .catch(() => null),
db db
.select({ referralsTotal: UserReferrals.referralsTotal }) .select({ referralsTotal: UserReferrals.referralsTotal })
.from(UserReferrals) .from(UserReferrals)
@@ -144,6 +147,7 @@ export async function loadUserDashboard(userId: number) {
]; ];
return { return {
dashboardEvent,
userRows, userRows,
hotelName, hotelName,
neededRaw, neededRaw,
+36
View File
@@ -0,0 +1,36 @@
import { afterEach, expect, it, vi } from "vitest";
import { verifyRuntimeMetadata } from "../../scripts/verify-runtime-metadata.mjs";
afterEach(() => vi.unstubAllGlobals());
it("verifies runtime names and domains without requesting DB-dependent login HTML", async () => {
const fetcher = vi.fn(async (url: string) => {
if (url.endsWith("/login"))
return new Response("Database unavailable", { status: 500 });
return url.endsWith("webmanifest")
? Response.json({ name: "Fixture alpha" })
: new Response("https://alpha.test/sitemap.xml");
});
vi.stubGlobal("fetch", fetcher);
await expect(
verifyRuntimeMetadata("https://alpha.test", "alpha"),
).resolves.toBeUndefined();
expect(fetcher.mock.calls.map((c) => c[0])).not.toContain(
"https://alpha.test/login",
);
});
it.each(["manifest", "domain", "http"])("rejects wrong %s", async (mode) => {
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) =>
url.endsWith("webmanifest")
? Response.json(
{ name: mode === "manifest" ? "Build fixture" : "Fixture alpha" },
{ status: mode === "http" ? 500 : 200 },
)
: new Response("https://wrong.test"),
),
);
await expect(
verifyRuntimeMetadata("https://alpha.test", "alpha"),
).rejects.toThrow();
});
File diff suppressed because it is too large. Load diff
+157 -3
View File
File diff suppressed because it is too large. Load diff
+157 -3
View File
File diff suppressed because it is too large. Load diff
+157 -3
View File
File diff suppressed because it is too large. Load diff
+157 -3
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more