From c389c3893d26db17c37a0891e460559c7c193cd1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 9 Sep 2026 19:36:15 +0200 Subject: [PATCH] feat(cms): improve catalog, editorial recovery and operations --- .gitignore | 8 +- Dockerfile | 1 + docs/cms-upgrade-2026-09.md | 48 ++++ .../0026_article_editor_recovery.sql | 16 ++ e2e/smoke.spec.ts | 27 ++ package.json | 4 +- playwright.config.ts | 14 ++ pnpm-lock.yaml | 55 +++- scripts/ci-deploy.sh | 10 +- scripts/jobs-worker.ts | 11 + scripts/publish-container.sh | 12 +- scripts/verify-portable-image.mjs | 26 +- scripts/verify-runtime-metadata.mjs | 19 ++ src/actions/admin-articles.test.ts | 59 ++++- src/actions/admin-articles.ts | 93 ++++--- src/actions/article-recovery.test.ts | 83 ++++++ src/actions/article-recovery.ts | 110 ++++++++ src/app/(site)/me/dashboard.module.css | 78 ++++++ src/app/(site)/me/page.tsx | 71 +++++- src/app/admin/articles/[id]/page.tsx | 3 + src/app/admin/articles/list-submit.tsx | 13 + src/app/admin/articles/page.tsx | 218 ++++++++++------ src/app/admin/commandocentrum/page.tsx | 2 + .../admin/devops/cms-errors/actions.test.ts | 36 ++- src/app/admin/devops/cms-errors/actions.ts | 17 ++ src/app/admin/devops/cms-errors/page.tsx | 168 +++++++++++-- .../admin/devops/installation/page.test.ts | 30 +++ src/app/admin/devops/installation/page.tsx | 50 ++++ src/app/admin/devops/page.tsx | 4 + src/app/admin/logs/audit/audit-filters.tsx | 68 +++++ src/app/admin/logs/audit/audit-table.tsx | 122 ++++----- src/app/admin/logs/audit/page.tsx | 12 +- .../api/admin/logs/audit/export/route.test.ts | 68 +++++ src/app/api/admin/logs/audit/export/route.ts | 69 +++++ .../admin/studio/import-jobs/route.test.ts | 29 ++- src/app/api/admin/studio/import-jobs/route.ts | 25 +- src/components/admin/article-form.tsx | 41 ++- src/components/admin/article-recovery.tsx | 176 +++++++++++++ src/components/admin/data-table.tsx | 37 ++- src/components/admin/operations-inbox.tsx | 152 +++++++++++ .../studio/furniture-completeness.test.tsx | 51 ++++ .../admin/studio/furniture-completeness.tsx | 58 +++++ .../admin/studio/furniture-detail-drawer.tsx | 216 ++++++++++++++++ .../admin/studio/furniture-inspector.tsx | 40 +-- .../admin/studio/furniture-jobs.tsx | 53 +++- src/components/admin/studio/import-review.tsx | 19 ++ src/components/admin/studio/studio-client.tsx | 236 +++--------------- .../admin/studio/use-furniture-jobs.ts | 38 ++- src/components/ui/dialog.tsx | 2 +- src/db/article-editor.ts | 30 +++ .../foundation-source-contract.test.ts | 6 +- .../migration/discover-legacy-pages.test.ts | 2 +- .../housekeeping/migration/matrix.test.ts | 4 +- .../housekeeping/migration/system.test.ts | 4 +- src/features/housekeeping/migration/system.ts | 15 ++ src/lib/admin/installation-state.test.ts | 24 ++ src/lib/admin/installation-state.ts | 20 ++ src/lib/admin/installation.ts | 111 ++++++++ src/lib/admin/operations-inbox.test.ts | 35 +++ src/lib/admin/operations-inbox.ts | 42 ++++ src/lib/admin/ops-health.ts | 77 +++--- src/lib/article-draft.test.ts | 38 +++ src/lib/article-draft.ts | 39 +++ src/lib/article-edit-token.ts | 24 ++ src/lib/article-input.ts | 2 + src/lib/ci-deploy.test.ts | 19 +- src/lib/error-groups.test.ts | 60 +++++ src/lib/error-groups.ts | 73 ++++++ src/lib/error-monitor.test.ts | 49 +++- src/lib/error-monitor.ts | 83 +++++- src/lib/furni/import-job-retry.test.ts | 34 ++- src/lib/furni/import-job-retry.ts | 6 +- src/lib/furni/import-job.ts | 11 +- src/lib/furni/studio-completeness.test.ts | 93 +++++++ src/lib/furni/studio-completeness.ts | 38 +++ src/lib/furni/studio-inspection.ts | 46 ++-- src/lib/publish-container.test.ts | 69 +++++ src/lib/services/article-list.test.ts | 62 +++++ src/lib/services/article-list.ts | 51 ++++ src/lib/services/audit-diff.ts | 67 +++++ src/lib/services/audit-filters.ts | 39 +++ src/lib/services/audit-query.test.ts | 56 +++++ src/lib/services/audit-view.test.ts | 70 ++++++ src/lib/services/audit.test.ts | 21 ++ src/lib/services/audit.ts | 70 ++++-- .../services/dashboard-event-phase.test.ts | 53 ++++ src/lib/services/dashboard-event-phase.ts | 8 + src/lib/services/dashboard-event.test.ts | 68 +++++ src/lib/services/dashboard-event.ts | 36 +++ src/lib/services/furni-job-store.test.ts | 41 +++ src/lib/services/furni-job-store.ts | 62 ++++- src/lib/services/furni-job-worker.test.ts | 115 ++++++++- src/lib/services/furni-job-worker.ts | 94 +++++-- src/lib/services/user-dashboard.ts | 8 +- src/lib/verify-runtime-metadata.test.ts | 36 +++ src/messages/ar.json | 162 ++++++++++++ src/messages/bg.json | 160 +++++++++++- src/messages/cs.json | 160 +++++++++++- src/messages/da.json | 160 +++++++++++- src/messages/de.json | 160 +++++++++++- src/messages/el.json | 160 +++++++++++- src/messages/en.json | 160 +++++++++++- src/messages/es.json | 160 +++++++++++- src/messages/fi.json | 162 ++++++++++++ src/messages/fr.json | 160 +++++++++++- src/messages/hr.json | 160 +++++++++++- src/messages/hu.json | 160 +++++++++++- src/messages/it.json | 160 +++++++++++- src/messages/ja.json | 162 ++++++++++++ src/messages/nl.json | 160 +++++++++++- src/messages/no.json | 160 +++++++++++- src/messages/pl.json | 160 +++++++++++- src/messages/pt.json | 160 +++++++++++- src/messages/ro.json | 160 +++++++++++- src/messages/ru.json | 160 +++++++++++- src/messages/sk.json | 160 +++++++++++- src/messages/sr.json | 160 +++++++++++- src/messages/sv.json | 160 +++++++++++- src/messages/tr.json | 160 +++++++++++- src/messages/uk.json | 160 +++++++++++- src/test/ci-deploy-harness.sh | 11 +- src/test/publish-container-harness.sh | 14 ++ 122 files changed, 8137 insertions(+), 703 deletions(-) create mode 100644 docs/cms-upgrade-2026-09.md create mode 100644 drizzle/migrations/0026_article_editor_recovery.sql create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 scripts/verify-runtime-metadata.mjs create mode 100644 src/actions/article-recovery.test.ts create mode 100644 src/actions/article-recovery.ts create mode 100644 src/app/admin/articles/list-submit.tsx create mode 100644 src/app/admin/devops/installation/page.test.ts create mode 100644 src/app/admin/devops/installation/page.tsx create mode 100644 src/app/admin/logs/audit/audit-filters.tsx create mode 100644 src/app/api/admin/logs/audit/export/route.test.ts create mode 100644 src/app/api/admin/logs/audit/export/route.ts create mode 100644 src/components/admin/article-recovery.tsx create mode 100644 src/components/admin/operations-inbox.tsx create mode 100644 src/components/admin/studio/furniture-completeness.test.tsx create mode 100644 src/components/admin/studio/furniture-completeness.tsx create mode 100644 src/components/admin/studio/furniture-detail-drawer.tsx create mode 100644 src/db/article-editor.ts create mode 100644 src/lib/admin/installation-state.test.ts create mode 100644 src/lib/admin/installation-state.ts create mode 100644 src/lib/admin/installation.ts create mode 100644 src/lib/admin/operations-inbox.test.ts create mode 100644 src/lib/admin/operations-inbox.ts create mode 100644 src/lib/article-draft.test.ts create mode 100644 src/lib/article-draft.ts create mode 100644 src/lib/article-edit-token.ts create mode 100644 src/lib/error-groups.test.ts create mode 100644 src/lib/error-groups.ts create mode 100644 src/lib/furni/studio-completeness.test.ts create mode 100644 src/lib/furni/studio-completeness.ts create mode 100644 src/lib/publish-container.test.ts create mode 100644 src/lib/services/article-list.test.ts create mode 100644 src/lib/services/article-list.ts create mode 100644 src/lib/services/audit-diff.ts create mode 100644 src/lib/services/audit-filters.ts create mode 100644 src/lib/services/audit-query.test.ts create mode 100644 src/lib/services/audit-view.test.ts create mode 100644 src/lib/services/dashboard-event-phase.test.ts create mode 100644 src/lib/services/dashboard-event-phase.ts create mode 100644 src/lib/services/dashboard-event.test.ts create mode 100644 src/lib/services/dashboard-event.ts create mode 100644 src/lib/verify-runtime-metadata.test.ts create mode 100644 src/test/publish-container-harness.sh diff --git a/.gitignore b/.gitignore index 469d2d3e..c1eee450 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ prod.log db_backup_*.sql # Local project documentation -/docs/ +/docs/* +!/docs/cms-upgrade-2026-09.md # Local gitea binary symlink gitea @@ -39,3 +40,8 @@ coverage/ # Shared deployment lock (never application source) .deploy.lock + +# Browser verification artifacts +test-results/ +playwright-report/ +blob-report/ diff --git a/Dockerfile b/Dockerfile index dabed1c8..c50c4103 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/.next/standalone ./ 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 USER nextjs EXPOSE 3002 diff --git a/docs/cms-upgrade-2026-09.md b/docs/cms-upgrade-2026-09.md new file mode 100644 index 00000000..21234e32 --- /dev/null +++ b/docs/cms-upgrade-2026-09.md @@ -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. diff --git a/drizzle/migrations/0026_article_editor_recovery.sql b/drizzle/migrations/0026_article_editor_recovery.sql new file mode 100644 index 00000000..95046234 --- /dev/null +++ b/drizzle/migrations/0026_article_editor_recovery.sql @@ -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; diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 00000000..bf2b4e27 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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(); +}); diff --git a/package.json b/package.json index 5515aea6..5637a673 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "deps:audit": "pnpm audit --audit-level=high", "analyze": "next experimental-analyze", "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": { "@base-ui/react": "1.8.0", @@ -80,6 +81,7 @@ "devDependencies": { "@babel/parser": "7.29.8", "@biomejs/biome": "2.5.12", + "@playwright/test": "1.62.1", "@tailwindcss/forms": "0.5.11", "@tailwindcss/postcss": "4.3.3", "@tailwindcss/typography": "0.5.20", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..edded504 --- /dev/null +++ b/playwright.config.ts @@ -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"] } }], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b16e52e7..75c22e90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,13 +84,13 @@ importers: version: 3.24.3(@types/node@26.4.1) next: specifier: 16.3.4 - version: 16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 5.0.0-beta.32(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) next-intl: specifier: 4.14.2 - version: 4.14.2(@swc/helpers@0.5.23)(next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 4.14.2(@swc/helpers@0.5.23)(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) otplib: specifier: 13.5.0 version: 13.5.0 @@ -134,6 +134,9 @@ importers: '@biomejs/biome': specifier: 2.5.12 version: 2.5.12 + '@playwright/test': + specifier: 1.62.1 + version: 1.62.1 '@tailwindcss/forms': specifier: 0.5.11 version: 0.5.11(tailwindcss@4.3.3) @@ -1177,6 +1180,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} @@ -2114,6 +2122,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2548,6 +2561,16 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + po-parser@2.2.0: resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} @@ -3651,6 +3674,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@radix-ui/primitive@1.1.7': {} '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': @@ -4325,6 +4352,9 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4633,15 +4663,15 @@ snapshots: dependencies: content-type: 2.1.0 - next-auth@5.0.0-beta.32(next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-auth@5.0.0-beta.32(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@auth/core': 0.41.3 - next: 16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 next-intl-swc-plugin-extractor@4.14.2: {} - next-intl@4.14.2(@swc/helpers@0.5.23)(next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-intl@4.14.2(@swc/helpers@0.5.23)(next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@eloqnt/config': 0.1.0 '@eloqnt/format-json': 0.1.0 @@ -4651,14 +4681,14 @@ snapshots: '@swc/core': 1.16.2(@swc/helpers@0.5.23) icu-minify: 4.14.2 negotiator: 1.1.0 - next: 16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-intl-swc-plugin-extractor: 4.14.2 react: 19.2.8 use-intl: 4.14.2(react@19.2.8) transitivePeerDependencies: - '@swc/helpers' - next@16.3.4(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.3.4(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.3.4 '@swc/helpers': 0.5.23 @@ -4677,6 +4707,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.3.4 '@next/swc-win32-arm64-msvc': 16.3.4 '@next/swc-win32-x64-msvc': 16.3.4 + '@playwright/test': 1.62.1 sharp: 0.35.4(@types/node@26.4.1) transitivePeerDependencies: - '@babel/core' @@ -4750,6 +4781,14 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.2.0 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + po-parser@2.2.0: {} postal-mime@2.7.5: {} diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index c5b9753e..2a5862a8 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -93,6 +93,7 @@ for managed_name in epicnext-cms epicnext-cms-app; do done cp "$deploy_dir/.env" .env pnpm install --frozen-lockfile +pnpm exec playwright install chromium echo "Building $image" DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \ @@ -159,8 +160,11 @@ candidate_attempted=1 ) healthy 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. -docker tag "$image" epicnext-cms:latest +PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e +# 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 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 @@ -168,7 +172,7 @@ if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || echo "Deployment verified: $sha" # Retain the current and previous releases; do not remove arbitrary named tags. 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)" if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi fi diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index cb62e8ca..1a5c4944 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -260,6 +260,13 @@ async function publishScheduledArticles(): Promise { } } +async function reportWorkerHeartbeat(): Promise { + if (!redis) return; + await redis + .set("cms:jobs-worker:heartbeat", new Date().toISOString(), "EX", 600) + .catch((error) => captureWorkerError(error, "WorkerHeartbeat")); +} + async function main() { new Cron("* * * * *", () => { runCatalogExport().catch((e) => @@ -307,6 +314,10 @@ async function main() { module: "jobs", }); + new Cron("* * * * *", () => { + void reportWorkerHeartbeat(); + }); + await reportWorkerHeartbeat(); await Promise.all([ backupEmulatorJar(), cleanupOldLogs(), diff --git a/scripts/publish-container.sh b/scripts/publish-container.sh index 577af455..81b0ac3f 100644 --- a/scripts/publish-container.sh +++ b/scripts/publish-container.sh @@ -21,7 +21,17 @@ trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT git archive HEAD | tar -x -C "$context" printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin 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" node scripts/verify-portable-image.mjs "$image" "$sha" # Publish only after the same application image passed both runtime configurations. diff --git a/scripts/verify-portable-image.mjs b/scripts/verify-portable-image.mjs index 1e8c2694..fe6cd11a 100644 --- a/scripts/verify-portable-image.mjs +++ b/scripts/verify-portable-image.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { createServer } from "node:http"; import { setTimeout as delay } from "node:timers/promises"; +import { verifyRuntimeMetadata } from "./verify-runtime-metadata.mjs"; const [image, release] = process.argv.slice(2); if (!image || !/^[0-9a-f]{40}$/.test(release ?? "")) @@ -95,30 +96,7 @@ try { await delay(1000); } if (!ready) throw new Error(`${hotel}: expected HTTP release not served`); - const page = await fetch(`${base}/login`, { - 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`); - } + await verifyRuntimeMetadata(base, hotel); const avatar = await fetch( `${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`, { signal: AbortSignal.timeout(15000) }, diff --git a/scripts/verify-runtime-metadata.mjs b/scripts/verify-runtime-metadata.mjs new file mode 100644 index 00000000..783a2a9e --- /dev/null +++ b/scripts/verify-runtime-metadata.mjs @@ -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`); + } +} diff --git a/src/actions/admin-articles.test.ts b/src/actions/admin-articles.test.ts index ca6d8a4f..499db9c4 100644 --- a/src/actions/admin-articles.test.ts +++ b/src/actions/admin-articles.test.ts @@ -27,12 +27,16 @@ vi.mock("next/navigation", () => ({ throw Error(`redirect ${url}`); }, })); -vi.mock("@/lib/db", async () => ({ - ...(await import("@/db/schema")), - db: { +vi.mock("@/lib/db", async () => { + const database = { select: () => ({ 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 }), @@ -42,9 +46,13 @@ vi.mock("@/lib/db", async () => ({ return { where: async () => [{ affectedRows: 1 }] }; }, }), - }, -})); + transaction: async (fn: (tx: unknown) => unknown): Promise => + fn(database), + }; + return { ...(await import("@/db/schema")), db: database }; +}); +import { articleEditToken } from "@/lib/article-edit-token"; import { createArticle, updateArticle } from "./admin-articles"; function form(extra: Record = {}) { @@ -89,11 +97,25 @@ describe("news saves", () => { expect(state.invalidate).toHaveBeenCalledOnce(); }); 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( ( await updateArticle( - form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }), + form({ + id: "1", + baseToken: articleEditToken(existing), + publishAt: "2099-01-01T00:00:00Z", + }), ) ).ok, ).toBe(true); @@ -106,6 +128,27 @@ describe("news saves", () => { ); 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 () => { expect( (await createArticle(form({ status: "scheduled", publishAt: "invalid" }))) diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 0b7f6c4c..1c3dde36 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -4,7 +4,9 @@ import { and, eq, ne } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; +import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor"; import { requirePermission } from "@/lib/admin/guard"; +import { articleEditToken } from "@/lib/article-edit-token"; import { ArticleInputError, type ArticleSaveResult, @@ -23,11 +25,15 @@ import { PERMS } from "@/lib/permissions"; import { invalidateNewsCache } from "@/lib/services/news-cache"; import { notify } from "@/lib/services/webhook"; -async function uniqueSlug(title: string, excludeId?: bigint): Promise { +async function uniqueSlug( + title: string, + excludeId?: bigint, + connection: Pick = db, +): Promise { const base = slugify(title); let slug = base; for (let n = 2; ; n++) { - const [existing] = await db + const [existing] = await connection .select({ id: WebsiteArticles.id }) .from(WebsiteArticles) .where( @@ -105,39 +111,54 @@ export async function updateArticle( if (!id) return { ok: false, error: t("articleNotFound") }; let input: ReturnType; let becamePublished = false; - let slug: string; + let slug = ""; try { input = readArticleInput(formData); - const [existing] = await db - .select({ - slug: WebsiteArticles.slug, - status: WebsiteArticles.status, - publishedAt: WebsiteArticles.publishedAt, - }) - .from(WebsiteArticles) - .where(eq(WebsiteArticles.id, id)) - .limit(1); - if (!existing) return { ok: false, error: t("articleNotFound") }; - const { rawSlug, ...fields } = input; - const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug; - slug = - requestedSlug === existing.slug - ? existing.slug - : await uniqueSlug(requestedSlug, id); - becamePublished = - fields.status === "published" && existing.status !== "published"; - await db - .update(WebsiteArticles) - .set({ - ...fields, - slug, - publishedAt: - fields.status === "published" - ? (existing.publishedAt ?? new Date()) - : null, - updatedAt: new Date(), - }) - .where(eq(WebsiteArticles.id, id)); + await db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(WebsiteArticles) + .where(eq(WebsiteArticles.id, id)) + .limit(1) + .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 requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug; + slug = + requestedSlug === existing.slug + ? existing.slug + : await uniqueSlug(requestedSlug, id, tx); + becamePublished = + fields.status === "published" && existing.status !== "published"; + await tx + .update(WebsiteArticles) + .set({ + ...fields, + slug, + publishedAt: + fields.status === "published" + ? (existing.publishedAt ?? new Date()) + : null, + updatedAt: new Date(), + }) + .where(eq(WebsiteArticles.id, id)); + }); } catch (error) { return saveFailure(error, "update"); } @@ -170,6 +191,12 @@ export async function deleteArticle(formData: FormData): Promise { await tx .delete(WebsiteArticleComments) .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)); }); diff --git a/src/actions/article-recovery.test.ts b/src/actions/article-recovery.test.ts new file mode 100644 index 00000000..5e244d84 --- /dev/null +++ b/src/actions/article-recovery.test.ts @@ -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(); +}); diff --git a/src/actions/article-recovery.ts b/src/actions/article-recovery.ts new file mode 100644 index 00000000..74bccd46 --- /dev/null +++ b/src/actions/article-recovery.ts @@ -0,0 +1,110 @@ +"use server"; +import { and, desc, eq } from "drizzle-orm"; +import { ArticleDrafts, ArticleRevisions } from "@/db/article-editor"; +import { requirePermission } from "@/lib/admin/guard"; +import { + type ArticleDraft, + articleKey, + readArticleDraft, +} from "@/lib/article-draft"; +import { db, WebsiteArticles } from "@/lib/db"; +import { PERMS } from "@/lib/permissions"; + +export async function loadArticleRecovery(key: string) { + const staff = await requirePermission(PERMS.NEWS_EDIT); + key = articleKey(key); + const [draft] = await db + .select() + .from(ArticleDrafts) + .where( + and( + eq(ArticleDrafts.userId, staff.id), + eq(ArticleDrafts.articleKey, key), + ), + ) + .limit(1); + const revisions = + key === "new" + ? [] + : await db + .select() + .from(ArticleRevisions) + .where(eq(ArticleRevisions.articleId, BigInt(key))) + .orderBy(desc(ArticleRevisions.id)) + .limit(20); + return { + version: draft?.version ?? 0, + draft: + draft && draft.payload !== "{}" + ? { + version: draft.version, + payload: JSON.parse(draft.payload) as ArticleDraft, + } + : null, + revisions: revisions.map((r) => ({ + id: r.id, + createdAt: r.createdAt.toISOString(), + payload: JSON.parse(r.payload) as ArticleDraft, + })), + }; +} +export async function autosaveArticle( + key: string, + expectedVersion: number, + form: FormData, +) { + const staff = await requirePermission(PERMS.NEWS_EDIT); + key = articleKey(key); + const payload = JSON.stringify(readArticleDraft(form)); + if (!Number.isSafeInteger(expectedVersion) || expectedVersion < 0) + throw new Error("draftInvalid"); + return db.transaction(async (tx) => { + if (key !== "new") { + const [article] = await tx + .select({ id: WebsiteArticles.id }) + .from(WebsiteArticles) + .where(eq(WebsiteArticles.id, BigInt(key))) + .limit(1) + .for("update"); + if (!article) return { ok: false as const }; + } + await tx + .insert(ArticleDrafts) + .values({ userId: staff.id, articleKey: key, version: 0, payload: "{}" }) + .onDuplicateKeyUpdate({ set: { articleKey: key } }); + const where = and( + eq(ArticleDrafts.userId, staff.id), + eq(ArticleDrafts.articleKey, key), + ); + const [draft] = await tx + .select() + .from(ArticleDrafts) + .where(where) + .for("update"); + if (!draft || draft.version !== expectedVersion) + return { ok: false as const }; + await tx + .update(ArticleDrafts) + .set({ payload, version: expectedVersion + 1, updatedAt: new Date() }) + .where(where); + return { ok: true as const, version: expectedVersion + 1 }; + }); +} + +export async function clearArticleRecovery( + key: string, + expectedVersion: number, +) { + const staff = await requirePermission(PERMS.NEWS_EDIT); + key = articleKey(key); + await db + .update(ArticleDrafts) + .set({ payload: "{}", version: expectedVersion + 1, updatedAt: new Date() }) + .where( + and( + eq(ArticleDrafts.userId, staff.id), + eq(ArticleDrafts.articleKey, key), + eq(ArticleDrafts.version, expectedVersion), + ), + ); +} diff --git a/src/app/(site)/me/dashboard.module.css b/src/app/(site)/me/dashboard.module.css index f0fc6bd8..e78e712c 100644 --- a/src/app/(site)/me/dashboard.module.css +++ b/src/app/(site)/me/dashboard.module.css @@ -247,3 +247,81 @@ background: var(--color-primary); 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; + } +} diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index e05cf991..249e3caa 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -10,7 +10,7 @@ import { ProfileImage } from "@/components/shared/profile-image"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { SurfaceCard } from "@/components/surface-card"; import { auth } from "@/lib/auth"; -import { avatarImageUrl } from "@/lib/format"; +import { avatarImageUrl, slugify } from "@/lib/format"; import { loadUserDashboard } from "@/lib/services/user-dashboard"; import CopyReferralButton from "./CopyReferralButton"; import styles from "./dashboard.module.css"; @@ -75,10 +75,11 @@ export default async function MePage({ referralsRows, friends, currencyRows, + dashboardEvent, } = data; const userSettings = userSettingsRows[0]; const friendCount = Number(friendCountRows[0]?.value ?? 0); - const unreadCount = Number(unreadCountRows[0]?.value ?? 0); + const unreadCount = Number(unreadCountRows?.[0]?.value ?? 0); const needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5); const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0; const rewardCurrency = ( @@ -173,12 +174,20 @@ export default async function MePage({ { href: "/shop", label: t("shop"), count: 0 }, ].map((link) => ( - {link.label} + + {link.label} + {link.href === "/messages" && ( + + {unreadCountRows === null + ? t("messagesUnavailable") + : unreadCount > 0 + ? t("unread", { count: unreadCount }) + : t("messagesCaughtUp")} + + )} + {link.count > 0 && ( - + )} @@ -199,6 +208,48 @@ export default async function MePage({ ))} + + {dashboardEvent.unavailable ? ( +

