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
+8137 -703

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
@@ -84,13 +84,13 @@ importers:
version: 3.24.3(@types/[email protected]) version: 3.24.3(@types/[email protected])
next: next:
specifier: 16.3.4 specifier: 16.3.4
version: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected]) version: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-auth: next-auth:
specifier: 5.0.0-beta.32 specifier: 5.0.0-beta.32
version: 5.0.0-beta.32([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]) version: 5.0.0-beta.32([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
next-intl: next-intl:
specifier: 4.14.2 specifier: 4.14.2
version: 4.14.2(@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]) version: 4.14.2(@swc/[email protected])([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected])
otplib: otplib:
specifier: 13.5.0 specifier: 13.5.0
version: 13.5.0 version: 13.5.0
@@ -134,6 +134,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.12 specifier: 2.5.12
version: 2.5.12 version: 2.5.12
'@playwright/test':
specifier: 1.62.1
version: 1.62.1
'@tailwindcss/forms': '@tailwindcss/forms':
specifier: 0.5.11 specifier: 0.5.11
version: 0.5.11([email protected]) version: 0.5.11([email protected])
@@ -1177,6 +1180,11 @@ packages:
'@pinojs/[email protected]': '@pinojs/[email protected]':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@playwright/[email protected]':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
hasBin: true
'@radix-ui/[email protected]': '@radix-ui/[email protected]':
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
@@ -2114,6 +2122,11 @@ packages:
react-dom: react-dom:
optional: true optional: true
[email protected]:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]: [email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2548,6 +2561,16 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
engines: {node: '>=20'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
engines: {node: '>=20'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==}
@@ -3651,6 +3674,10 @@ snapshots:
'@pinojs/[email protected]': {} '@pinojs/[email protected]': {}
'@playwright/[email protected]':
dependencies:
playwright: 1.62.1
'@radix-ui/[email protected]': {} '@radix-ui/[email protected]': {}
'@radix-ui/[email protected](@types/[email protected])([email protected])': '@radix-ui/[email protected](@types/[email protected])([email protected])':
@@ -4325,6 +4352,9 @@ snapshots:
react: 19.2.8 react: 19.2.8
react-dom: 19.2.8([email protected]) react-dom: 19.2.8([email protected])
[email protected]:
optional: true
[email protected]: [email protected]:
optional: true optional: true
@@ -4633,15 +4663,15 @@ snapshots:
dependencies: dependencies:
content-type: 2.1.0 content-type: 2.1.0
[email protected]([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]): [email protected]([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies: dependencies:
'@auth/core': 0.41.3 '@auth/core': 0.41.3
next: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected]) next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.8 react: 19.2.8
[email protected]: {} [email protected]: {}
[email protected](@swc/[email protected])([email protected](@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]): [email protected](@swc/[email protected])([email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]))([email protected]):
dependencies: dependencies:
'@eloqnt/config': 0.1.0 '@eloqnt/config': 0.1.0
'@eloqnt/format-json': 0.1.0 '@eloqnt/format-json': 0.1.0
@@ -4651,14 +4681,14 @@ snapshots:
'@swc/core': 1.16.2(@swc/[email protected]) '@swc/core': 1.16.2(@swc/[email protected])
icu-minify: 4.14.2 icu-minify: 4.14.2
negotiator: 1.1.0 negotiator: 1.1.0
next: 16.3.4(@types/[email protected])([email protected]([email protected]))([email protected]) next: 16.3.4(@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
next-intl-swc-plugin-extractor: 4.14.2 next-intl-swc-plugin-extractor: 4.14.2
react: 19.2.8 react: 19.2.8
use-intl: 4.14.2([email protected]) use-intl: 4.14.2([email protected])
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
[email protected](@types/[email protected])([email protected]([email protected]))([email protected]): [email protected](@playwright/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected]):
dependencies: dependencies:
'@next/env': 16.3.4 '@next/env': 16.3.4
'@swc/helpers': 0.5.23 '@swc/helpers': 0.5.23
@@ -4677,6 +4707,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.4 '@next/swc-linux-x64-musl': 16.3.4
'@next/swc-win32-arm64-msvc': 16.3.4 '@next/swc-win32-arm64-msvc': 16.3.4
'@next/swc-win32-x64-msvc': 16.3.4 '@next/swc-win32-x64-msvc': 16.3.4
'@playwright/test': 1.62.1
sharp: 0.35.4(@types/[email protected]) sharp: 0.35.4(@types/[email protected])
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -4750,6 +4781,14 @@ snapshots:
sonic-boom: 4.2.1 sonic-boom: 4.2.1
thread-stream: 4.2.0 thread-stream: 4.2.0
[email protected]: {}
[email protected]:
dependencies:
playwright-core: 1.62.1
optionalDependencies:
fsevents: 2.3.2
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
+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" })))
+60 -33
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,39 +111,54 @@ 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, .from(WebsiteArticles)
publishedAt: WebsiteArticles.publishedAt, .where(eq(WebsiteArticles.id, id))
}) .limit(1)
.from(WebsiteArticles) .for("update");
.where(eq(WebsiteArticles.id, id)) if (!existing) throw new ArticleInputError("articleNotFound");
.limit(1); if (formData.get("baseToken") !== articleEditToken(existing))
if (!existing) return { ok: false, error: t("articleNotFound") }; throw new ArticleInputError("editConflict");
const { rawSlug, ...fields } = input; await tx.insert(ArticleRevisions).values({
const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug; articleId: id,
slug = userId: staff.id,
requestedSlug === existing.slug payload: JSON.stringify({
? existing.slug title: existing.title,
: await uniqueSlug(requestedSlug, id); slug: existing.slug,
becamePublished = image: existing.image,
fields.status === "published" && existing.status !== "published"; shortStory: existing.shortStory,
await db fullStory: existing.fullStory,
.update(WebsiteArticles) status: existing.status,
.set({ publishAt: existing.publishAt?.toISOString() ?? "",
...fields, baseToken: "",
slug, }),
publishedAt: });
fields.status === "published" const { rawSlug, ...fields } = input;
? (existing.publishedAt ?? new Date()) const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug;
: null, slug =
updatedAt: new Date(), requestedSlug === existing.slug
}) ? existing.slug
.where(eq(WebsiteArticles.id, id)); : 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) { } 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();
});
+110
View File
@@ -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),
),
);
}
+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;
}
}
+63 -8
View File
@@ -10,7 +10,7 @@ import { ProfileImage } from "@/components/shared/profile-image";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { SurfaceCard } from "@/components/surface-card"; import { SurfaceCard } from "@/components/surface-card";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { avatarImageUrl } from "@/lib/format"; import { avatarImageUrl, slugify } from "@/lib/format";
import { loadUserDashboard } from "@/lib/services/user-dashboard"; import { loadUserDashboard } from "@/lib/services/user-dashboard";
import CopyReferralButton from "./CopyReferralButton"; import CopyReferralButton from "./CopyReferralButton";
import styles from "./dashboard.module.css"; import styles from "./dashboard.module.css";
@@ -75,10 +75,11 @@ export default async function MePage({
referralsRows, referralsRows,
friends, friends,
currencyRows, currencyRows,
dashboardEvent,
} = data; } = data;
const userSettings = userSettingsRows[0]; const userSettings = userSettingsRows[0];
const friendCount = Number(friendCountRows[0]?.value ?? 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 needed = Math.max(1, Number.parseInt(neededRaw ?? "5", 10) || 5);
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0; const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
const rewardCurrency = ( const rewardCurrency = (
@@ -173,12 +174,20 @@ export default async function MePage({
{ href: "/shop", label: t("shop"), count: 0 }, { href: "/shop", label: t("shop"), count: 0 },
].map((link) => ( ].map((link) => (
<Link key={link.href} href={link.href} className={styles.shortcut}> <Link key={link.href} href={link.href} className={styles.shortcut}>
{link.label} <span>
{link.label}
{link.href === "/messages" && (
<span className={styles.shortcutHint}>
{unreadCountRows === null
? t("messagesUnavailable")
: unreadCount > 0
? t("unread", { count: unreadCount })
: t("messagesCaughtUp")}
</span>
)}
</span>
{link.count > 0 && ( {link.count > 0 && (
<span <span className={styles.count} aria-hidden="true">
className={styles.count}
title={t("unread", { count: link.count })}
>
{link.count > 99 ? "99+" : link.count} {link.count > 99 ? "99+" : link.count}
</span> </span>
)} )}
@@ -199,6 +208,48 @@ export default async function MePage({
</SurfaceCard> </SurfaceCard>
))} ))}
</section> </section>
<SurfaceCard
title={t("eventTitle")}
actionHref="/events"
actionLabel={t("allEvents")}
bodyClassName="p-5"
>
{dashboardEvent.unavailable ? (
<p className="muted" role="status">
{t("eventsUnavailable")}
</p>
) : dashboardEvent.event ? (
<div className={styles.event}>
<div className={styles.eventDetails}>
<span className={styles.eventPhase}>
{t(
dashboardEvent.event.phase === "ongoing"
? "eventOngoing"
: "eventUpcoming",
)}
</span>
<h2>{dashboardEvent.event.title}</h2>
<p className="muted">
{dashboardEvent.event.typeName} ·{" "}
<time dateTime={dashboardEvent.event.startsAt.toISOString()}>
{format.dateTime(dashboardEvent.event.startsAt, {
dateStyle: "medium",
timeStyle: "short",
})}
</time>
</p>
</div>
<Link
href={`/events/${slugify(dashboardEvent.event.title)}-${dashboardEvent.event.id}`}
className="btn btn-outline"
>
{t("eventDetails")} →
</Link>
</div>
) : (
<p className="muted">{t("noUpcomingEvents")}</p>
)}
</SurfaceCard>
<div className={styles.columns}> <div className={styles.columns}>
<div className={styles.stack}> <div className={styles.stack}>
<SurfaceCard <SurfaceCard
@@ -236,7 +287,11 @@ export default async function MePage({
bodyClassName="p-5" bodyClassName="p-5"
> >
<p className="muted">{t("roomsHint")}</p> <p className="muted">{t("roomsHint")}</p>
{recentRooms.length === 0 ? ( {recentRooms === null ? (
<p className="muted" role="status">
{t("roomsUnavailable")}
</p>
) : recentRooms.length === 0 ? (
<p className="muted">{t("noRooms")}</p> <p className="muted">{t("noRooms")}</p>
) : ( ) : (
<div className={styles.rooms}> <div className={styles.rooms}>
+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>
);
}
+141 -77
View File
@@ -1,107 +1,171 @@
import { desc, inArray } from "drizzle-orm";
import { Newspaper } from "lucide-react"; import { Newspaper } from "lucide-react";
import Form from "next/form";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { AdminPageShell } from "@/components/admin/admin-page-shell"; import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { ArticleCard } from "@/components/admin/article-card"; import { ArticleCard } from "@/components/admin/article-card";
import { StatusCard } from "@/components/admin/dashboard";
import Link from "@/components/link"; import Link from "@/components/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { db, User, WebsiteArticles } from "@/lib/db"; import { Input } from "@/components/ui/input";
import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { loadArticleList } from "@/lib/services/article-list";
import { ArticleListSubmit } from "./list-submit";
export default async function AdminArticles({ export default async function AdminArticles({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{
error?: string;
search?: string;
status?: string;
page?: string;
}>;
}) { }) {
const { session, permissions } = await getAdminContext(); 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"); redirect("/admin");
} const params = await searchParams;
const { error } = await searchParams;
const t = await getTranslations("pages.admin.articles"); const t = await getTranslations("pages.admin.articles");
const articles = await db let result: Awaited<ReturnType<typeof loadArticleList>> | null = null;
.select({ try {
id: WebsiteArticles.id, result = await loadArticleList({
title: WebsiteArticles.title, status: params.status,
slug: WebsiteArticles.slug, search: params.search,
image: WebsiteArticles.image, page: Number(params.page ?? 1),
createdAt: WebsiteArticles.createdAt, });
userId: WebsiteArticles.userId, } catch {
}) /* Render a retry state instead of an empty article list. */
.from(WebsiteArticles)
.orderBy(desc(WebsiteArticles.createdAt))
.limit(50)
.catch(() => []);
const authorMap = new Map<number, string>();
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);
} }
const status =
const latest = articles[0]?.createdAt result?.status ??
? formatDate(articles[0].createdAt, "date") (["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 ( return (
<AdminPageShell <AdminPageShell
icon={Newspaper} icon={Newspaper}
title={t("title")} title={t("title")}
subtitle={t("subtitle")} subtitle={t("subtitle")}
actions={ actions={
<Button variant="default" asChild> canAccess(permissions, PERMS.NEWS_EDIT, session.user.rank) ? (
<Link href="/admin/articles/new">{t("newArticle")}</Link> <Button asChild>
</Button> <Link href="/admin/articles/new">{t("newArticle")}</Link>
</Button>
) : undefined
} }
> >
{error ? ( {params.error ? (
<p className="text-xs text-[var(--admin-error)] mb-4">Error: {error}</p> <p role="alert" className="text-sm text-[var(--admin-error)] mb-4">
{t("listActionError")}
</p>
) : null} ) : null}
<Form
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6"> action="/admin/articles"
<StatusCard className="admin-card mb-5 flex flex-wrap items-end gap-3"
label={t("articlesShown")} key={`${status}:${search}`}
value={articles.length} >
icon="📰" <label htmlFor="article-status" className="grid gap-1 text-sm">
/> <span>{t("listStatus")}</span>
<StatusCard <select
label={t("latest")} id="article-status"
value={latest} name="status"
hint={t("mostRecentPost")} defaultValue={status}
icon="🕒" className="h-9 rounded-md border bg-[var(--admin-surface)] px-3"
/> >
</div> {["all", "draft", "published", "scheduled"].map((value) => (
<option value={value} key={value}>
<section className="mt-2"> {t(`listStatus_${value}`)}
<h2 className="admin-section-title">{t("recentArticles")}</h2> </option>
{articles.length === 0 ? (
<div className="admin-empty">{t("noArticles")}</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-4">
{articles.map((a) => (
<ArticleCard
key={String(a.id)}
id={String(a.id)}
title={a.title}
image={a.image}
createdAt={a.createdAt ? a.createdAt.toISOString() : null}
author={authorMap.get(a.userId ?? -1)}
/>
))} ))}
</div> </select>
)} </label>
</section> <label
htmlFor="article-search"
className="grid flex-1 min-w-0 gap-1 text-sm"
>
<span>{t("listSearch")}</span>
<Input
id="article-search"
name="search"
defaultValue={search}
maxLength={191}
/>
</label>
<ArticleListSubmit />
<Button variant="outline" asChild>
<Link href="/admin/articles">{t("listReset")}</Link>
</Button>
</Form>
{!result ? (
<div className="admin-card space-y-3" role="alert">
<p>{t("listUnavailable")}</p>
<Link
className="btn btn-outline"
href={href(Number(params.page) || 1)}
>
{t("listRetry")}
</Link>
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
{t("listTotal", { count: result.total })}
</p>
{result.rows.length === 0 ? (
<div className="admin-empty">
{search || status !== "all"
? t("listNoMatches")
: t("noArticles")}
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,260px),1fr))] gap-4">
{result.rows.map((article) => (
<div key={String(article.id)} className="min-w-0 space-y-2">
<span className="text-xs text-[var(--admin-text-muted)]">
{["draft", "published", "scheduled"].includes(
article.status,
)
? t(`listStatus_${article.status}`)
: article.status}
</span>
<ArticleCard
id={String(article.id)}
title={article.title}
image={article.image}
createdAt={article.createdAt?.toISOString() ?? null}
author={article.author ?? undefined}
/>
</div>
))}
</div>
)}
<nav
aria-label={t("listPagination")}
className="mt-5 flex flex-wrap items-center justify-between gap-3"
>
{result.page > 1 ? (
<Link className="btn btn-outline" href={href(result.page - 1)}>
{t("listPrevious")}
</Link>
) : (
<span />
)}
<span className="text-sm">
{t("listPage", { page: result.page, total: result.lastPage })}
</span>
{result.page < result.lastPage ? (
<Link className="btn btn-outline" href={href(result.page + 1)}>
{t("listNext")}
</Link>
) : (
<span />
)}
</nav>
</>
)}
</AdminPageShell> </AdminPageShell>
); );
} }
+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");
}
+154 -14
View File
@@ -1,21 +1,35 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; import Link from "@/components/link";
import { summarizeErrorGroup } from "@/lib/error-groups";
import { ErrorStore } from "@/lib/error-monitor"; import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { resolveCmsError } from "./actions";
import { assignCmsError, resolveCmsError } from "./actions";
export default async function CmsErrorsPage({ export default async function CmsErrorsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<Record<string, string>>; searchParams: Promise<Record<string, string>>;
}) { }) {
const { session, permissions } = await getAdminContext(); const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin"); redirect("/admin");
const t = await getTranslations("pages.admin.cmsErrors"); const t = await getTranslations("pages.admin.cmsErrors");
const params = await searchParams; const params = await searchParams;
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase(); const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
let result: Awaited<ReturnType<ErrorStore["read"]>>; let result: Awaited<ReturnType<ErrorStore["read"]>>;
try { try {
result = await new ErrorStore().read(); result = await new ErrorStore().read();
} catch { } catch {
@@ -25,28 +39,48 @@ export default async function CmsErrorsPage({
</div> </div>
); );
} }
const filtered = result.records.filter(
(r) => const allGroups = new Map<string, typeof result.records>();
(!params.source || r.source === params.source) &&
(params.status !== "open" || !r.resolved) && for (const record of result.records) {
(params.status !== "resolved" || r.resolved) && const events = allGroups.get(record.fingerprint) ?? [];
(!query || JSON.stringify(r).toLowerCase().includes(query)),
); events.push(record);
const groups = new Map<string, typeof filtered>(); allGroups.set(record.fingerprint, events);
for (const r of filtered) {
const list = groups.get(r.fingerprint) ?? [];
list.push(r);
groups.set(r.fingerprint, list);
} }
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( const page = Math.max(
1, 1,
Math.min( Math.min(
Number(params.page) || 1, Number(params.page) || 1,
Math.max(1, Math.ceil(groups.size / 25)), Math.max(1, Math.ceil(groups.size / 25)),
), ),
); );
const pageHref = (next: number) => const pageHref = (next: number) =>
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`; `?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`;
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
@@ -104,9 +138,16 @@ export default async function CmsErrorsPage({
<div className="admin-card">{t("empty")}</div> <div className="admin-card">{t("empty")}</div>
) : null} ) : null}
{[...groups.entries()] {[...groups.entries()]
.slice((page - 1) * 25, page * 25) .slice((page - 1) * 25, page * 25)
.map(([fingerprint, events]) => { .map(([fingerprint, events]) => {
const r = events[0]; const metrics = summarizeErrorGroup(events);
const r = metrics.latest;
if (!r) return null;
return ( return (
<details key={fingerprint} className="admin-card overflow-hidden"> <details key={fingerprint} className="admin-card overflow-hidden">
<summary className="cursor-pointer"> <summary className="cursor-pointer">
@@ -118,6 +159,105 @@ export default async function CmsErrorsPage({
<p className="text-sm break-words">{r.message}</p> <p className="text-sm break-words">{r.message}</p>
</summary> </summary>
<div className="mt-4 space-y-3 text-sm"> <div className="mt-4 space-y-3 text-sm">
<dl className="grid gap-2 sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">{t("firstSeen")}</dt>
<dd>
<time dateTime={metrics.firstAt ?? ""}>
{metrics.firstAt}
</time>
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("lastSeen")}</dt>
<dd>
<time dateTime={metrics.lastAt ?? ""}>
{metrics.lastAt}
</time>
</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("affectedUsers")}
</dt>
<dd>{metrics.affectedUsers}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("unidentifiedEvents")}
</dt>
<dd>{metrics.unidentified}</dd>
</div>
</dl>
<p className="text-xs text-muted-foreground">
{t("metricsScope")}
</p>
<p>
{r.assignment
? t("assignedStaff", { id: r.assignment.userId })
: t("unassigned")}
</p>
{canAccess(
permissions,
PERMS.DEVOPS_EDIT,
session.user.rank,
) && (
<form
action={assignCmsError}
className="flex flex-wrap gap-2"
>
<input
type="hidden"
name="fingerprint"
value={fingerprint}
/>
{r.assignment?.userId !== session.user.id && (
<button
type="submit"
name="assignment"
value="self"
className="btn btn-outline"
>
{t("assignSelf")}
</button>
)}
{r.assignment && (
<button
type="submit"
name="assignment"
value="clear"
className="btn btn-outline"
>
{t("clearAssignment")}
</button>
)}
</form>
)}
{metrics.operationLink && (
<Link
href={metrics.operationLink}
className="btn btn-outline"
>
{t("openOperation")}
</Link>
)}
<details>
<summary className="cursor-pointer">
{t("releaseOccurrences")}
</summary>
<ul className="mt-2 space-y-2">
{metrics.releases.map((release) => (
<li key={release.release} className="break-all">
<code>{release.release}</code> ·{" "}
{t("occurrences", { count: release.count })} ·{" "}
{release.firstAt} — {release.lastAt}
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">
{t("releaseScope")}
</p>
</details>
<p> <p>
{t("reference")}: <code className="break-all">{r.id}</code> {t("reference")}: <code className="break-all">{r.id}</code>
</p> </p>
@@ -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>
);
}
+62 -60
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useState } from "react";
import { DataTable } from "@/components/admin/data-table"; import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge"; 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 type { DataTableColumn, PaginatedResult } from "@/types/common";
import { AuditFilters } from "./audit-filters";
interface AuditRow { interface AuditRow {
id: number; id: number;
@@ -14,75 +15,73 @@ interface AuditRow {
target: string; target: string;
targetId: number | null; targetId: number | null;
diff: string | null; diff: string | null;
before: string | null;
after: string | null;
createdAt: string; createdAt: string;
} }
function DiffCell({ diff }: { diff: string | null }) { function DiffCell({ row }: { row: AuditRow }) {
const t = useTranslations("pages.admin.logs"); const t = useTranslations("pages.admin.logs");
const [open, setOpen] = useState(false); const { changes, invalid } = readAuditChanges(
if (!diff) row.diff,
row.before,
row.after,
);
if (invalid)
return (
<span className="text-muted-foreground text-xs">
{t("audit.invalidDetails")}
</span>
);
if (!changes.length)
return <span className="text-muted-foreground">{t("emptyValue")}</span>; return <span className="text-muted-foreground">{t("emptyValue")}</span>;
let parsed: Record<string, { from: unknown; to: unknown }>;
try {
parsed = JSON.parse(diff);
} catch {
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
}
const keys = Object.keys(parsed);
if (keys.length === 0)
return <span className="text-muted-foreground">{t("emptyValue")}</span>;
const summary =
keys.length === 1
? `${keys[0]}: ${String(parsed[keys[0]].from ?? "∅")} → ${String(parsed[keys[0]].to ?? "∅")}`
: t("audit.changesCount", { count: keys.length });
return ( return (
<div> <details className="max-w-xl text-xs">
<Button <summary className="cursor-pointer font-medium">
variant="ghost" {t("audit.changesCount", { count: changes.length })}
size="sm" </summary>
className="h-auto p-0 text-xs font-normal" <div className="mt-2 space-y-3">
onClick={() => setOpen(!open)} {changes.map((change) => (
> <div key={change.key} className="space-y-1">
{summary} <p className="font-medium break-all">{change.key}</p>
</Button> <div className="grid gap-2 sm:grid-cols-2">
{open && ( <div>
<div className="mt-1 space-y-0.5 text-xs"> <span className="text-muted-foreground">
{keys.map((k) => ( {t("audit.beforeValue")}
<div key={k} className="flex gap-1"> </span>
<span className="font-medium">{k}:</span> <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
<span className="text-[var(--admin-error)] line-through"> {formatAuditValue(change.from)}
{String(parsed[k].from ?? "∅")} </pre>
</span> </div>
<span>→</span> <div>
<span className="text-[var(--admin-success)]"> <span className="text-muted-foreground">
{String(parsed[k].to ?? "∅")} {t("audit.afterValue")}
</span> </span>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border p-2">
{formatAuditValue(change.to)}
</pre>
</div>
</div> </div>
))} </div>
</div> ))}
)} </div>
</div> </details>
); );
} }
interface AuditTableProps { interface AuditTableProps {
data: PaginatedResult<AuditRow>; data: PaginatedResult<AuditRow>;
} }
export function AuditTable({ data }: AuditTableProps) { export function AuditTable({ data }: AuditTableProps) {
const params = useSearchParams();
const t = useTranslations("pages.admin.logs"); const t = useTranslations("pages.admin.logs");
const columns: DataTableColumn<AuditRow>[] = [ const columns: DataTableColumn<AuditRow>[] = [
{ key: "id", label: t("colId"), sortable: true }, { key: "id", label: t("colId") },
{ key: "username", label: t("colUser"), sortable: true }, { key: "username", label: t("colUser") },
{ {
key: "action", key: "action",
label: t("colAction"), label: t("colAction"),
sortable: true,
render: (value) => { render: (value) => {
const v = String(value); const v = String(value);
let variant: "default" | "secondary" | "destructive" = "secondary"; let variant: "default" | "secondary" | "destructive" = "secondary";
@@ -92,7 +91,7 @@ export function AuditTable({ data }: AuditTableProps) {
return <Badge variant={variant}>{v}</Badge>; return <Badge variant={variant}>{v}</Badge>;
}, },
}, },
{ key: "target", label: t("colTarget"), sortable: true }, { key: "target", label: t("colTarget") },
{ {
key: "targetId", key: "targetId",
label: t("audit.colTargetId"), label: t("audit.colTargetId"),
@@ -103,12 +102,11 @@ export function AuditTable({ data }: AuditTableProps) {
{ {
key: "diff", key: "diff",
label: t("audit.colDetails"), label: t("audit.colDetails"),
render: (_value, row) => <DiffCell diff={row.diff} />, render: (_value, row) => <DiffCell row={row} />,
}, },
{ {
key: "createdAt", key: "createdAt",
label: t("audit.colDate"), label: t("audit.colDate"),
sortable: true,
render: (value) => ( render: (value) => (
<span>{String(value) || t("audit.notAvailable")}</span> <span>{String(value) || t("audit.notAvailable")}</span>
), ),
@@ -116,11 +114,15 @@ export function AuditTable({ data }: AuditTableProps) {
]; ];
return ( return (
<DataTable <div className="space-y-4">
data={data} <AuditFilters />
columns={columns} <p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
searchPlaceholder={t("audit.searchPlaceholder")} <DataTable
exportUrl="/api/admin/export?type=audit" data={data}
/> columns={columns}
searchPlaceholder={t("audit.searchPlaceholder")}
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
/>
</div>
); );
} }
+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}
/> />
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useTranslations } from "next-intl";
import { type RefObject, useEffect, useRef, useState } from "react";
import {
autosaveArticle,
clearArticleRecovery,
loadArticleRecovery,
} from "@/actions/article-recovery";
import type { ArticleDraft } from "@/lib/article-draft";
export function useArticleRecovery(
key: string,
form: RefObject<HTMLFormElement | null>,
saving: RefObject<boolean>,
dirty: boolean,
restore: (draft: ArticleDraft, revision: boolean) => void,
) {
const t = useTranslations("pages.admin.articles.form");
const [recovery, setRecovery] = useState<Awaited<
ReturnType<typeof loadArticleRecovery>
> | null>(null);
const [message, setMessage] = useState("autosaveLoading");
const [reload, setReload] = useState(0);
const version = useRef(0);
const request = useRef<Promise<void> | null>(null);
const last = useRef("");
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
const blocked = useRef(true);
// biome-ignore lint/correctness/useExhaustiveDependencies: reload explicitly retries reconciliation without remounting the editor.
useEffect(() => {
let active = true;
blocked.current = true;
last.current = "";
setMessage("autosaveLoading");
loadArticleRecovery(key)
.then((data) => {
if (!active) return;
version.current = data.version;
setRecovery(data);
blocked.current = Boolean(data.draft);
setMessage(data.draft ? "recoveryFound" : "autosaveReady");
})
.catch(() => {
if (active) setMessage("autosaveFailed");
});
const timer = setInterval(() => {
if (
blocked.current ||
!dirtyRef.current ||
saving.current ||
request.current ||
!form.current
)
return;
const data = new FormData(form.current);
const snapshot = JSON.stringify(Array.from(data.entries()));
if (snapshot === last.current) return;
setMessage("autosaveSaving");
request.current = autosaveArticle(key, version.current, data)
.then((result) => {
if (!active) return;
if (!result.ok) {
blocked.current = true;
setMessage("autosaveConflict");
return;
}
version.current = result.version;
last.current = snapshot;
setMessage("autosaveSaved");
})
.catch(() => {
if (active) setMessage("autosaveFailed");
})
.finally(() => {
request.current = null;
});
}, 2500);
return () => {
active = false;
clearInterval(timer);
};
}, [key, form, saving, reload]);
return {
wait: () => request.current ?? Promise.resolve(),
clear: () => clearArticleRecovery(key, version.current),
panel: (
<div className="grid gap-2 text-xs" aria-live="polite">
<p>{t(message)}</p>
{(message === "autosaveFailed" || message === "autosaveConflict") && (
<button
type="button"
className="btn btn-outline"
onClick={async () => {
if (saving.current) return;
// Reconcile server versions without replacing any current form contents.
blocked.current = true;
await request.current;
setReload((current) => current + 1);
}}
>
{t("retryRecovery")}
</button>
)}
{recovery?.draft && (
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (!recovery.draft || !window.confirm(t("confirmRecovery")))
return;
restore(recovery.draft.payload, false);
setRecovery({ ...recovery, draft: null });
blocked.current = false;
setMessage("autosaveReady");
}}
>
{t("recoverDraft")}
</button>
<button
type="button"
className="btn btn-outline"
onClick={async () => {
if (!window.confirm(t("confirmDiscardDraft"))) return;
try {
await clearArticleRecovery(key, version.current);
// Reload the version in case another tab changed it before discarding.
const data = await loadArticleRecovery(key);
version.current = data.version;
setRecovery(data);
blocked.current = Boolean(data.draft);
setMessage(data.draft ? "autosaveConflict" : "autosaveReady");
} catch {
setMessage("autosaveFailed");
}
}}
>
{t("discardDraft")}
</button>
</div>
)}
{!!recovery?.revisions.length && (
<details>
<summary>{t("revisionHistory")}</summary>
<ul className="grid gap-2 mt-2">
{recovery.revisions.map((revision) => (
<li key={revision.id} className="flex gap-2 items-center">
<time dateTime={revision.createdAt}>
{new Date(revision.createdAt).toLocaleString()}
</time>
<span>{revision.payload.title}</span>
<button
type="button"
className="btn btn-outline"
onClick={() => {
if (
blocked.current ||
!window.confirm(t("confirmRevision"))
)
return;
restore(revision.payload, true);
}}
disabled={Boolean(recovery.draft)}
>
{t("restoreRevision")}
</button>
</li>
))}
</ul>
</details>
)}
</div>
),
};
}
+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";
+152
View File
@@ -0,0 +1,152 @@
import { count, eq, ne } from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { collectOperations } from "@/lib/admin/operations-inbox";
import {
db,
WebsiteArticles,
WebsiteHelpCenterTickets,
WebsiteTicket,
} from "@/lib/db";
import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { ImportJobStore } from "@/lib/services/furni-job-store";
export async function OperationsInbox() {
const { session, permissions } = await getAdminContext();
const allowed = (permission: string) =>
canAccess(permissions, permission, session.user.rank);
const t = await getTranslations("pages.admin.operations");
const result = await collectOperations([
{
source: "errors",
allowed: allowed(PERMS.DEVOPS_VIEW),
load: async () => {
const { records } = await new ErrorStore().read();
const total = new Set(
records
.filter((record) => !record.resolved)
.map((record) => record.fingerprint),
).size;
return [
{
id: "unresolved",
source: "errors",
count: total,
severity: 3,
href: "/admin/devops/cms-errors?status=open",
},
];
},
},
{
source: "imports",
allowed: allowed(PERMS.ASSETS_IMPORT),
load: async () => {
const jobs = await new ImportJobStore().list({
userId: Number(session.user.id),
limit: 30,
});
const total = jobs.filter(
(job) =>
job.state === "interrupted" ||
job.items.some((item) => item.state === "failed"),
).length;
return [
{
id: "attention",
source: "imports",
count: total,
severity: 2,
href: "/admin/studio/furni",
},
];
},
},
{
source: "tickets",
allowed: allowed(PERMS.TICKETS_VIEW),
load: async () => {
const [rows, helpRows] = await Promise.all([
db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed")),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true)),
]);
return [
{
id: "open",
source: "tickets",
count:
Number(rows[0]?.total ?? 0) + Number(helpRows[0]?.total ?? 0),
severity: 1,
href: "/admin/tickets",
},
];
},
},
{
source: "publications",
allowed: allowed(PERMS.NEWS_VIEW),
load: async () => {
const rows = await db
.select({ total: count() })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.status, "draft"));
return [
{
id: "drafts",
source: "publications",
count: Number(rows[0]?.total ?? 0),
severity: 0,
href: "/admin/articles?status=draft",
},
];
},
},
]);
return (
<section
className="admin-card mb-6 space-y-3"
aria-labelledby="operations-title"
>
<h2 id="operations-title" className="text-lg font-semibold">
{t("title")}
</h2>
<p className="text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
{result.items.length === 0 && result.unavailable.length === 0 ? (
<p>{t("empty")}</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{result.items.map((item) => (
<Link
key={`${item.source}:${item.id}`}
href={item.href}
className="rounded-lg border border-[var(--admin-border)] p-3 focus-visible:outline-2"
>
<span>{t(item.source)}</span>
<strong className="float-right">{item.count}</strong>
</Link>
))}
</div>
{result.unavailable.map((source) => (
<p
key={source}
role="status"
className="text-sm text-[var(--admin-warning)]"
>
{t("unavailable", { source: t(source) })}
</p>
))}
{allowed(PERMS.DEVOPS_VIEW) ? (
<Link href="/admin/devops/installation" className="btn btn-outline">
{t("installation")}
</Link>
) : null}
</section>
);
}
@@ -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>
);
}
@@ -0,0 +1,216 @@
"use client";
import {
Check,
CloudDownload,
Loader2,
Pencil,
RefreshCw,
Sparkles,
Trash2,
WandSparkles,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { FurnitureInspector } from "./furniture-inspector";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type { FurniItem } from "./studio-types";
interface FurnitureDetailDrawerProps {
detail: FurniItem;
importingId: string | null;
regenerating: boolean;
busy: boolean;
inspectionKey: string;
onClose: () => void;
onImport: () => void;
onEdit: () => void;
onDelete: () => void;
onRegenerate: () => void;
onRepair: () => Promise<unknown>;
}
export function FurnitureDetailDrawer({
detail,
importingId,
regenerating,
busy,
inspectionKey,
onClose,
onImport,
onEdit,
onDelete,
onRegenerate,
onRepair,
}: FurnitureDetailDrawerProps) {
const t = useTranslations("admin.studio.completeness");
const detailPreview = previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
);
return (
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
</span>
<button
type="button"
aria-label="Close furniture details"
onClick={onClose}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={14} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => handleImgError(e, detail)}
/>
</div>
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
{detail.name}
</h2>
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
{detail.classname}
</p>
{detail.description ? (
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
{detail.description}
</p>
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> {t("parts.sql")}: {t("statuses.present")}
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
<FurnitureInspector
key={inspectionKey}
item={detail}
busy={busy}
onRepair={onRepair}
/>
{/* Auto-catalog preview */}
{detailPreview && (
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles size={12} className="text-[var(--admin-accent)]" />
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Category</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">Price</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
</div>
)}
</div>
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="space-y-2">
{!detail.alreadyImported ? (
<Button
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={onImport}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is missing.
</p>
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
disabled={regenerating}
onClick={onRegenerate}
>
{regenerating ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
</Button>
</>
)}
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={onEdit}
disabled={!detail.nitroExists}
>
<Pencil size={14} />
Edit nitro
</Button>
</div>
)}
{detail.alreadyImported && (
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
onClick={onDelete}
>
<Trash2 size={14} />
Delete imported furni
</Button>
)}
</div>
</div>
</aside>
);
}
@@ -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 ||
+38 -198
View File
@@ -20,7 +20,6 @@ import {
Search, Search,
Sparkles, Sparkles,
Trash2, Trash2,
WandSparkles,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -53,10 +52,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { adminFetch } from "@/lib/admin-fetch"; import { adminFetch } from "@/lib/admin-fetch";
import {
type AutoCatalogPreview,
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client"; import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -64,7 +59,7 @@ import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress"; import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail"; import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot"; import { CheckboxDot } from "./checkbox-dot";
import { FurnitureInspector } from "./furniture-inspector"; import { FurnitureDetailDrawer } from "./furniture-detail-drawer";
import { FurnitureJobHistory } from "./furniture-jobs"; import { FurnitureJobHistory } from "./furniture-jobs";
import { ImportReview } from "./import-review"; import { ImportReview } from "./import-review";
import { filterFurniture } from "./studio-filters"; import { filterFurniture } from "./studio-filters";
@@ -883,14 +878,6 @@ export function StudioClient({
} }
} }
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
if (!detail) return null;
return previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
);
}, [detail]);
const selectedNonImported = useMemo( const selectedNonImported = useMemo(
() => () =>
filteredItems.filter( filteredItems.filter(
@@ -1930,190 +1917,43 @@ export function StudioClient({
{/* ── Detail drawer ─────────────────────────────── */} {/* ── Detail drawer ─────────────────────────────── */}
{detail ? ( {detail ? (
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none"> <FurnitureDetailDrawer
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5"> detail={detail}
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]"> importingId={importingId}
Furni detail regenerating={regeneratingNitro.has(detail.classname)}
</span> inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
<button .filter(
type="button" (j) =>
aria-label="Close furniture details" j.state === "completed" ||
onClick={() => setDetail(null)} j.state === "cancelled" ||
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]" j.state === "interrupted",
> )
<X size={14} /> .map((j) => j.id)
</button> .join(",")}`}
</div> busy={
jobs.busy ||
<div className="min-h-0 flex-1 overflow-y-auto p-4"> jobs.jobs.some(
<div (j) =>
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]" (j.state === "queued" || j.state === "running") &&
style={{ j.items.some((i) => i.classname === detail.classname),
backgroundImage: )
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)", }
backgroundSize: "14px 14px", onClose={() => setDetail(null)}
}} onImport={() => setReview({ items: [detail], single: true })}
> onEdit={() => setEditingClassname(detail.classname)}
<img onRegenerate={() => void regenNitro(detail)}
src={getFurniImageUrl(detail)} onDelete={() => {
alt={detail.name} setDeleting(detail.classname);
className="h-28 w-28 object-contain" setConfirmDelete(true);
style={{ imageRendering: "pixelated" }} }}
onError={(e) => handleImgError(e, detail)} onRepair={() =>
/> jobs.submit([detail], {
</div> sourceId: activeSource || undefined,
translate: false,
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]"> mode: "repair",
{detail.name} })
</h2> }
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]"> />
{detail.classname}
</p>
{detail.description ? (
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
{detail.description}
</p>
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> In catalog
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
<FurnitureInspector
key={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
.filter((j) => j.state === "completed")
.map((j) => j.id)
.join(",")}`}
item={detail}
busy={
jobs.busy ||
jobs.jobs.some(
(j) =>
(j.state === "queued" || j.state === "running") &&
j.items.some((i) => i.classname === detail.classname),
)
}
onRepair={() =>
jobs.submit([detail], {
sourceId: activeSource || undefined,
translate: false,
mode: "repair",
})
}
/>
{/* Auto-catalog preview */}
{detailPreview && (
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles
size={12}
className="text-[var(--admin-accent)]"
/>
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
Category
</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
Price
</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
</div>
)}
</div>
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="space-y-2">
{!detail.alreadyImported ? (
<Button
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() => setReview({ items: [detail], single: true })}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is
missing.
</p>
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
disabled={regeneratingNitro.has(detail.classname)}
onClick={() => regenNitro(detail)}
>
{regeneratingNitro.has(detail.classname) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
</Button>
</>
)}
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={() => setEditingClassname(detail.classname)}
>
<Pencil size={14} />
Edit nitro
</Button>
</div>
)}
{detail.alreadyImported && (
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
onClick={() => {
setDeleting(detail.classname);
setConfirmDelete(true);
}}
>
<Trash2 size={14} />
Delete imported furni
</Button>
)}
</div>
</div>
</aside>
) : null} ) : null}
</div> </div>
@@ -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 };
}
+111
View File
@@ -0,0 +1,111 @@
import "server-only";
import { constants } from "node:fs";
import { access, readdir } from "node:fs/promises";
import path from "node:path";
import { sql } from "drizzle-orm";
import {
type DiagnosticState,
migrationState,
workerState,
} from "@/lib/admin/installation-state";
import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db } from "@/lib/db";
import { redis } from "@/lib/redis";
import { siteSettings } from "@/lib/services/site-settings";
export async function inspectInstallation() {
const started = performance.now();
const [health, storage, expected, applied, heartbeat, renderer] =
await Promise.all([
fetchOpsHealth(),
Promise.all(
["storage", "public/nitro-assets", "public/swf"].map(async (name) => ({
name,
state: await access(
path.join(process.cwd(), name),
constants.R_OK | constants.W_OK,
)
.then(() => "ok" as const)
.catch(() => "failed" as const),
})),
),
readdir(path.join(process.cwd(), "drizzle/migrations"))
.then((files) =>
files
.filter((name) => name.endsWith(".sql"))
.map((name) => name.slice(0, -4)),
)
.catch(() => []),
db
.execute(sql`SELECT migration FROM cms_migrations`)
.then(([rows]) =>
(rows as unknown as { migration: string }[]).map(
(row) => row.migration,
),
)
.catch(() => null),
redis
? redis.get("cms:jobs-worker:heartbeat").catch(() => null)
: Promise.resolve(null),
siteSettings.get("nitro_client_url", "").catch(() => ""),
]);
const migration = migrationState(expected, applied);
const release = process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown";
const rows: Array<{ key: string; state: DiagnosticState; detail?: string }> =
[
{
key: "release",
state: /^[a-f0-9]{40}$/i.test(release) ? "ok" : "unknown",
detail: /^[a-f0-9]{40}$/i.test(release) ? release : undefined,
},
{
key: "database",
state: health.dbOk ? "ok" : "failed",
detail: health.dbOk ? `${health.dbLatencyMs} ms` : undefined,
},
{
key: "redis",
state:
health.redisOk === null
? "missing"
: health.redisOk
? "ok"
: "failed",
},
{ key: "emulator", state: health.emulatorOk ? "ok" : "failed" },
{
key: "storage",
state: storage.every((item) => item.state === "ok") ? "ok" : "failed",
detail:
storage
.filter((item) => item.state !== "ok")
.map((item) => item.name)
.join(", ") || undefined,
},
{
key: "migrations",
state: migration.state,
detail:
migration.pending === null ? undefined : String(migration.pending),
},
{
key: "worker",
state: workerState(heartbeat),
detail:
heartbeat && Number.isFinite(Date.parse(heartbeat))
? new Date(heartbeat).toISOString()
: undefined,
},
{
key: "renderer",
state: health.dbOk ? "ok" : "unknown",
detail: health.dbOk && !renderer ? "/nitro-client/" : undefined,
},
{ key: "registry", state: "unknown" },
];
return {
rows,
checkedAt: new Date().toISOString(),
durationMs: Math.round(performance.now() - started),
};
}
+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,
};
}
+40 -37
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,40 +20,42 @@ 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(
const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([ async function fetchOpsHealth(): Promise<OpsHealth> {
(async () => { const [dbProbe, redisOk, emulatorOk, onlineUsers] = await Promise.all([
try { (async () => {
const start = Date.now(); try {
await db.execute(sql`SELECT 1`); const start = Date.now();
return { dbOk: true, dbLatencyMs: Date.now() - start }; await db.execute(sql`SELECT 1`);
} catch { return { dbOk: true, dbLatencyMs: Date.now() - start };
return { dbOk: false, dbLatencyMs: 0 }; } catch {
} return { dbOk: false, dbLatencyMs: 0 };
})(), }
(async (): Promise<boolean | null> => { })(),
if (!env.REDIS_URL) return null; (async (): Promise<boolean | null> => {
if (!redis) return false; if (!env.REDIS_URL) return null;
try { if (!redis) return false;
const pong = await redis.ping(); try {
return pong === "PONG"; const pong = await redis.ping();
} catch { return pong === "PONG";
return false; } catch {
} return false;
})(), }
rcon.send("ping", null).catch(() => false), })(),
db rcon.send("ping", null).catch(() => false),
.select({ total: count() }) db
.from(User) .select({ total: count() })
.where(eq(User.online, "1")) .from(User)
.then((rows) => rows[0]?.total ?? 0) .where(eq(User.online, "1"))
.catch(() => 0), .then((rows) => rows[0]?.total ?? 0)
]); .catch(() => 0),
return { ]);
dbOk: dbProbe.dbOk, return {
dbLatencyMs: dbProbe.dbLatencyMs, dbOk: dbProbe.dbOk,
redisOk, dbLatencyMs: dbProbe.dbLatencyMs,
emulatorOk: Boolean(emulatorOk), redisOk,
onlineUsers, emulatorOk: Boolean(emulatorOk),
}; 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 });
}
});
+78 -5
View File
@@ -4,6 +4,7 @@ import {
mkdir, mkdir,
readdir, readdir,
readFile, readFile,
rename,
stat, stat,
unlink, unlink,
writeFile, writeFile,
@@ -27,6 +28,7 @@ export function redactErrorText(value: string): string {
export interface CmsErrorRecord { export interface CmsErrorRecord {
id: string; id: string;
resolved?: boolean; resolved?: boolean;
assignment?: { userId: number; at: string } | null;
at: string; at: string;
source: "server" | "browser"; source: "server" | "browser";
event: string; event: string;
@@ -111,7 +113,9 @@ export class ErrorStore {
.slice(0, 10); .slice(0, 10);
for (const name of files) for (const name of files)
if ( if (
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) && /^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.(?:resolved|assignment)(?:\.[a-f0-9-]+\.tmp)?)$/.test(
name,
) &&
name.slice(0, 10) < cutoff name.slice(0, 10) < cutoff
) )
await unlink(path.join(this.directory, name)).catch(() => {}); await unlink(path.join(this.directory, name)).catch(() => {});
@@ -135,6 +139,43 @@ export class ErrorStore {
{ mode: 0o600 }, { mode: 0o600 },
); );
} }
async assign(fingerprint: string, userId: number | null) {
if (!/^[a-f0-9]{16}$/.test(fingerprint))
throw new Error("Invalid fingerprint");
if (userId !== null && (!Number.isSafeInteger(userId) || userId <= 0))
throw new Error("Invalid assignee");
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
ErrorStore.pending++;
const write = ErrorStore.queue.then(async () => {
const { records } = await this.read();
if (!records.some((record) => record.fingerprint === fingerprint))
throw new Error("Error group no longer available");
const at = new Date().toISOString();
const file = path.join(
this.directory,
`${at.slice(0, 10)}.${fingerprint}.assignment`,
);
const metadata = (await readdir(this.directory)).filter(
(name) =>
name.startsWith(at.slice(0, 10)) && name.endsWith(".assignment"),
);
if (metadata.length >= 1000 && !metadata.includes(path.basename(file)))
throw new Error("Daily assignment storage limit reached");
const temp = `${file}.${randomUUID()}.tmp`;
try {
await writeFile(temp, JSON.stringify({ userId, at }), { mode: 0o600 });
await rename(temp, file);
} finally {
await unlink(temp).catch(() => {});
}
});
ErrorStore.queue = write.catch(() => {});
try {
await write;
} finally {
ErrorStore.pending--;
}
}
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> { async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
const files = await readdir(this.directory).catch( const files = await readdir(this.directory).catch(
(e: NodeJS.ErrnoException) => { (e: NodeJS.ErrnoException) => {
@@ -142,17 +183,48 @@ export class ErrorStore {
throw e; throw e;
}, },
); );
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
const assignments = new Map<string, CmsErrorRecord["assignment"]>();
for (const name of files
.filter(
(f) =>
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.assignment$/.test(f) &&
f.slice(0, 10) >= cutoff,
)
.sort()) {
const file = path.join(this.directory, name);
if ((await stat(file)).size > 512) continue;
try {
const value = JSON.parse(await readFile(file, "utf8"));
if (
typeof value.at !== "string" ||
!Number.isFinite(Date.parse(value.at))
)
continue;
if (value.userId === null) assignments.set(name.split(".")[1], null);
else if (Number.isSafeInteger(value.userId) && value.userId > 0)
assignments.set(name.split(".")[1], {
userId: value.userId,
at: value.at,
});
} catch {
/* Ignore interrupted or malformed metadata. */
}
}
const resolved = new Map<string, string>(); const resolved = new Map<string, string>();
for (const name of files for (const name of files
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f)) .filter(
(f) =>
/^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f) &&
f.slice(0, 10) >= cutoff,
)
.sort()) { .sort()) {
const at = await readFile(path.join(this.directory, name), "utf8"); const at = await readFile(path.join(this.directory, name), "utf8");
resolved.set(name.split(".")[1], at); resolved.set(name.split(".")[1], at);
} }
const records: CmsErrorRecord[] = []; const records: CmsErrorRecord[] = [];
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
for (const name of files for (const name of files
.filter( .filter(
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff, (f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
@@ -169,6 +241,7 @@ export class ErrorStore {
records.push({ records.push({
...r, ...r,
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at, resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
assignment: assignments.get(r.fingerprint) ?? null,
}); });
} catch { } catch {
/* Ignore an interrupted final write. */ /* Ignore an interrupted final write. */
+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,
};
});
}
+25 -21
View File
@@ -56,16 +56,15 @@ 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" : !sql && !fd
: !sql && !fd ? "new"
? "new" : fields.some((f) => f.changed)
: fields.some((f) => f.changed) ? "changed"
? "changed" : "present";
: "present";
return { state, fields } as const; return { state, fields } as const;
} }
export function furnitureHealth(local: FurnitureInspection) { export function furnitureHealth(local: FurnitureInspection) {
@@ -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");
});
});
+49 -21
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) => {
...r, const details = readAuditChanges(r.diff, r.before, r.after);
username: userMap.get(r.userId) ?? `User #${r.userId}`, return {
})); ...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}`,
};
});
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));
} }
} }
+113 -2
View File
@@ -5,11 +5,14 @@ const mocks = vi.hoisted(() => ({
set: vi.fn(), set: vi.fn(),
eval: vi.fn(), eval: vi.fn(),
list: vi.fn(), list: vi.fn(),
cancel: vi.fn(),
save: vi.fn(), save: vi.fn(),
import: vi.fn(), import: vi.fn(),
attachment: vi.fn(), attachment: vi.fn(),
export: vi.fn(), export: vi.fn(),
translate: vi.fn(), translate: vi.fn(),
updateCatalog: vi.fn(),
updateItems: vi.fn(),
})); }));
vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } })); vi.mock("@/lib/redis", () => ({ redis: { set: mocks.set, eval: mocks.eval } }));
vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() })); vi.mock("@/lib/server-log", () => ({ logServerError: vi.fn() }));
@@ -19,6 +22,7 @@ vi.mock("./clone-sources", () => ({ getSource: async () => null }));
vi.mock("./furni-job-store", () => ({ vi.mock("./furni-job-store", () => ({
ImportJobStore: class { ImportJobStore: class {
list = mocks.list; list = mocks.list;
isCancellationRequested = mocks.cancel;
save = mocks.save; save = mocks.save;
}, },
})); }));
@@ -33,7 +37,7 @@ vi.mock("./furni-attachment", () => ({
readFurnitureAttachment: mocks.attachment, readFurnitureAttachment: mocks.attachment,
})); }));
vi.mock("./rcon", () => ({ vi.mock("./rcon", () => ({
rcon: { updateCatalog: async () => {}, updateItems: async () => {} }, rcon: { updateCatalog: mocks.updateCatalog, updateItems: mocks.updateItems },
})); }));
vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() })); vi.mock("./catalog-git-export", () => ({ runCatalogExport: vi.fn() }));
@@ -44,10 +48,14 @@ let job: ImportJob;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.set.mockResolvedValue("OK"); mocks.set.mockResolvedValue("OK");
mocks.cancel.mockResolvedValue(false);
mocks.eval.mockResolvedValue(1); mocks.eval.mockResolvedValue(1);
mocks.export.mockImplementation((fn) => fn()); mocks.export.mockImplementation((fn) => fn());
mocks.import.mockResolvedValue({ ok: true, itemId: 900, warnings: [] }); mocks.import.mockResolvedValue({ ok: true, itemId: 900, warnings: [] });
mocks.save.mockResolvedValue(undefined); mocks.save.mockResolvedValue(undefined);
mocks.updateCatalog.mockResolvedValue(undefined);
mocks.updateItems.mockResolvedValue(undefined);
mocks.translate.mockResolvedValue(undefined);
job = { job = {
id: "job", id: "job",
userId: 5, userId: 5,
@@ -89,7 +97,7 @@ it("marks abandoned work interrupted without repeating an uncertain import", asy
job.items[0].state = "running"; job.items[0].state = "running";
await drainFurnitureImports(); await drainFurnitureImports();
expect(job.state).toBe("interrupted"); expect(job.state).toBe("interrupted");
expect(job.items[0].state).toBe("failed"); expect(job.items[0].state).toBe("interrupted");
expect(mocks.import).not.toHaveBeenCalled(); expect(mocks.import).not.toHaveBeenCalled();
}); });
it("keeps errors for failed items and continues the batch", async () => { it("keeps errors for failed items and continues the batch", async () => {
@@ -112,3 +120,106 @@ it("loads attachments using the job owner and exact classname", async () => {
expect.objectContaining({ providedNitro: Buffer.from("verified") }), expect.objectContaining({ providedNitro: Buffer.from("verified") }),
); );
}); });
it("cancels queued work without importing any items", async () => {
mocks.cancel.mockResolvedValue(true);
await drainFurnitureImports();
expect(job.state).toBe("cancelled");
expect(job.items[0].state).toBe("cancelled");
expect(mocks.import).not.toHaveBeenCalled();
});
it("finishes the in-flight item and cancels only the remaining items", async () => {
job.items.push({ ...job.items[0], classname: "table" });
mocks.import.mockImplementationOnce(async () => {
mocks.cancel.mockResolvedValue(true);
return { ok: true, itemId: 900, warnings: [] };
});
await drainFurnitureImports();
expect(job.state).toBe("cancelled");
expect(job.items.map((item) => item.state)).toEqual(["done", "cancelled"]);
expect(mocks.import).toHaveBeenCalledOnce();
});
it("reports completion if cancellation arrives during the final item", async () => {
mocks.import.mockImplementationOnce(async () => {
mocks.cancel.mockResolvedValue(true);
return { ok: true, itemId: 900, warnings: [] };
});
await drainFurnitureImports();
expect(job.state).toBe("completed");
});
it("preserves pending items when recovery finds an uncertain in-flight import", async () => {
job.state = "running";
job.items[0].state = "running";
job.items.push({ ...job.items[0], classname: "table", state: "pending" });
await drainFurnitureImports();
expect(job.items.map((item) => item.state)).toEqual([
"interrupted",
"pending",
]);
expect(mocks.import).not.toHaveBeenCalled();
});
it("does not overwrite another worker's interruption after losing ownership during the final import", async () => {
let persisted: ImportJob | undefined;
mocks.save.mockImplementation(async (value: ImportJob) => {
persisted = structuredClone(value);
});
mocks.import.mockImplementationOnce(async () => {
persisted = {
...structuredClone(job),
state: "interrupted",
items: job.items.map((item) => ({ ...item, state: "interrupted" })),
};
mocks.eval.mockResolvedValue(0);
return { ok: true, itemId: 900, warnings: [] };
});
await drainFurnitureImports();
expect(mocks.import).toHaveBeenCalledOnce();
expect(mocks.save).toHaveBeenCalledTimes(2);
expect(persisted?.state).toBe("interrupted");
expect(persisted?.items[0].state).toBe("interrupted");
expect(mocks.updateCatalog).not.toHaveBeenCalled();
});
it("keeps an uncertain import running on disk when lease verification fails", async () => {
const snapshots: ImportJob[] = [];
mocks.save.mockImplementation(async (value: ImportJob) => {
snapshots.push(structuredClone(value));
});
mocks.import.mockImplementationOnce(async () => {
mocks.eval.mockRejectedValue(new Error("Redis unavailable"));
throw new Error("SQL outcome unknown");
});
await drainFurnitureImports();
expect(snapshots).toHaveLength(2);
expect(snapshots.at(-1)?.items[0].state).toBe("running");
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
});
it("does not persist final completion when ownership changes during cache refresh", async () => {
const snapshots: ImportJob[] = [];
mocks.save.mockImplementation(async (value: ImportJob) => {
snapshots.push(structuredClone(value));
});
mocks.updateCatalog.mockImplementationOnce(async () => {
mocks.eval.mockResolvedValue(0);
});
await drainFurnitureImports();
expect(snapshots.at(-1)?.state).toBe("running");
expect(snapshots.at(-1)?.items[0].state).toBe("done");
expect(snapshots.some((value) => value.state === "completed")).toBe(false);
expect(mocks.updateItems).not.toHaveBeenCalled();
});
it("does not save a translated outcome after ownership is lost during translation", async () => {
const snapshots: ImportJob[] = [];
job.translate = true;
mocks.save.mockImplementation(async (value: ImportJob) => {
snapshots.push(structuredClone(value));
});
mocks.translate.mockImplementationOnce(async () => {
mocks.eval.mockResolvedValue(0);
});
await drainFurnitureImports();
expect(mocks.translate).toHaveBeenCalledOnce();
expect(snapshots).toHaveLength(2);
expect(snapshots.at(-1)?.items[0].state).toBe("running");
expect(mocks.updateCatalog).not.toHaveBeenCalled();
});
+76 -18
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { ImportJob } from "@/lib/furni/import-job";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
@@ -13,6 +14,7 @@ import { repairFurniture } from "./furniture-repair";
import { rcon } from "./rcon"; import { rcon } from "./rcon";
const LOCK = "furniture-import-worker:v1"; const LOCK = "furniture-import-worker:v1";
class ImportLeaseLostError extends Error {}
let running: Promise<void> | undefined; let running: Promise<void> | undefined;
export function drainFurnitureImports(): Promise<void> { export function drainFurnitureImports(): Promise<void> {
if (running) return running; if (running) return running;
@@ -30,6 +32,28 @@ async function drain() {
const token = randomUUID(); const token = randomUUID();
if ((await redis.set(LOCK, token, "EX", 600, "NX")) !== "OK") return; if ((await redis.set(LOCK, token, "EX", 600, "NX")) !== "OK") return;
let lease = true; let lease = true;
async function requireLease() {
if (!lease) throw new ImportLeaseLostError("Import worker lost its lease");
try {
const owned = await redis?.eval(
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],600) else return 0 end",
1,
LOCK,
token,
);
if (owned !== 1 || !lease) {
lease = false;
throw new ImportLeaseLostError("Import worker lost its lease");
}
} catch (error) {
lease = false;
if (error instanceof ImportLeaseLostError) throw error;
throw new ImportLeaseLostError(
"Import worker could not verify its lease",
{ cause: error },
);
}
}
const timer = setInterval(() => { const timer = setInterval(() => {
void redis void redis
?.eval( ?.eval(
@@ -47,29 +71,48 @@ async function drain() {
}, 30000); }, 30000);
try { try {
const store = new ImportJobStore(); const store = new ImportJobStore();
// Redis ownership checks and file writes are separate operations. These checks
// prevent known stale writes; they do not provide atomic filesystem fencing.
async function saveOwned(job: ImportJob) {
await requireLease();
await store.save(job);
}
for (const job of await store.list()) { for (const job of await store.list()) {
if (!lease) break; await requireLease();
if (job.state === "running") { if (job.state === "running") {
job.state = "interrupted"; job.state = "interrupted";
for (const item of job.items) for (const item of job.items)
if (item.state === "running" || item.state === "pending") { if (item.state === "running") {
item.state = "failed"; item.state = "interrupted";
item.error = item.error =
"Server restarted during import. Review local data and retry to complete missing parts."; "Server restarted during import. Its outcome is uncertain. Inspect local data before starting a new repair.";
} }
await store.save(job); await saveOwned(job);
continue; continue;
} }
if (job.state !== "queued") continue; if (job.state !== "queued") continue;
if (await store.isCancellationRequested(job.id)) {
job.state = "cancelled";
for (const item of job.items)
if (item.state === "pending") item.state = "cancelled";
await saveOwned(job);
continue;
}
job.state = "running"; job.state = "running";
await store.save(job); await saveOwned(job);
await withCatalogExport(async () => { await withCatalogExport(async () => {
await ensureDirectories(); await ensureDirectories();
const source = job.sourceId ? await getSource(job.sourceId) : null; const source = job.sourceId ? await getSource(job.sourceId) : null;
for (const item of job.items) { for (const item of job.items) {
if (!lease) throw Error("Import worker lost its lease"); await requireLease();
if (item.state !== "pending") continue;
if (await store.isCancellationRequested(job.id)) {
for (const remaining of job.items)
if (remaining.state === "pending") remaining.state = "cancelled";
break;
}
item.state = "running"; item.state = "running";
await store.save(job); await saveOwned(job);
try { try {
if (job.sourceId && !source) if (job.sourceId && !source)
throw Error("Import source no longer exists"); throw Error("Import source no longer exists");
@@ -80,6 +123,7 @@ async function drain() {
job.userId, job.userId,
) )
: undefined; : undefined;
await requireLease();
const result = await (job.mode === "repair" const result = await (job.mode === "repair"
? repairFurniture ? repairFurniture
: importSingleFurni)({ : importSingleFurni)({
@@ -90,6 +134,7 @@ async function drain() {
nitroBaseUrl: source?.nitroBaseUrl, nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl, iconBaseUrl: source?.iconBaseUrl,
}); });
await requireLease();
item.warnings = result.warnings; item.warnings = result.warnings;
item.itemId = result.itemId; item.itemId = result.itemId;
if (!result.ok) throw Error(result.error || "Import failed"); if (!result.ok) throw Error(result.error || "Import failed");
@@ -114,16 +159,21 @@ async function drain() {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof ImportLeaseLostError) throw error;
await requireLease();
item.state = "failed"; item.state = "failed";
item.error = item.error =
error instanceof Error ? error.message : "Import failed"; error instanceof Error ? error.message : "Import failed";
} }
await store.save(job); await saveOwned(job);
} }
await requireLease();
try { try {
await rcon.updateCatalog(); await rcon.updateCatalog();
await requireLease();
await rcon.updateItems(); await rcon.updateItems();
} catch { } catch (error) {
if (error instanceof ImportLeaseLostError) throw error;
for (const item of job.items) for (const item of job.items)
if (item.state === "done") { if (item.state === "done") {
item.warnings ??= []; item.warnings ??= [];
@@ -131,17 +181,25 @@ async function drain() {
} }
} }
}); });
job.state = "completed"; await requireLease();
await store.save(job); job.state = job.items.some((item) => item.state === "cancelled")
? "cancelled"
: "completed";
await saveOwned(job);
} }
await requireLease();
await runCatalogExport(); await runCatalogExport();
} finally { } finally {
clearInterval(timer); clearInterval(timer);
await redis.eval( await redis
"if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", .eval(
1, "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
LOCK, 1,
token, LOCK,
); token,
)
.catch((error) => {
logServerError("furni.worker_release_failed", error);
});
} }
} }
+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();
});
+162
View File
@@ -7,7 +7,138 @@
"admin": { "admin": {
"nav": { "nav": {
"studio": "Studio" "studio": "Studio"
},
"logs": {
"audit": {
"filtersTitle": "Filter audit records",
"filterActor": "Actor (exact username or ID)",
"filterAction": "Exact action",
"filterFrom": "From date (UTC)",
"filterTo": "Through date (UTC)",
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
"applyFilters": "Apply filters",
"resetFilters": "Reset filters",
"invalidDetails": "These legacy details cannot be displayed.",
"beforeValue": "Before",
"afterValue": "After",
"exportHint": "CSV exports the current filtered page, up to 100 records."
}
},
"cmsErrors": {
"firstSeen": "First occurrence in retained records",
"lastSeen": "Latest occurrence",
"affectedUsers": "Distinct identified users",
"unidentifiedEvents": "Events without a verified user ID",
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
"assignedStaff": "Assigned to staff #{id}",
"unassigned": "Unassigned",
"assignSelf": "Assign to me",
"clearAssignment": "Clear assignment",
"openOperation": "Open related area",
"releaseOccurrences": "Occurrences by release",
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
},
"installation": {
"title": "Installation diagnostics",
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
"checked": "Checked at {time} · probes completed in {ms} ms",
"recheck": "Check again",
"ok": "Check passed",
"failed": "Needs attention",
"unknown": "Not verified",
"missing": "Not configured",
"release": "Running release",
"database": "Database",
"redis": "Redis",
"emulator": "Emulator",
"storage": "Writable storage",
"migrations": "Database migrations",
"worker": "Background worker",
"renderer": "Renderer configuration",
"registry": "Container registry",
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
"databaseHint": "Connection check and measured SELECT 1 response time.",
"redisHint": "Optional for some pages; required for queued import work.",
"emulatorHint": "Response to the configured emulator ping.",
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
},
"importHistory": {
"cancelled": "Cancelled",
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
"cancelRemaining": "Cancel remaining items",
"historyLimit": "Showing up to 30 most recently updated jobs.",
"refreshHistory": "Refresh history",
"showMoreItems": "Show 50 more items",
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
"reviewLocalData": "Review local data"
},
"articles": {
"form": {
"autosaveLoading": "Loading saved drafts…",
"autosaveReady": "Private draft autosave is ready.",
"autosaveSaving": "Saving private draft…",
"autosaveSaved": "Private draft saved. Publication changes require Save.",
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
"recoverDraft": "Recover draft",
"discardDraft": "Discard saved draft",
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
"revisionHistory": "Previous saved versions (latest 20)",
"restoreRevision": "Restore into editor",
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
"retryRecovery": "Reload recovery without changing this form"
},
"listActionError": "The article action could not be completed.",
"listStatus": "Publication status",
"listStatus_all": "All articles",
"listStatus_draft": "Draft",
"listStatus_published": "Published",
"listStatus_scheduled": "Scheduled",
"listSearch": "Search title or summary",
"listApply": "Apply",
"listLoading": "Loading…",
"listReset": "Reset filters",
"listUnavailable": "Articles are temporarily unavailable. Try again.",
"listRetry": "Try again",
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
"listPagination": "Article pages",
"listPrevious": "Previous",
"listNext": "Next",
"listPage": "Page {page} of {total}"
},
"operations": {
"title": "Needs attention",
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
"empty": "No pending items found in the checked records.",
"errors": "Unresolved CMS error groups",
"imports": "Your imports needing review",
"tickets": "Open support tickets",
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
} }
},
"myDashboard": {
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
"allEvents": "All events",
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
"eventOngoing": "Happening now",
"eventUpcoming": "Coming up",
"eventDetails": "View event",
"noUpcomingEvents": "No upcoming events have been announced yet.",
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
} }
}, },
"toolbar": { "toolbar": {
@@ -23,5 +154,36 @@
"fullscreenExit": "الخروج من ملء الشاشة", "fullscreenExit": "الخروج من ملء الشاشة",
"show": "إظهار الشريط", "show": "إظهار الشريط",
"emulatorUnknown": "حالة المحاكي غير معروفة" "emulatorUnknown": "حالة المحاكي غير معروفة"
},
"admin": {
"studio": {
"completeness": {
"title": "Completeness and next steps",
"parts": {
"sql": "SQL item",
"catalog": "Catalog offer",
"furnidata": "Furnidata",
"icon": "Local icon",
"nitro": "Nitro file"
},
"statuses": {
"present": "Present",
"missing": "Missing",
"unknown": "Not verified",
"conflict": "Needs review"
},
"actions": {
"sql": "Import or repair this furni to create the missing item record.",
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
"furnidata": "Import or repair this furni to add the missing furniture data.",
"icon": "Import or repair this furni to retrieve the missing local icon.",
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
},
"openAudit": "Open Catalog audit to resolve conflicts",
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
}
}
} }
} }
+157 -3
View File
@@ -967,8 +967,42 @@
"saving": "Запазва се...", "saving": "Запазва се...",
"deleteArticle": "Изтриване на статия", "deleteArticle": "Изтриване на статия",
"deleteConfirm": "Това действие не може да бъде отменено. Статията ще бъде окончателно премахната.", "deleteConfirm": "Това действие не може да бъде отменено. Статията ще бъде окончателно премахната.",
"cancel": "Отказ" "cancel": "Отказ",
} "autosaveLoading": "Loading saved drafts…",
"autosaveReady": "Private draft autosave is ready.",
"autosaveSaving": "Saving private draft…",
"autosaveSaved": "Private draft saved. Publication changes require Save.",
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
"recoverDraft": "Recover draft",
"discardDraft": "Discard saved draft",
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
"revisionHistory": "Previous saved versions (latest 20)",
"restoreRevision": "Restore into editor",
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
"retryRecovery": "Reload recovery without changing this form"
},
"listActionError": "The article action could not be completed.",
"listStatus": "Publication status",
"listStatus_all": "All articles",
"listStatus_draft": "Draft",
"listStatus_published": "Published",
"listStatus_scheduled": "Scheduled",
"listSearch": "Search title or summary",
"listApply": "Apply",
"listLoading": "Loading…",
"listReset": "Reset filters",
"listUnavailable": "Articles are temporarily unavailable. Try again.",
"listRetry": "Try again",
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
"listPagination": "Article pages",
"listPrevious": "Previous",
"listNext": "Next",
"listPage": "Page {page} of {total}"
}, },
"tags": { "tags": {
"title": "Етикети", "title": "Етикети",
@@ -2071,7 +2105,19 @@
"colDate": "Date", "colDate": "Date",
"searchPlaceholder": "Search by action or target…", "searchPlaceholder": "Search by action or target…",
"changesCount": "{count} changes", "changesCount": "{count} changes",
"notAvailable": "N/A" "notAvailable": "N/A",
"filtersTitle": "Filter audit records",
"filterActor": "Actor (exact username or ID)",
"filterAction": "Exact action",
"filterFrom": "From date (UTC)",
"filterTo": "Through date (UTC)",
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
"applyFilters": "Apply filters",
"resetFilters": "Reset filters",
"invalidDetails": "These legacy details cannot be displayed.",
"beforeValue": "Before",
"afterValue": "After",
"exportHint": "CSV exports the current filtered page, up to 100 records."
}, },
"chat": { "chat": {
"colMessage": "Message", "colMessage": "Message",
@@ -2973,6 +3019,71 @@
"hideItem": "Hide item", "hideItem": "Hide item",
"showItem": "Show item", "showItem": "Show item",
"pinned": "Pinned" "pinned": "Pinned"
},
"cmsErrors": {
"firstSeen": "First occurrence in retained records",
"lastSeen": "Latest occurrence",
"affectedUsers": "Distinct identified users",
"unidentifiedEvents": "Events without a verified user ID",
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
"assignedStaff": "Assigned to staff #{id}",
"unassigned": "Unassigned",
"assignSelf": "Assign to me",
"clearAssignment": "Clear assignment",
"openOperation": "Open related area",
"releaseOccurrences": "Occurrences by release",
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
},
"installation": {
"title": "Installation diagnostics",
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
"checked": "Checked at {time} · probes completed in {ms} ms",
"recheck": "Check again",
"ok": "Check passed",
"failed": "Needs attention",
"unknown": "Not verified",
"missing": "Not configured",
"release": "Running release",
"database": "Database",
"redis": "Redis",
"emulator": "Emulator",
"storage": "Writable storage",
"migrations": "Database migrations",
"worker": "Background worker",
"renderer": "Renderer configuration",
"registry": "Container registry",
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
"databaseHint": "Connection check and measured SELECT 1 response time.",
"redisHint": "Optional for some pages; required for queued import work.",
"emulatorHint": "Response to the configured emulator ping.",
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
},
"importHistory": {
"cancelled": "Cancelled",
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
"cancelRemaining": "Cancel remaining items",
"historyLimit": "Showing up to 30 most recently updated jobs.",
"refreshHistory": "Refresh history",
"showMoreItems": "Show 50 more items",
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
"reviewLocalData": "Review local data"
},
"operations": {
"title": "Needs attention",
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
"empty": "No pending items found in the checked records.",
"errors": "Unresolved CMS error groups",
"imports": "Your imports needing review",
"tickets": "Open support tickets",
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
} }
}, },
"error": { "error": {
@@ -3255,6 +3366,18 @@
"failed": "Failed" "failed": "Failed"
} }
} }
},
"myDashboard": {
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
"allEvents": "All events",
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
"eventOngoing": "Happening now",
"eventUpcoming": "Coming up",
"eventDetails": "View event",
"noUpcomingEvents": "No upcoming events have been announced yet.",
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
} }
}, },
"emails": { "emails": {
@@ -3279,5 +3402,36 @@
"fullscreenExit": "Изход от цял екран", "fullscreenExit": "Изход от цял екран",
"show": "Покажи лентата", "show": "Покажи лентата",
"emulatorUnknown": "Състоянието на емулатора е неизвестно" "emulatorUnknown": "Състоянието на емулатора е неизвестно"
},
"admin": {
"studio": {
"completeness": {
"title": "Completeness and next steps",
"parts": {
"sql": "SQL item",
"catalog": "Catalog offer",
"furnidata": "Furnidata",
"icon": "Local icon",
"nitro": "Nitro file"
},
"statuses": {
"present": "Present",
"missing": "Missing",
"unknown": "Not verified",
"conflict": "Needs review"
},
"actions": {
"sql": "Import or repair this furni to create the missing item record.",
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
"furnidata": "Import or repair this furni to add the missing furniture data.",
"icon": "Import or repair this furni to retrieve the missing local icon.",
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
},
"openAudit": "Open Catalog audit to resolve conflicts",
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
}
}
} }
} }
+157 -3
View File
@@ -967,8 +967,42 @@
"saving": "Ukládání…", "saving": "Ukládání…",
"deleteArticle": "Smazat článek", "deleteArticle": "Smazat článek",
"deleteConfirm": "Tuto akci nelze vrátit zpět. Článek bude trvale odstraněn.", "deleteConfirm": "Tuto akci nelze vrátit zpět. Článek bude trvale odstraněn.",
"cancel": "Zrušit" "cancel": "Zrušit",
} "autosaveLoading": "Loading saved drafts…",
"autosaveReady": "Private draft autosave is ready.",
"autosaveSaving": "Saving private draft…",
"autosaveSaved": "Private draft saved. Publication changes require Save.",
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
"recoverDraft": "Recover draft",
"discardDraft": "Discard saved draft",
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
"revisionHistory": "Previous saved versions (latest 20)",
"restoreRevision": "Restore into editor",
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
"retryRecovery": "Reload recovery without changing this form"
},
"listActionError": "The article action could not be completed.",
"listStatus": "Publication status",
"listStatus_all": "All articles",
"listStatus_draft": "Draft",
"listStatus_published": "Published",
"listStatus_scheduled": "Scheduled",
"listSearch": "Search title or summary",
"listApply": "Apply",
"listLoading": "Loading…",
"listReset": "Reset filters",
"listUnavailable": "Articles are temporarily unavailable. Try again.",
"listRetry": "Try again",
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
"listPagination": "Article pages",
"listPrevious": "Previous",
"listNext": "Next",
"listPage": "Page {page} of {total}"
}, },
"tags": { "tags": {
"title": "Tagy", "title": "Tagy",
@@ -2071,7 +2105,19 @@
"colDate": "Date", "colDate": "Date",
"searchPlaceholder": "Search by action or target…", "searchPlaceholder": "Search by action or target…",
"changesCount": "{count} changes", "changesCount": "{count} changes",
"notAvailable": "N/A" "notAvailable": "N/A",
"filtersTitle": "Filter audit records",
"filterActor": "Actor (exact username or ID)",
"filterAction": "Exact action",
"filterFrom": "From date (UTC)",
"filterTo": "Through date (UTC)",
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
"applyFilters": "Apply filters",
"resetFilters": "Reset filters",
"invalidDetails": "These legacy details cannot be displayed.",
"beforeValue": "Before",
"afterValue": "After",
"exportHint": "CSV exports the current filtered page, up to 100 records."
}, },
"chat": { "chat": {
"colMessage": "Message", "colMessage": "Message",
@@ -2973,6 +3019,71 @@
"hideItem": "Hide item", "hideItem": "Hide item",
"showItem": "Show item", "showItem": "Show item",
"pinned": "Pinned" "pinned": "Pinned"
},
"cmsErrors": {
"firstSeen": "First occurrence in retained records",
"lastSeen": "Latest occurrence",
"affectedUsers": "Distinct identified users",
"unidentifiedEvents": "Events without a verified user ID",
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
"assignedStaff": "Assigned to staff #{id}",
"unassigned": "Unassigned",
"assignSelf": "Assign to me",
"clearAssignment": "Clear assignment",
"openOperation": "Open related area",
"releaseOccurrences": "Occurrences by release",
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
},
"installation": {
"title": "Installation diagnostics",
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
"checked": "Checked at {time} · probes completed in {ms} ms",
"recheck": "Check again",
"ok": "Check passed",
"failed": "Needs attention",
"unknown": "Not verified",
"missing": "Not configured",
"release": "Running release",
"database": "Database",
"redis": "Redis",
"emulator": "Emulator",
"storage": "Writable storage",
"migrations": "Database migrations",
"worker": "Background worker",
"renderer": "Renderer configuration",
"registry": "Container registry",
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
"databaseHint": "Connection check and measured SELECT 1 response time.",
"redisHint": "Optional for some pages; required for queued import work.",
"emulatorHint": "Response to the configured emulator ping.",
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
},
"importHistory": {
"cancelled": "Cancelled",
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
"cancelRemaining": "Cancel remaining items",
"historyLimit": "Showing up to 30 most recently updated jobs.",
"refreshHistory": "Refresh history",
"showMoreItems": "Show 50 more items",
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
"reviewLocalData": "Review local data"
},
"operations": {
"title": "Needs attention",
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
"empty": "No pending items found in the checked records.",
"errors": "Unresolved CMS error groups",
"imports": "Your imports needing review",
"tickets": "Open support tickets",
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
} }
}, },
"error": { "error": {
@@ -3255,6 +3366,18 @@
"failed": "Failed" "failed": "Failed"
} }
} }
},
"myDashboard": {
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
"allEvents": "All events",
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
"eventOngoing": "Happening now",
"eventUpcoming": "Coming up",
"eventDetails": "View event",
"noUpcomingEvents": "No upcoming events have been announced yet.",
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
} }
}, },
"emails": { "emails": {
@@ -3279,5 +3402,36 @@
"fullscreenExit": "Ukončit celou obrazovku", "fullscreenExit": "Ukončit celou obrazovku",
"show": "Zobrazit lištu", "show": "Zobrazit lištu",
"emulatorUnknown": "Stav emulátoru neznámý" "emulatorUnknown": "Stav emulátoru neznámý"
},
"admin": {
"studio": {
"completeness": {
"title": "Completeness and next steps",
"parts": {
"sql": "SQL item",
"catalog": "Catalog offer",
"furnidata": "Furnidata",
"icon": "Local icon",
"nitro": "Nitro file"
},
"statuses": {
"present": "Present",
"missing": "Missing",
"unknown": "Not verified",
"conflict": "Needs review"
},
"actions": {
"sql": "Import or repair this furni to create the missing item record.",
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
"furnidata": "Import or repair this furni to add the missing furniture data.",
"icon": "Import or repair this furni to retrieve the missing local icon.",
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
},
"openAudit": "Open Catalog audit to resolve conflicts",
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
}
}
} }
} }
+157 -3
View File
@@ -967,8 +967,42 @@
"saving": "Gemmer...", "saving": "Gemmer...",
"deleteArticle": "Slet artikel", "deleteArticle": "Slet artikel",
"deleteConfirm": "Denne handling kan ikke fortrydes. Artiklen fjernes permanent.", "deleteConfirm": "Denne handling kan ikke fortrydes. Artiklen fjernes permanent.",
"cancel": "Annuller" "cancel": "Annuller",
} "autosaveLoading": "Loading saved drafts…",
"autosaveReady": "Private draft autosave is ready.",
"autosaveSaving": "Saving private draft…",
"autosaveSaved": "Private draft saved. Publication changes require Save.",
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
"recoverDraft": "Recover draft",
"discardDraft": "Discard saved draft",
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
"revisionHistory": "Previous saved versions (latest 20)",
"restoreRevision": "Restore into editor",
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
"retryRecovery": "Reload recovery without changing this form"
},
"listActionError": "The article action could not be completed.",
"listStatus": "Publication status",
"listStatus_all": "All articles",
"listStatus_draft": "Draft",
"listStatus_published": "Published",
"listStatus_scheduled": "Scheduled",
"listSearch": "Search title or summary",
"listApply": "Apply",
"listLoading": "Loading…",
"listReset": "Reset filters",
"listUnavailable": "Articles are temporarily unavailable. Try again.",
"listRetry": "Try again",
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
"listPagination": "Article pages",
"listPrevious": "Previous",
"listNext": "Next",
"listPage": "Page {page} of {total}"
}, },
"tags": { "tags": {
"title": "Tags", "title": "Tags",
@@ -2071,7 +2105,19 @@
"colDate": "Date", "colDate": "Date",
"searchPlaceholder": "Search by action or target…", "searchPlaceholder": "Search by action or target…",
"changesCount": "{count} changes", "changesCount": "{count} changes",
"notAvailable": "N/A" "notAvailable": "N/A",
"filtersTitle": "Filter audit records",
"filterActor": "Actor (exact username or ID)",
"filterAction": "Exact action",
"filterFrom": "From date (UTC)",
"filterTo": "Through date (UTC)",
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
"applyFilters": "Apply filters",
"resetFilters": "Reset filters",
"invalidDetails": "These legacy details cannot be displayed.",
"beforeValue": "Before",
"afterValue": "After",
"exportHint": "CSV exports the current filtered page, up to 100 records."
}, },
"chat": { "chat": {
"colMessage": "Message", "colMessage": "Message",
@@ -2973,6 +3019,71 @@
"hideItem": "Hide item", "hideItem": "Hide item",
"showItem": "Show item", "showItem": "Show item",
"pinned": "Pinned" "pinned": "Pinned"
},
"cmsErrors": {
"firstSeen": "First occurrence in retained records",
"lastSeen": "Latest occurrence",
"affectedUsers": "Distinct identified users",
"unidentifiedEvents": "Events without a verified user ID",
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
"assignedStaff": "Assigned to staff #{id}",
"unassigned": "Unassigned",
"assignSelf": "Assign to me",
"clearAssignment": "Clear assignment",
"openOperation": "Open related area",
"releaseOccurrences": "Occurrences by release",
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
},
"installation": {
"title": "Installation diagnostics",
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
"checked": "Checked at {time} · probes completed in {ms} ms",
"recheck": "Check again",
"ok": "Check passed",
"failed": "Needs attention",
"unknown": "Not verified",
"missing": "Not configured",
"release": "Running release",
"database": "Database",
"redis": "Redis",
"emulator": "Emulator",
"storage": "Writable storage",
"migrations": "Database migrations",
"worker": "Background worker",
"renderer": "Renderer configuration",
"registry": "Container registry",
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
"databaseHint": "Connection check and measured SELECT 1 response time.",
"redisHint": "Optional for some pages; required for queued import work.",
"emulatorHint": "Response to the configured emulator ping.",
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
},
"importHistory": {
"cancelled": "Cancelled",
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
"cancelRemaining": "Cancel remaining items",
"historyLimit": "Showing up to 30 most recently updated jobs.",
"refreshHistory": "Refresh history",
"showMoreItems": "Show 50 more items",
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
"reviewLocalData": "Review local data"
},
"operations": {
"title": "Needs attention",
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
"empty": "No pending items found in the checked records.",
"errors": "Unresolved CMS error groups",
"imports": "Your imports needing review",
"tickets": "Open support tickets",
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
} }
}, },
"error": { "error": {
@@ -3255,6 +3366,18 @@
"failed": "Failed" "failed": "Failed"
} }
} }
},
"myDashboard": {
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
"allEvents": "All events",
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
"eventOngoing": "Happening now",
"eventUpcoming": "Coming up",
"eventDetails": "View event",
"noUpcomingEvents": "No upcoming events have been announced yet.",
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
} }
}, },
"emails": { "emails": {
@@ -3279,5 +3402,36 @@
"fullscreenExit": "Afslut fuldskærm", "fullscreenExit": "Afslut fuldskærm",
"show": "Vis værktøjslinje", "show": "Vis værktøjslinje",
"emulatorUnknown": "Emulatorstatus ukendt" "emulatorUnknown": "Emulatorstatus ukendt"
},
"admin": {
"studio": {
"completeness": {
"title": "Completeness and next steps",
"parts": {
"sql": "SQL item",
"catalog": "Catalog offer",
"furnidata": "Furnidata",
"icon": "Local icon",
"nitro": "Nitro file"
},
"statuses": {
"present": "Present",
"missing": "Missing",
"unknown": "Not verified",
"conflict": "Needs review"
},
"actions": {
"sql": "Import or repair this furni to create the missing item record.",
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
"furnidata": "Import or repair this furni to add the missing furniture data.",
"icon": "Import or repair this furni to retrieve the missing local icon.",
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
},
"openAudit": "Open Catalog audit to resolve conflicts",
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
}
}
} }
} }
+157 -3
View File
@@ -938,8 +938,42 @@
"saving": "Wird gespeichert…", "saving": "Wird gespeichert…",
"deleteArticle": "Artikel löschen", "deleteArticle": "Artikel löschen",
"deleteConfirm": "Dieser Vorgang kann nicht rückgängig gemacht werden. Der Artikel wird dauerhaft entfernt.", "deleteConfirm": "Dieser Vorgang kann nicht rückgängig gemacht werden. Der Artikel wird dauerhaft entfernt.",
"cancel": "Abbrechen" "cancel": "Abbrechen",
} "autosaveLoading": "Loading saved drafts…",
"autosaveReady": "Private draft autosave is ready.",
"autosaveSaving": "Saving private draft…",
"autosaveSaved": "Private draft saved. Publication changes require Save.",
"autosaveFailed": "Draft recovery is unavailable. Keep this page open and use Save; reload to retry recovery.",
"autosaveConflict": "Another tab changed this draft. Autosave is paused: reload to review the newer draft before continuing.",
"recoveryFound": "An unfinished private draft is available. Recover or discard it to enable autosave.",
"recoverDraft": "Recover draft",
"discardDraft": "Discard saved draft",
"confirmRecovery": "Replace the current editor content with this recovered draft? It will remain unpublished until you save.",
"confirmDiscardDraft": "Permanently discard your saved recovery draft? The current editor content will remain.",
"revisionHistory": "Previous saved versions (latest 20)",
"restoreRevision": "Restore into editor",
"confirmRevision": "Replace the current editor content with this previous version as a draft? Review it and choose a publication status before saving.",
"editConflict": "This article changed after you opened it. Your changes were not applied. Copy your work, reload the article, and merge the changes before saving again.",
"retryRecovery": "Reload recovery without changing this form"
},
"listActionError": "The article action could not be completed.",
"listStatus": "Publication status",
"listStatus_all": "All articles",
"listStatus_draft": "Draft",
"listStatus_published": "Published",
"listStatus_scheduled": "Scheduled",
"listSearch": "Search title or summary",
"listApply": "Apply",
"listLoading": "Loading…",
"listReset": "Reset filters",
"listUnavailable": "Articles are temporarily unavailable. Try again.",
"listRetry": "Try again",
"listTotal": "{count, plural, one {# matching article} other {# matching articles}}",
"listNoMatches": "No articles match these filters. Change the search or reset the filters.",
"listPagination": "Article pages",
"listPrevious": "Previous",
"listNext": "Next",
"listPage": "Page {page} of {total}"
}, },
"tags": { "tags": {
"title": "Tags", "title": "Tags",
@@ -2042,7 +2076,19 @@
"colDate": "Date", "colDate": "Date",
"searchPlaceholder": "Search by action or target…", "searchPlaceholder": "Search by action or target…",
"changesCount": "{count} changes", "changesCount": "{count} changes",
"notAvailable": "N/A" "notAvailable": "N/A",
"filtersTitle": "Filter audit records",
"filterActor": "Actor (exact username or ID)",
"filterAction": "Exact action",
"filterFrom": "From date (UTC)",
"filterTo": "Through date (UTC)",
"filtersHint": "Dates include the full day in UTC. Actions and usernames must match exactly. Records are shown newest first.",
"applyFilters": "Apply filters",
"resetFilters": "Reset filters",
"invalidDetails": "These legacy details cannot be displayed.",
"beforeValue": "Before",
"afterValue": "After",
"exportHint": "CSV exports the current filtered page, up to 100 records."
}, },
"chat": { "chat": {
"colMessage": "Message", "colMessage": "Message",
@@ -2944,6 +2990,71 @@
"hideItem": "Hide item", "hideItem": "Hide item",
"showItem": "Show item", "showItem": "Show item",
"pinned": "Pinned" "pinned": "Pinned"
},
"cmsErrors": {
"firstSeen": "First occurrence in retained records",
"lastSeen": "Latest occurrence",
"affectedUsers": "Distinct identified users",
"unidentifiedEvents": "Events without a verified user ID",
"metricsScope": "Counts cover only the retained records loaded here. Users are counted only from numeric server-side user IDs; anonymous and browser reports are not attributed.",
"assignedStaff": "Assigned to staff #{id}",
"unassigned": "Unassigned",
"assignSelf": "Assign to me",
"clearAssignment": "Clear assignment",
"openOperation": "Open related area",
"releaseOccurrences": "Occurrences by release",
"releaseScope": "These releases appear in the retained records. The earliest occurrence here does not establish which release introduced the error."
},
"installation": {
"title": "Installation diagnostics",
"hint": "Read-only checks of this installation. Unknown means there is not enough evidence; configured does not prove external reachability.",
"checked": "Checked at {time} · probes completed in {ms} ms",
"recheck": "Check again",
"ok": "Check passed",
"failed": "Needs attention",
"unknown": "Not verified",
"missing": "Not configured",
"release": "Running release",
"database": "Database",
"redis": "Redis",
"emulator": "Emulator",
"storage": "Writable storage",
"migrations": "Database migrations",
"worker": "Background worker",
"renderer": "Renderer configuration",
"registry": "Container registry",
"releaseHint": "The full identifier of the running build. Use it to compare installations and choose a previous image for rollback.",
"databaseHint": "Connection check and measured SELECT 1 response time.",
"redisHint": "Optional for some pages; required for queued import work.",
"emulatorHint": "Response to the configured emulator ping.",
"storageHint": "Read/write permissions on storage, Nitro assets and SWF directories.",
"migrationsHint": "Number of packaged migrations absent from the database history. Apply migrations through the release process.",
"workerHint": "Last worker heartbeat in Redis. More than two minutes old needs investigation; no heartbeat cannot establish whether the worker is running.",
"rendererHint": "The client uses the configured address or /nitro-client/ by default. Open the game separately to verify rendering and assets.",
"registryHint": "Registry credentials live in CI, not in the CMS. Check the image publication job and its immutable release tag in Gitea."
},
"importHistory": {
"cancelled": "Cancelled",
"cancelFailed": "Could not request cancellation. Retry or refresh the job history.",
"cancelRequested": "Cancellation requested. The current item will finish; remaining items will not start.",
"cancelHint": "Cancellation preserves completed items and allows the current item to finish.",
"cancelRemaining": "Cancel remaining items",
"historyLimit": "Showing up to 30 most recently updated jobs.",
"refreshHistory": "Refresh history",
"showMoreItems": "Show 50 more items",
"uncertainOutcome": "An interrupted item may already have changed local data. It is excluded from retries.",
"reviewLocalData": "Review local data"
},
"operations": {
"title": "Needs attention",
"hint": "Accessible work: imports sample your latest 30 jobs; errors sample up to 1,000 retained events. Older work may be outside these counts.",
"empty": "No pending items found in the checked records.",
"errors": "Unresolved CMS error groups",
"imports": "Your imports needing review",
"tickets": "Open support tickets",
"publications": "News drafts",
"unavailable": "{source}: data is temporarily unavailable.",
"installation": "Installation diagnostics"
} }
}, },
"radioRequests": { "radioRequests": {
@@ -3256,6 +3367,18 @@
"failed": "Failed" "failed": "Failed"
} }
} }
},
"myDashboard": {
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
"allEvents": "All events",
"eventsUnavailable": "The calendar is temporarily unavailable. Open all events to try again.",
"eventOngoing": "Happening now",
"eventUpcoming": "Coming up",
"eventDetails": "View event",
"noUpcomingEvents": "No upcoming events have been announced yet.",
"roomsUnavailable": "Rooms are temporarily unavailable. Open the community to try again."
} }
}, },
"emails": { "emails": {
@@ -3280,5 +3403,36 @@
"fullscreenExit": "Vollbild beenden", "fullscreenExit": "Vollbild beenden",
"show": "Leiste anzeigen", "show": "Leiste anzeigen",
"emulatorUnknown": "Emulatorstatus unbekannt" "emulatorUnknown": "Emulatorstatus unbekannt"
},
"admin": {
"studio": {
"completeness": {
"title": "Completeness and next steps",
"parts": {
"sql": "SQL item",
"catalog": "Catalog offer",
"furnidata": "Furnidata",
"icon": "Local icon",
"nitro": "Nitro file"
},
"statuses": {
"present": "Present",
"missing": "Missing",
"unknown": "Not verified",
"conflict": "Needs review"
},
"actions": {
"sql": "Import or repair this furni to create the missing item record.",
"catalog": "Import or repair this furni to add an offer using the automatic category and price.",
"furnidata": "Import or repair this furni to add the missing furniture data.",
"icon": "Import or repair this furni to retrieve the missing local icon.",
"nitro": "Import or repair this furni to retrieve the asset. For an existing item, Regenerate .nitro is also available.",
"recheck": "Recheck local state before repair. If this persists, check the local file access.",
"audit": "Review duplicate records and matching sprite IDs and types in Catalog audit before repair."
},
"openAudit": "Open Catalog audit to resolve conflicts",
"excludedConflicts": "{count, plural, one {# item needs review and is excluded from this import.} other {# items need review and are excluded from this import.}}"
}
}
} }
} }
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more