+ {t("eventsUnavailable")} +

+ ) : dashboardEvent.event ? ( +
+
+ + {t( + dashboardEvent.event.phase === "ongoing" + ? "eventOngoing" + : "eventUpcoming", + )} + +

{dashboardEvent.event.title}

+

+ {dashboardEvent.event.typeName} ·{" "} + +

+
+ + {t("eventDetails")} → + +
+ ) : ( +

{t("noUpcomingEvents")}

+ )} +

{t("roomsHint")}

- {recentRooms.length === 0 ? ( + {recentRooms === null ? ( +

+ {t("roomsUnavailable")} +

+ ) : recentRooms.length === 0 ? (

{t("noRooms")}

) : (
diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx index 2abd7831..9bac5f25 100644 --- a/src/app/admin/articles/[id]/page.tsx +++ b/src/app/admin/articles/[id]/page.tsx @@ -6,6 +6,7 @@ import { deleteArticle, updateArticle } from "@/actions/admin-articles"; import { ArticleForm } from "@/components/admin/article-form"; import Link from "@/components/link"; import { Button } from "@/components/ui/button"; +import { articleEditToken } from "@/lib/article-edit-token"; import { db, WebsiteArticles } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -62,6 +63,8 @@ export default async function EditArticle({

) : null} { "use server"; fd.set("id", String(article.id)); diff --git a/src/app/admin/articles/list-submit.tsx b/src/app/admin/articles/list-submit.tsx new file mode 100644 index 00000000..49411266 --- /dev/null +++ b/src/app/admin/articles/list-submit.tsx @@ -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 ( + + ); +} diff --git a/src/app/admin/articles/page.tsx b/src/app/admin/articles/page.tsx index caabe346..9d90ea89 100644 --- a/src/app/admin/articles/page.tsx +++ b/src/app/admin/articles/page.tsx @@ -1,107 +1,171 @@ -import { desc, inArray } from "drizzle-orm"; import { Newspaper } from "lucide-react"; +import Form from "next/form"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { ArticleCard } from "@/components/admin/article-card"; -import { StatusCard } from "@/components/admin/dashboard"; import Link from "@/components/link"; import { Button } from "@/components/ui/button"; -import { db, User, WebsiteArticles } from "@/lib/db"; -import { formatDate } from "@/lib/format-date"; +import { Input } from "@/components/ui/input"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; +import { loadArticleList } from "@/lib/services/article-list"; +import { ArticleListSubmit } from "./list-submit"; export default async function AdminArticles({ searchParams, }: { - searchParams: Promise<{ error?: string }>; + searchParams: Promise<{ + error?: string; + search?: string; + status?: string; + page?: string; + }>; }) { const { session, permissions } = await getAdminContext(); - if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) { + if (!canAccess(permissions, PERMS.NEWS_VIEW, session.user.rank)) redirect("/admin"); - } - - const { error } = await searchParams; + const params = await searchParams; const t = await getTranslations("pages.admin.articles"); - const articles = await db - .select({ - id: WebsiteArticles.id, - title: WebsiteArticles.title, - slug: WebsiteArticles.slug, - image: WebsiteArticles.image, - createdAt: WebsiteArticles.createdAt, - userId: WebsiteArticles.userId, - }) - .from(WebsiteArticles) - .orderBy(desc(WebsiteArticles.createdAt)) - .limit(50) - .catch(() => []); - - const authorMap = new Map(); - const authorIds = articles - .map((a) => a.userId) - .filter((id): id is number => Boolean(id)); - if (authorIds.length > 0) { - const authors = await db - .select({ id: User.id, username: User.username }) - .from(User) - .where(inArray(User.id, authorIds)) - .catch(() => []); - for (const u of authors) authorMap.set(u.id, u.username); + let result: Awaited> | null = null; + try { + result = await loadArticleList({ + status: params.status, + search: params.search, + page: Number(params.page ?? 1), + }); + } catch { + /* Render a retry state instead of an empty article list. */ } - - const latest = articles[0]?.createdAt - ? formatDate(articles[0].createdAt, "date") - : "—"; - + const status = + result?.status ?? + (["draft", "published", "scheduled"].includes(params.status ?? "") + ? params.status + : "all"); + const search = result?.search ?? (params.search ?? "").slice(0, 191); + const href = (page: number) => + `/admin/articles?${new URLSearchParams({ status: status ?? "all", search, page: String(page) })}`; return ( - {t("newArticle")} - + canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank) ? ( + + ) : undefined } > - {error ? ( -

Error: {error}

+ {params.error ? ( +

+ {t("listActionError")} +

) : null} - -
- - -
- -
-

{t("recentArticles")}

- {articles.length === 0 ? ( -
{t("noArticles")}
- ) : ( -
- {articles.map((a) => ( - +
+ + + + + + {!result ? ( +
+

{t("listUnavailable")}

+ + {t("listRetry")} + +
+ ) : ( + <> +

+ {t("listTotal", { count: result.total })} +

+ {result.rows.length === 0 ? ( +
+ {search || status !== "all" + ? t("listNoMatches") + : t("noArticles")} +
+ ) : ( +
+ {result.rows.map((article) => ( +
+ + {["draft", "published", "scheduled"].includes( + article.status, + ) + ? t(`listStatus_${article.status}`) + : article.status} + + +
+ ))} +
+ )} + + + )} ); } diff --git a/src/app/admin/commandocentrum/page.tsx b/src/app/admin/commandocentrum/page.tsx index 80b18a82..f6be8ef9 100644 --- a/src/app/admin/commandocentrum/page.tsx +++ b/src/app/admin/commandocentrum/page.tsx @@ -10,6 +10,7 @@ import { OnlineUsersWidget, StatusCard, } from "@/components/admin/dashboard"; +import { OperationsInbox } from "@/components/admin/operations-inbox"; import Link from "@/components/link"; import { env } from "@/env"; import { fetchOpsHealth } from "@/lib/admin/ops-health"; @@ -126,6 +127,7 @@ export default async function CommandoCentrum() { ) : null}
+ {/* ── Live status ────────────────────────────────────────── */}
({ guard: vi.fn(), resolve: vi.fn(), + assign: vi.fn(), audit: vi.fn(), })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard })); vi.mock("@/lib/error-monitor", () => ({ ErrorStore: class { resolve = mocks.resolve; + assign = mocks.assign; }, })); vi.mock("@/lib/permissions", () => ({ @@ -17,7 +19,7 @@ vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit })); 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 () => { 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" }), ); }); + +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(); +}); diff --git a/src/app/admin/devops/cms-errors/actions.ts b/src/app/admin/devops/cms-errors/actions.ts index ae8edacc..d8bab9e4 100644 --- a/src/app/admin/devops/cms-errors/actions.ts +++ b/src/app/admin/devops/cms-errors/actions.ts @@ -15,3 +15,20 @@ export async function resolveCmsError(data: FormData) { }); 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"); +} diff --git a/src/app/admin/devops/cms-errors/page.tsx b/src/app/admin/devops/cms-errors/page.tsx index 375ab3fa..ef45e737 100644 --- a/src/app/admin/devops/cms-errors/page.tsx +++ b/src/app/admin/devops/cms-errors/page.tsx @@ -1,21 +1,35 @@ import { redirect } from "next/navigation"; + import { getTranslations } from "next-intl/server"; + import Link from "@/components/link"; + +import { summarizeErrorGroup } from "@/lib/error-groups"; + import { ErrorStore } from "@/lib/error-monitor"; + import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; -import { resolveCmsError } from "./actions"; + +import { assignCmsError, resolveCmsError } from "./actions"; + export default async function CmsErrorsPage({ searchParams, }: { searchParams: Promise>; }) { const { session, permissions } = await getAdminContext(); + if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) redirect("/admin"); + const t = await getTranslations("pages.admin.cmsErrors"); + const params = await searchParams; + const query = (params.q ?? "").trim().slice(0, 200).toLowerCase(); + let result: Awaited>; + try { result = await new ErrorStore().read(); } catch { @@ -25,28 +39,48 @@ export default async function CmsErrorsPage({
); } - const filtered = result.records.filter( - (r) => - (!params.source || r.source === params.source) && - (params.status !== "open" || !r.resolved) && - (params.status !== "resolved" || r.resolved) && - (!query || JSON.stringify(r).toLowerCase().includes(query)), - ); - const groups = new Map(); - for (const r of filtered) { - const list = groups.get(r.fingerprint) ?? []; - list.push(r); - groups.set(r.fingerprint, list); + + const allGroups = new Map(); + + for (const record of result.records) { + const events = allGroups.get(record.fingerprint) ?? []; + + events.push(record); + allGroups.set(record.fingerprint, events); } + + const groups = new Map( + [...allGroups.entries()].filter(([, events]) => { + const latest = summarizeErrorGroup(events).latest; + + return ( + latest && + (!params.source || latest.source === params.source) && + (params.status !== "open" || !latest.resolved) && + (params.status !== "resolved" || latest.resolved) && + (!query || + events.some((record) => + JSON.stringify(record).toLowerCase().includes(query), + )) + ); + }), + ); + + const filtered = [...groups.values()].flat(); + const page = Math.max( 1, + Math.min( Number(params.page) || 1, + Math.max(1, Math.ceil(groups.size / 25)), ), ); + const pageHref = (next: number) => `?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`; + return (
@@ -104,9 +138,16 @@ export default async function CmsErrorsPage({
{t("empty")}
) : null} {[...groups.entries()] + .slice((page - 1) * 25, page * 25) + .map(([fingerprint, events]) => { - const r = events[0]; + const metrics = summarizeErrorGroup(events); + + const r = metrics.latest; + + if (!r) return null; + return (
@@ -118,6 +159,105 @@ export default async function CmsErrorsPage({

{r.message}

+
+
+
{t("firstSeen")}
+
+ +
+
+
+
{t("lastSeen")}
+
+ +
+
+
+
+ {t("affectedUsers")} +
+
{metrics.affectedUsers}
+
+
+
+ {t("unidentifiedEvents")} +
+
{metrics.unidentified}
+
+
+

+ {t("metricsScope")} +

+

+ {r.assignment + ? t("assignedStaff", { id: r.assignment.userId }) + : t("unassigned")} +

+ {canAccess( + permissions, + PERMS.DEVOPS_EDIT, + session.user.rank, + ) && ( +
+ + {r.assignment?.userId !== session.user.id && ( + + )} + {r.assignment && ( + + )} +
+ )} + {metrics.operationLink && ( + + {t("openOperation")} + + )} +
+ + {t("releaseOccurrences")} + +
    + {metrics.releases.map((release) => ( +
  • + {release.release} ·{" "} + {t("occurrences", { count: release.count })} ·{" "} + {release.firstAt} — {release.lastAt} +
  • + ))} +
+

+ {t("releaseScope")} +

+

{t("reference")}: {r.id}

diff --git a/src/app/admin/devops/installation/page.test.ts b/src/app/admin/devops/installation/page.test.ts new file mode 100644 index 00000000..ed4630ee --- /dev/null +++ b/src/app/admin/devops/installation/page.test.ts @@ -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(); +}); diff --git a/src/app/admin/devops/installation/page.tsx b/src/app/admin/devops/installation/page.tsx new file mode 100644 index 00000000..b29e123e --- /dev/null +++ b/src/app/admin/devops/installation/page.tsx @@ -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 ( +
+

{t("title")}

+

{t("hint")}

+

+ {t("checked", { + time: formatDate(new Date(result.checkedAt), "datetime-seconds"), + ms: result.durationMs, + })} +

+ +
+ {result.rows.map((row) => ( +
+

{t(row.key)}

+

+ {t(row.state)} +

+ {row.detail ? ( + {row.detail} + ) : null} +

+ {t(`${row.key}Hint`)} +

+
+ ))} +
+
+ ); +} diff --git a/src/app/admin/devops/page.tsx b/src/app/admin/devops/page.tsx index dabff00c..8c78f215 100644 --- a/src/app/admin/devops/page.tsx +++ b/src/app/admin/devops/page.tsx @@ -53,12 +53,16 @@ export default async function DevOpsPage() { const t = await getTranslations("pages.admin.devops"); const data = await getDevOpsData(); + const installation = await getTranslations("pages.admin.installation"); return (
{t("cmsErrors")} + + {installation("title")} + {/* Status cards */}
diff --git a/src/app/admin/logs/audit/audit-filters.tsx b/src/app/admin/logs/audit/audit-filters.tsx new file mode 100644 index 00000000..bf50ad49 --- /dev/null +++ b/src/app/admin/logs/audit/audit-filters.tsx @@ -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) { + 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 ( +
+
+ + {t("filtersTitle")} + +
+ {keys.map((key) => ( + + ))} +
+

{t("filtersHint")}

+
+ + +
+
+
+ ); +} diff --git a/src/app/admin/logs/audit/audit-table.tsx b/src/app/admin/logs/audit/audit-table.tsx index 4c35159d..40fe6372 100644 --- a/src/app/admin/logs/audit/audit-table.tsx +++ b/src/app/admin/logs/audit/audit-table.tsx @@ -1,11 +1,12 @@ "use client"; +import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useState } from "react"; import { DataTable } from "@/components/admin/data-table"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; +import { formatAuditValue, readAuditChanges } from "@/lib/services/audit-diff"; import type { DataTableColumn, PaginatedResult } from "@/types/common"; +import { AuditFilters } from "./audit-filters"; interface AuditRow { id: number; @@ -14,75 +15,73 @@ interface AuditRow { target: string; targetId: number | null; diff: string | null; + before: string | null; + after: string | null; createdAt: string; } -function DiffCell({ diff }: { diff: string | null }) { +function DiffCell({ row }: { row: AuditRow }) { const t = useTranslations("pages.admin.logs"); - const [open, setOpen] = useState(false); - if (!diff) + const { changes, invalid } = readAuditChanges( + row.diff, + row.before, + row.after, + ); + if (invalid) + return ( + + {t("audit.invalidDetails")} + + ); + if (!changes.length) return {t("emptyValue")}; - - let parsed: Record; - try { - parsed = JSON.parse(diff); - } catch { - return {t("emptyValue")}; - } - - const keys = Object.keys(parsed); - if (keys.length === 0) - return {t("emptyValue")}; - - const summary = - keys.length === 1 - ? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}` - : t("audit.changesCount", { count: keys.length }); - return ( -
- - {open && ( -
- {keys.map((k) => ( -
- {k}: - - {String(parsed[k].from ?? "∅")} - - → - - {String(parsed[k].to ?? "∅")} - +
+ + {t("audit.changesCount", { count: changes.length })} + +
+ {changes.map((change) => ( +
+

{change.key}

+
+
+ + {t("audit.beforeValue")} + +
+									{formatAuditValue(change.from)}
+								
+
+
+ + {t("audit.afterValue")} + +
+									{formatAuditValue(change.to)}
+								
+
- ))} -
- )} -
+
+ ))} +
+
); } - interface AuditTableProps { data: PaginatedResult; } export function AuditTable({ data }: AuditTableProps) { + const params = useSearchParams(); const t = useTranslations("pages.admin.logs"); const columns: DataTableColumn[] = [ - { key: "id", label: t("colId"), sortable: true }, - { key: "username", label: t("colUser"), sortable: true }, + { key: "id", label: t("colId") }, + { key: "username", label: t("colUser") }, { key: "action", label: t("colAction"), - sortable: true, render: (value) => { const v = String(value); let variant: "default" | "secondary" | "destructive" = "secondary"; @@ -92,7 +91,7 @@ export function AuditTable({ data }: AuditTableProps) { return {v}; }, }, - { key: "target", label: t("colTarget"), sortable: true }, + { key: "target", label: t("colTarget") }, { key: "targetId", label: t("audit.colTargetId"), @@ -103,12 +102,11 @@ export function AuditTable({ data }: AuditTableProps) { { key: "diff", label: t("audit.colDetails"), - render: (_value, row) => , + render: (_value, row) => , }, { key: "createdAt", label: t("audit.colDate"), - sortable: true, render: (value) => ( {String(value) || t("audit.notAvailable")} ), @@ -116,11 +114,15 @@ export function AuditTable({ data }: AuditTableProps) { ]; return ( - +
+ +

{t("audit.exportHint")}

+ +
); } diff --git a/src/app/admin/logs/audit/page.tsx b/src/app/admin/logs/audit/page.tsx index f84e5355..8104a649 100644 --- a/src/app/admin/logs/audit/page.tsx +++ b/src/app/admin/logs/audit/page.tsx @@ -21,7 +21,15 @@ export default async function AuditLogsPage({ const sp = new URLSearchParams(params); 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); @@ -32,6 +40,8 @@ export default async function AuditLogsPage({ target: r.target, targetId: r.targetId, diff: r.diff, + before: r.before, + after: r.after, createdAt: r.createdAt, })); diff --git a/src/app/api/admin/logs/audit/export/route.test.ts b/src/app/api/admin/logs/audit/export/route.test.ts new file mode 100644 index 00000000..d38b312d --- /dev/null +++ b/src/app/api/admin/logs/audit/export/route.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + options: {} as Record, + 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, 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]"); + }); +}); diff --git a/src/app/api/admin/logs/audit/export/route.ts b/src/app/api/admin/logs/audit/export/route.ts new file mode 100644 index 00000000..f38ec28a --- /dev/null +++ b/src/app/api/admin/logs/audit/export/route.ts @@ -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", + }, + }, + ); + }, +); diff --git a/src/app/api/admin/studio/import-jobs/route.test.ts b/src/app/api/admin/studio/import-jobs/route.test.ts index f18a0dfc..9b1d15f0 100644 --- a/src/app/api/admin/studio/import-jobs/route.test.ts +++ b/src/app/api/admin/studio/import-jobs/route.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ guard: vi.fn(), create: vi.fn(), list: vi.fn(), + cancel: vi.fn(), after: vi.fn(), ping: vi.fn(), source: vi.fn(), @@ -25,15 +26,17 @@ vi.mock("@/lib/redis", () => ({ redis: { ping: mocks.ping } })); vi.mock("@/lib/services/furni-job-worker", () => ({ drainFurnitureImports: vi.fn(), })); -vi.mock("@/lib/services/furni-job-store", () => ({ +vi.mock("@/lib/services/furni-job-store", async (original) => ({ + ...(await original()), ImportJobStore: class { create = mocks.create; list = mocks.list; + requestCancellation = mocks.cancel; }, })); vi.mock("@/lib/services/clone-sources", () => ({ getSource: mocks.source })); -import { GET, POST } from "./route"; +import { GET, PATCH, POST } from "./route"; const item = { id: 1, @@ -106,11 +109,23 @@ it("rejects a missing configured source", async () => { ).status, ).toBe(404); }); -it("only returns the current operators history", async () => { - mocks.list.mockResolvedValue([ - { id: "a", userId: 7 }, - { id: "b", userId: 9 }, - ]); +it("requests bounded owner-scoped history from the store", async () => { + mocks.list.mockResolvedValue([{ id: "a", userId: 7 }]); const response = await GET(request({}), ctx); + expect(mocks.list).toHaveBeenCalledWith({ userId: 7, limit: 30 }); 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); +}); diff --git a/src/app/api/admin/studio/import-jobs/route.ts b/src/app/api/admin/studio/import-jobs/route.ts index bbd1b230..7b28b459 100644 --- a/src/app/api/admin/studio/import-jobs/route.ts +++ b/src/app/api/admin/studio/import-jobs/route.ts @@ -6,7 +6,7 @@ import { validateClassnames } from "@/lib/furni/studio-inspection"; import { PERMS } from "@/lib/permission-slugs"; import { redis } from "@/lib/redis"; 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"; const schema = z.object({ @@ -38,10 +38,10 @@ export const GET = withAdmin( { permission: PERMS.ASSETS_IMPORT }, async (_request, ctx) => { after(drainFurnitureImports); - const jobs = (await new ImportJobStore().list()) - .filter((job) => job.userId === ctx.session.user.id) - .reverse() - .slice(0, 30); + const jobs = await new ImportJobStore().list({ + userId: ctx.session.user.id, + limit: 30, + }); return apiOk({ jobs }); }, ); @@ -82,3 +82,18 @@ export const POST = withAdmin( 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 }); + }, +); diff --git a/src/components/admin/article-form.tsx b/src/components/admin/article-form.tsx index 44b03645..459a711d 100644 --- a/src/components/admin/article-form.tsx +++ b/src/components/admin/article-form.tsx @@ -4,18 +4,24 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useUnsavedChanges } from "@/hooks/use-unsaved-changes"; +import type { ArticleDraft } from "@/lib/article-draft"; import type { ArticleSaveResult } from "@/lib/article-input"; import { slugify } from "@/lib/format"; import { ArticlePreview, type ArticlePreviewData } from "./article-preview"; +import { useArticleRecovery } from "./article-recovery"; import { MediaPicker } from "./media-picker"; import { RichText } from "./rich-text"; export function ArticleForm({ action, defaultValues, + articleKey = "new", + baseToken = "", }: { action: (formData: FormData) => Promise; edit?: boolean; + articleKey?: string; + baseToken?: string; defaultValues?: { title?: string; slug?: string; @@ -55,6 +61,31 @@ export function ArticleForm({ }, [defaultValues?.publishAt]); const formRef = useRef(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(null); const suggestedSlug = useMemo(() => slugify(title), [title]); @@ -76,12 +107,14 @@ export function ArticleForm({ setSaveError(null); startTransition(async () => { try { + await recovery.wait(); const result = await action(data); if (result && !result.ok) { setSaveError(result.error); return; } if (editVersion.current === submittedVersion) setDirty(false); + if (result?.ok) await recovery.clear().catch(() => {}); if (result?.ok && result.data?.redirectTo) router.push(result.data.redirectTo); } catch (error) { @@ -104,6 +137,8 @@ export function ArticleForm({ style={{ display: "grid", gap: "1rem" }} >
+ + {recovery.panel} {saveError && (

{saveError} @@ -193,7 +228,8 @@ export function ArticleForm({