diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index c2c3c92d..7c7cc229 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -37,6 +37,9 @@ jobs: - name: Lint run: pnpm biome:lint + - name: CMS translation contracts + run: pnpm i18n:check + - name: Typecheck run: pnpm typecheck diff --git a/CMS_TRANSLATIONS.md b/CMS_TRANSLATIONS.md new file mode 100644 index 00000000..e54fb8c5 --- /dev/null +++ b/CMS_TRANSLATIONS.md @@ -0,0 +1,30 @@ +# CMS translation audit and editor + +The CMS editor at `/admin/translations/cms` now uses the same bundled catalogs as the request-time translator. Runtime changes are stored separately in `storage/cms-translations/.json`, inside the existing persistent `/app/storage` mount. No source-file write, environment-variable change or rebuild is required to apply an edit. + +Only overrides are saved. Unchanged messages continue to receive updates from Git. Saves use a file lock, an atomic replacement and a revision check; a stale editor cannot overwrite another operator's changes. ICU syntax, argument names, rich-text tags and allowed keys are checked server-side. Invalid or obsolete overrides are excluded when reading a new release. Storage read errors are reported to the CMS error monitor and public pages fall back to bundled text. + +The page starts in the operator's language. It shows all English reference keys, including missing translations, and supports search by key, translated text or English source. Filters separate missing, identical and modified text. Drafts survive language switches and failed saves. Users without `SETTINGS_EDIT` can review and export but cannot save. + +## Audit outcome, 6 September 2026 + +- Scanned 25 JSON catalogs; 22 languages are selectable. The small Arabic, Finnish and Japanese catalogs are legacy files and remain outside the supported locale list. +- Repaired 66 malformed ICU messages across the 22 active catalogs, including HTML fragments and unescaped JSON examples. +- Added 199 missing English reference keys used by page components, with Italian translations, and fixed the incorrect navigation namespace in the admin error page. +- Completed the 31 previously missing Italian reference keys. +- Repaired missing `count` and `preset` variables in other locales. +- Final checks: zero malformed messages, zero argument/tag mismatches and zero missing references among the statically resolved translation calls. +- English contains 3,423 reference keys. Italian covers all of them; 629 values match English. Dutch is missing 524 reference keys and has 618 identical values. Matching English can be intentional for names and technical labels; this is not proof of translation quality. +- Found 1,577 literal JSX text candidates outside translation calls. These include labels, technical strings and names; they are an editorial inventory, not 1,577 confirmed bugs. The largest concentrations are the catalog item table (118), Studio main component (79), import audit (53), sound management (50) and permission editor (42). + +## Repeatable checks + +- `pnpm i18n:check`: fails on malformed messages, incompatible variables/tags, empty messages, source parsing errors or missing statically referenced keys. Runs in Gitea CI. +- `pnpm i18n:audit`: prints coverage and findings. +- `node scripts/audit-cms-translations.mjs --json`: full machine-readable inventory, including file and line references for literal JSX candidates. + +Static analysis resolves literal translator namespaces and literal message keys. Dynamic key construction, prose embedded in arbitrary JavaScript strings, and the linguistic accuracy of all 22 translations still require targeted review. English fallback remains explicit; copying English into other catalogs would hide untranslated entries and is intentionally avoided. + +## Validation + +Automated tests exercise message syntax, actual translator output, persistent overrides, invalid-message rejection, revision conflicts and reset behavior. A browser fixture mounts the real editor and verifies missing-key editing, validation, failed-save preservation, language switching, successful saves, read-only access and mobile layout. Server actions are simulated in that fixture; authenticated production editing requires a staff session. diff --git a/drizzle/migrations/0025_article_publication.sql b/drizzle/migrations/0025_article_publication.sql new file mode 100644 index 00000000..53f15d1e --- /dev/null +++ b/drizzle/migrations/0025_article_publication.sql @@ -0,0 +1,7 @@ +-- Existing articles remain published. Preserve any publication settings already present. +ALTER TABLE website_articles + ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'published', + ADD COLUMN IF NOT EXISTS publish_at TIMESTAMP NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS published_at TIMESTAMP NULL DEFAULT NULL; +CREATE INDEX IF NOT EXISTS idx_website_articles_publication + ON website_articles (status, publish_at, created_at); diff --git a/package.json b/package.json index 43f49e7a..da5aac77 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,9 @@ "prepare": "node scripts/prepare-hooks.mjs", "assets:editor": "node scripts/copy-editor-assets.mjs", "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:audit": "node scripts/audit-cms-translations.mjs" }, "lint-staged": { "*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched", @@ -41,6 +43,7 @@ "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", + "@formatjs/icu-messageformat-parser": "3.5.17", "@hookform/resolvers": "5.9.1", "@tanstack/react-virtual": "3.14.10", "class-variance-authority": "0.7.1", @@ -72,10 +75,11 @@ "sharp": "^0.35.4", "sonner": "2.0.8", "tailwind-merge": "3.6.0", - "zod": "4.5.4", - "tinymce": "8.9.0" + "tinymce": "8.9.0", + "zod": "4.5.4" }, "devDependencies": { + "@babel/parser": "7.29.8", "@biomejs/biome": "2.5.12", "@playwright/test": "^1.62.1", "@tailwindcss/forms": "0.5.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a54e2485..bed4d714 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@dnd-kit/utilities': specifier: 3.2.2 version: 3.2.2(react@19.2.8) + '@formatjs/icu-messageformat-parser': + specifier: 3.5.17 + version: 3.5.17 '@hookform/resolvers': specifier: 5.9.1 version: 5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.87.0(react@19.2.8))(zod@4.5.4) @@ -125,6 +128,9 @@ importers: specifier: 4.5.4 version: 4.5.4 devDependencies: + '@babel/parser': + specifier: 7.29.8 + version: 7.29.8 '@biomejs/biome': specifier: 2.5.12 version: 2.5.12 diff --git a/scripts/audit-cms-translations.mjs b/scripts/audit-cms-translations.mjs new file mode 100644 index 00000000..7d8edb53 --- /dev/null +++ b/scripts/audit-cms-translations.mjs @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import path from "node:path"; +import * as babel from "@babel/parser"; +import { parse } from "@formatjs/icu-messageformat-parser"; + +const flat = (obj, p = "", r = {}) => { + for (const [k, v] of Object.entries(obj)) { + const key = p ? `${p}.${k}` : k; + if (v && typeof v === "object") flat(v, key, r); + else r[key] = v; + } + return r; +}; +const messages = Object.fromEntries( + fs + .readdirSync("src/messages") + .filter((n) => n.endsWith(".json")) + .map((n) => [ + n.slice(0, -5), + flat(JSON.parse(fs.readFileSync(`src/messages/${n}`, "utf8"))), + ]), +); +const base = messages.en, + invalid = [], + mismatches = []; +function vars(ast, r = new Set()) { + for (const n of ast) { + if ([1, 2, 3, 4, 5, 6, 8].includes(n.type)) r.add(n.value); + if (n.options) for (const o of Object.values(n.options)) vars(o.value, r); + if (n.children) vars(n.children, r); + } + return [...r].sort().join(","); +} +const signatures = {}; +for (const [k, v] of Object.entries(base)) { + try { + signatures[k] = vars(parse(v)); + } catch {} +} +const locales = Object.entries(messages).map(([locale, m]) => { + for (const [k, v] of Object.entries(m)) { + try { + const sig = vars(parse(v)); + if (k in signatures && sig !== signatures[k]) + mismatches.push({ + locale, + key: k, + expected: signatures[k], + actual: sig, + value: v, + }); + } catch (e) { + invalid.push({ locale, key: k, value: v, error: e.message }); + } + } + return { + locale, + total: Object.keys(m).length, + missing: Object.keys(base).filter((k) => !(k in m)).length, + empty: Object.values(m).filter((v) => !v?.trim()).length, + sameAsEnglish: Object.keys(base).filter((k) => m[k] === base[k]).length, + }; +}); +const missingCalls = [], + hardcoded = [], + unparsedFiles = []; +function walk(n, fn) { + if (!n || typeof n !== "object") return; + fn(n); + for (const [k, v] of Object.entries(n)) { + if (k === "loc" || k === "extra") continue; + if (Array.isArray(v)) + v.forEach((x) => { + walk(x, fn); + }); + else if (v && typeof v === "object") walk(v, fn); + } +} +for (const f of fs + .readdirSync("src", { recursive: true }) + .filter((f) => /\.(tsx|ts)$/.test(f) && !f.includes(".test."))) { + let ast; + try { + ast = babel.parse(fs.readFileSync(path.join("src", f), "utf8"), { + sourceType: "module", + plugins: ["typescript", "jsx"], + }); + } catch (error) { + unparsedFiles.push({ file: f, error: error.message }); + continue; + } + const bindings = {}; + walk(ast, (n) => { + if (n.type === "VariableDeclarator" && n.id.type === "Identifier") { + let c = n.init; + if (c?.type === "AwaitExpression") c = c.argument; + if ( + c?.type === "CallExpression" && + ["useTranslations", "getTranslations"].includes(c.callee.name) + ) { + const arg = c.arguments[0]; + const ns = + arg?.type === "StringLiteral" + ? arg.value + : arg?.type === "ObjectExpression" + ? arg.properties.find((p) => p.key?.name === "namespace")?.value + ?.value + : arg + ? null + : ""; + if (ns !== null) { + bindings[n.id.name] ??= new Set(); + bindings[n.id.name].add(ns); + } + } + } + }); + walk(ast, (n) => { + if (n.type === "CallExpression") { + const name = n.callee.name ?? n.callee.object?.name; + const ns = bindings[name]; + const k = n.arguments[0]; + if (ns?.size === 1 && k?.type === "StringLiteral") { + const full = [...ns][0] ? `${[...ns][0]}.${k.value}` : k.value; + if ( + !(full in base) && + !Object.keys(base).some((b) => b.startsWith(`${full}.`)) + ) + missingCalls.push({ file: f, line: n.loc.start.line, key: full }); + } + } + if (n.type === "JSXText" && /[A-Za-z]{3}/.test(n.value.trim())) + hardcoded.push({ + file: f, + line: n.loc.start.line, + text: n.value.trim().replace(/\s+/g, " "), + }); + }); +} +const result = { + locales, + invalid, + mismatches, + missingCalls, + hardcoded, + unparsedFiles, +}; +if (process.argv.includes("--json")) + console.log(JSON.stringify(result, null, 2)); +else { + console.table(locales); + console.log( + "Invalid ICU:", + invalid.length, + "Variable mismatches:", + mismatches.length, + "Missing static message references:", + missingCalls.length, + ); + console.log( + "Literal JSX text candidates requiring editorial review:", + hardcoded.length, + ); + console.log( + "Coverage checks static translator namespaces and literal keys; dynamic keys and semantic translation quality require review.", + ); + if ( + invalid.length || + mismatches.length || + missingCalls.length || + unparsedFiles.length + ) + console.log( + JSON.stringify( + { invalid, mismatches, missingCalls, unparsedFiles }, + null, + 2, + ), + ); +} +if ( + process.argv.includes("--check") && + (invalid.length || + mismatches.length || + missingCalls.length || + unparsedFiles.length || + locales.some((locale) => locale.empty > 0)) +) + process.exitCode = 1; diff --git a/scripts/jobs-worker.ts b/scripts/jobs-worker.ts index 7f3b50b2..cb62e8ca 100644 --- a/scripts/jobs-worker.ts +++ b/scripts/jobs-worker.ts @@ -12,6 +12,7 @@ import { logger } from "../src/lib/logger"; import { redis } from "../src/lib/redis"; import { emulatorOffline, healthDegraded } from "../src/lib/services/alert"; import { runCatalogExport } from "../src/lib/services/catalog-git-export"; +import { invalidateNewsCache } from "../src/lib/services/news-cache"; import { rcon } from "../src/lib/services/rcon"; function captureWorkerError(err: unknown, context: string): void { @@ -246,12 +247,10 @@ async function publishScheduledArticles(): Promise { lte(WebsiteArticles.publishAt, now), ), ); - const info = result as unknown as { - affectedRows?: number; - rowsAffected?: number; - }; - const published = info.affectedRows ?? info.rowsAffected ?? 0; + const [info] = result; + const published = info.affectedRows ?? 0; if (published > 0) { + await invalidateNewsCache(); logger.info(`Published ${published} scheduled article(s)`, { module: "jobs", }); diff --git a/src/actions/admin-articles.test.ts b/src/actions/admin-articles.test.ts new file mode 100644 index 00000000..ca6d8a4f --- /dev/null +++ b/src/actions/admin-articles.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + rows: [] as unknown[][], + insert: vi.fn(), + update: vi.fn(), + invalidate: vi.fn(), + log: vi.fn(() => "news-error-1"), + notify: vi.fn(), +})); +vi.mock("@/lib/admin/guard", () => ({ + requirePermission: async () => ({ id: 1, username: "staff" }), +})); +vi.mock("@/lib/permissions", () => ({ PERMS: { NEWS_EDIT: "news.edit" } })); +vi.mock("@/lib/services/webhook", () => ({ notify: state.notify })); +vi.mock("@/lib/services/news-cache", () => ({ + invalidateNewsCache: state.invalidate, +})); +vi.mock("@/lib/logger", () => ({ logger: { error: state.log } })); +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string, args?: { reference: string }) => + key + (args?.reference ?? ""), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ + redirect: (url: string) => { + throw Error(`redirect ${url}`); + }, +})); +vi.mock("@/lib/db", async () => ({ + ...(await import("@/db/schema")), + db: { + select: () => ({ + from: () => ({ + where: () => ({ limit: async () => state.rows.shift() ?? [] }), + }), + }), + insert: () => ({ values: state.insert }), + update: () => ({ + set: (value: unknown) => { + state.update(value); + return { where: async () => [{ affectedRows: 1 }] }; + }, + }), + }, +})); + +import { createArticle, updateArticle } from "./admin-articles"; + +function form(extra: Record = {}) { + const f = new FormData(); + for (const [k, v] of Object.entries({ + title: "Test news", + slug: "test-news", + shortStory: "Summary", + fullStory: "Body", + status: "published", + ...extra, + })) + f.set(k, v); + return f; +} +describe("news saves", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.rows = []; + state.insert.mockResolvedValue([{ insertId: 1 }]); + }); + it("preserves a recoverable error and logs its reference", async () => { + state.insert.mockRejectedValueOnce(Error("Unknown column status")); + const result = await createArticle(form()); + expect(result).toEqual({ + ok: false, + error: "saveFailedReferencenews-error-1", + }); + expect(state.invalidate).not.toHaveBeenCalled(); + expect(state.log).toHaveBeenCalled(); + }); + it("saves drafts without publication notifications", async () => { + expect((await createArticle(form({ status: "draft" }))).ok).toBe(true); + expect(state.insert).toHaveBeenCalledWith( + expect.objectContaining({ + status: "draft", + publishAt: null, + publishedAt: null, + }), + ); + expect(state.notify).not.toHaveBeenCalled(); + expect(state.invalidate).toHaveBeenCalledOnce(); + }); + it("preserves the slug and clears old scheduling for immediate publication", async () => { + state.rows = [[{ slug: "test-news", status: "draft", publishedAt: null }]]; + expect( + ( + await updateArticle( + form({ id: "1", publishAt: "2099-01-01T00:00:00Z" }), + ) + ).ok, + ).toBe(true); + expect(state.update).toHaveBeenCalledWith( + expect.objectContaining({ + slug: "test-news", + publishAt: null, + status: "published", + }), + ); + expect(state.invalidate).toHaveBeenCalledOnce(); + }); + it("rejects invalid scheduled dates before writing", async () => { + expect( + (await createArticle(form({ status: "scheduled", publishAt: "invalid" }))) + .ok, + ).toBe(false); + expect(state.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index b9cbf1d1..0b7f6c4c 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -1,9 +1,15 @@ "use server"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { requirePermission } from "@/lib/admin/guard"; +import { + ArticleInputError, + type ArticleSaveResult, + readArticleInput, +} from "@/lib/article-input"; import { db, WebsiteArticleComments, @@ -12,152 +18,139 @@ import { } from "@/lib/db"; import { formPositiveBigInt } from "@/lib/form-data"; import { slugify } from "@/lib/format"; +import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; +import { invalidateNewsCache } from "@/lib/services/news-cache"; import { notify } from "@/lib/services/webhook"; -const ARTICLE_STATUSES = ["published", "scheduled", "draft"] as const; -type ArticleStatus = (typeof ARTICLE_STATUSES)[number]; - -function parseArticleStatus(raw: unknown): ArticleStatus { - const value = String(raw ?? "published").trim(); - return (ARTICLE_STATUSES as readonly string[]).includes(value) - ? (value as ArticleStatus) - : "published"; -} - -function parsePublishAt(raw: unknown): Date | null { - const value = String(raw ?? "").trim(); - if (!value) return null; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} - -async function uniqueSlug(title: string): Promise { +async function uniqueSlug(title: string, excludeId?: bigint): Promise { const base = slugify(title); let slug = base; - let n = 2; - for (;;) { + for (let n = 2; ; n++) { const [existing] = await db .select({ id: WebsiteArticles.id }) .from(WebsiteArticles) - .where(eq(WebsiteArticles.slug, slug)) + .where( + and( + eq(WebsiteArticles.slug, slug), + excludeId ? ne(WebsiteArticles.id, excludeId) : undefined, + ), + ) .limit(1); if (!existing) return slug; - slug = `${base}-${n++}`; + const suffix = `-${n}`; + slug = base.slice(0, 255 - suffix.length) + suffix; } } - -export async function createArticle(formData: FormData): Promise { +async function saveFailure( + error: unknown, + operation: string, +): Promise { + const t = await getTranslations("pages.admin.articles.form"); + if (error instanceof ArticleInputError) + return { ok: false, error: t(error.code) }; + const reference = logger.error("News save failed", { + module: "news", + operation, + error, + }); + return { ok: false, error: t("saveFailedReference", { reference }) }; +} +async function refreshNews() { + await invalidateNewsCache(); + revalidatePath("/admin/articles"); + revalidatePath("/news", "layout"); + revalidatePath("/me"); + revalidatePath("/"); +} +export async function createArticle( + formData: FormData, +): Promise { const staff = await requirePermission(PERMS.NEWS_EDIT); - const title = String(formData.get("title") ?? "") - .normalize("NFC") - .trim(); - const shortStory = String(formData.get("shortStory") ?? "") - .normalize("NFC") - .trim(); - const fullStory = String(formData.get("fullStory") ?? "") - .normalize("NFC") - .trim(); - const image = String(formData.get("image") ?? "") - .normalize("NFC") - .trim(); - const rawSlug = String(formData.get("slug") ?? "").trim(); - const status = parseArticleStatus(formData.get("status")); - const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); - if (!title) return; - if (status === "scheduled" && !parsePublishAt(rawPublishAt)) { - redirect( - "/admin/articles/new?error=Scheduled articles need a valid publish date.", - ); - } - + let input: ReturnType; + let slug: string; try { + input = readArticleInput(formData); + const { rawSlug, ...fields } = input; + slug = await uniqueSlug(rawSlug || fields.title); const now = new Date(); - const publishAt = parsePublishAt(rawPublishAt); - const slug = rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title); await db.insert(WebsiteArticles).values({ + ...fields, slug, - title: title.slice(0, 255), - shortStory: shortStory.slice(0, 255), - fullStory, - image: image.slice(0, 255), userId: staff.id, createdAt: now, updatedAt: now, - status, - publishAt, - publishedAt: status === "published" ? now : null, + publishedAt: fields.status === "published" ? now : null, }); - + } catch (error) { + return saveFailure(error, "create"); + } + // Auxiliary services must not turn a committed insert into an apparent failure. + if (input.status === "published") notify({ action: "news_publish", actor: staff.username, - target: title, + target: input.title, details: slug, }); - } catch { - // Database error — re-render unchanged with error. - redirect( - "/admin/articles/new?error=Database error while creating article. Please try again.", - ); - } - redirect("/admin/articles"); + await refreshNews(); + return { ok: true, data: { redirectTo: "/admin/articles" } }; } - -export async function updateArticle(formData: FormData): Promise { - await requirePermission(PERMS.NEWS_EDIT); +export async function updateArticle( + formData: FormData, +): Promise { + const staff = await requirePermission(PERMS.NEWS_EDIT); + const t = await getTranslations("pages.admin.articles.form"); const id = formPositiveBigInt(formData, "id"); - if (!id) redirect("/admin/articles?error=Missing article id"); - const rawSlug = String(formData.get("slug") ?? "").trim(); - const status = parseArticleStatus(formData.get("status")); - const rawPublishAt = String(formData.get("publishAt") ?? "").trim(); - if (status === "scheduled" && !parsePublishAt(rawPublishAt)) { - redirect( - "/admin/articles?error=Scheduled articles need a valid publish date.", - ); - } + if (!id) return { ok: false, error: t("articleNotFound") }; + let input: ReturnType; + let becamePublished = false; + let slug: string; try { - const publishAt = parsePublishAt(rawPublishAt); + input = readArticleInput(formData); const [existing] = await db .select({ + slug: WebsiteArticles.slug, status: WebsiteArticles.status, publishedAt: WebsiteArticles.publishedAt, }) .from(WebsiteArticles) .where(eq(WebsiteArticles.id, id)) .limit(1); - const publishedAt = - status === "published" ? (existing?.publishedAt ?? new Date()) : null; + if (!existing) return { ok: false, error: t("articleNotFound") }; + const { rawSlug, ...fields } = input; + const requestedSlug = rawSlug ? slugify(rawSlug) : existing.slug; + slug = + requestedSlug === existing.slug + ? existing.slug + : await uniqueSlug(requestedSlug, id); + becamePublished = + fields.status === "published" && existing.status !== "published"; await db .update(WebsiteArticles) .set({ - title: String(formData.get("title") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - ...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}), - shortStory: String(formData.get("shortStory") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - fullStory: String(formData.get("fullStory") ?? "") - .normalize("NFC") - .trim(), - image: String(formData.get("image") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255), - status, - publishAt, - publishedAt, + ...fields, + slug, + publishedAt: + fields.status === "published" + ? (existing.publishedAt ?? new Date()) + : null, updatedAt: new Date(), }) .where(eq(WebsiteArticles.id, id)); - } catch { - redirect("/admin/articles?error=Update failed"); + } catch (error) { + return saveFailure(error, "update"); } + if (becamePublished) + notify({ + action: "news_publish", + actor: staff.username, + target: input.title, + details: slug, + }); + await refreshNews(); revalidatePath(`/admin/articles/${id}`); - redirect("/admin/articles"); + return { ok: true, data: { redirectTo: "/admin/articles" } }; } export async function deleteArticle(formData: FormData): Promise { @@ -185,8 +178,10 @@ export async function deleteArticle(formData: FormData): Promise { actor: staff.username, target: article?.title ?? String(id), }); - } catch { + } catch (error) { + logger.error("News deletion failed", { module: "news", error }); redirect("/admin/articles?error=Delete failed"); } + await refreshNews(); redirect("/admin/articles"); } diff --git a/src/actions/translations.ts b/src/actions/translations.ts index 3163ec76..6cf6dfd5 100644 --- a/src/actions/translations.ts +++ b/src/actions/translations.ts @@ -3,60 +3,47 @@ import fs from "node:fs/promises"; import path from "node:path"; import * as JSONC from "jsonc-parser"; +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; import { z } from "zod"; +import { SUPPORTED_LOCALES } from "@/i18n/locales"; import { CLIENT_TRANSLATION_FILES, getClientTranslationFile, } from "@/lib/client-translation-files"; +import { + CmsTranslationError, + saveCmsTranslation, +} from "@/lib/cms-translations"; import { patchJson5 } from "@/lib/json5-patch"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; const saveTranslationsSchema = z.object({ - locale: z.enum([ - "en", - "it", - "nl", - "de", - "fr", - "es", - "pt", - "pl", - "sv", - "tr", - "ro", - "hu", - "cs", - "sk", - "da", - "no", - "el", - "bg", - "hr", - "sr", - "uk", - "ru", - ]), - data: z.record(z.string(), z.unknown()), + locale: z.enum(SUPPORTED_LOCALES), + revision: z.string().regex(/^[a-f0-9]{64}$/), + changes: z + .record(z.string().max(300), z.string().max(20000).nullable()) + .refine((value) => Object.keys(value).length <= 5000), }); export const saveTranslations = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema }, async (ctx) => { - const filePath = path.join( - process.cwd(), - "src", - "messages", - `${ctx.data.locale}.json`, - ); - await fs.writeFile( - filePath, - JSON.stringify(ctx.data.data, null, 2), - "utf-8", - ); - - return actionOk(); + try { + const snapshot = await saveCmsTranslation( + ctx.data.locale, + ctx.data.revision, + ctx.data.changes, + ); + revalidatePath("/", "layout"); + return actionOk({ snapshot }); + } catch (error) { + if (!(error instanceof CmsTranslationError)) throw error; + const t = await getTranslations("pages.admin.translations.cms"); + return { ok: false, error: t(error.code, { key: error.key }) }; + } }, ); diff --git a/src/app/(site)/me/page.tsx b/src/app/(site)/me/page.tsx index b4e3f445..e05cf991 100644 --- a/src/app/(site)/me/page.tsx +++ b/src/app/(site)/me/page.tsx @@ -99,13 +99,7 @@ export default async function MePage({ ); const canClaim = referralTotal >= needed; const remaining = Math.max(0, needed - referralTotal); - const onlineFriends = [ - ...new Map( - friends - .filter((friend) => friend.online === "1") - .map((friend) => [friend.id, friend]), - ).values(), - ]; + const onlineFriends = friends.filter((friend) => friend.online === "1"); const date = (value: number) => value ? format.dateTime(new Date(value * 1000), { dateStyle: "medium" }) diff --git a/src/app/(site)/news/[...slug]/page.tsx b/src/app/(site)/news/[...slug]/page.tsx index 11136a44..2dddeeac 100644 --- a/src/app/(site)/news/[...slug]/page.tsx +++ b/src/app/(site)/news/[...slug]/page.tsx @@ -8,8 +8,8 @@ import { toggleReaction } from "@/actions/article-reactions"; import Link from "@/components/link"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { SanitizedHtml } from "@/components/shared/sanitized-html"; +import { articleSlug } from "@/lib/article-input"; import { auth } from "@/lib/auth"; -import { cachedQuery } from "@/lib/cached-db"; import { db, User, @@ -20,6 +20,7 @@ import { import { excerpt } from "@/lib/format"; import { formatDate } from "@/lib/format-date"; import { sanitize } from "@/lib/sanitize"; +import { cacheNews } from "@/lib/services/news-cache"; type SearchParams = Promise<{ comment?: string; @@ -46,10 +47,10 @@ function feedbackStyle(tone: "success" | "error"): CSSProperties { export async function generateMetadata({ params, }: { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; }): Promise { const appUrl = process.env.APP_URL ?? "http://localhost:3000"; - const { slug } = await params; + const slug = articleSlug((await params).slug); const [article] = await db .select({ title: WebsiteArticles.title, @@ -95,44 +96,40 @@ export default async function ArticlePage({ params, searchParams, }: { - params: Promise<{ slug: string }>; + params: Promise<{ slug: string[] }>; searchParams: SearchParams; }) { - const { slug } = await params; + const slug = articleSlug((await params).slug); const { comment, reaction, error } = await searchParams; const t = await getTranslations("pages.article"); - const article = await cachedQuery( - `article:slug:${slug}`, - async () => { - const [row] = await db - .select({ - id: WebsiteArticles.id, - slug: WebsiteArticles.slug, - title: WebsiteArticles.title, - shortStory: WebsiteArticles.shortStory, - fullStory: WebsiteArticles.fullStory, - image: WebsiteArticles.image, - createdAt: WebsiteArticles.createdAt, - updatedAt: WebsiteArticles.updatedAt, - }) - .from(WebsiteArticles) - .where( - and( - eq(WebsiteArticles.slug, slug), - eq(WebsiteArticles.status, "published"), - or( - sql`${WebsiteArticles.publishAt} IS NULL`, - sql`${WebsiteArticles.publishAt} <= NOW()`, - ), + const article = await cacheNews(`article:slug:${slug}`, 60_000, async () => { + const [row] = await db + .select({ + id: WebsiteArticles.id, + slug: WebsiteArticles.slug, + title: WebsiteArticles.title, + shortStory: WebsiteArticles.shortStory, + fullStory: WebsiteArticles.fullStory, + image: WebsiteArticles.image, + createdAt: WebsiteArticles.createdAt, + updatedAt: WebsiteArticles.updatedAt, + }) + .from(WebsiteArticles) + .where( + and( + eq(WebsiteArticles.slug, slug), + eq(WebsiteArticles.status, "published"), + or( + sql`${WebsiteArticles.publishAt} IS NULL`, + sql`${WebsiteArticles.publishAt} <= NOW()`, ), - ) - .limit(1) - .catch(() => []); - return row ?? null; - }, - 60, - ); + ), + ) + .limit(1) + .catch(() => []); + return row ?? null; + }); if (!article) notFound(); const articleId = article.id; diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx index 985734d1..2abd7831 100644 --- a/src/app/admin/articles/[id]/page.tsx +++ b/src/app/admin/articles/[id]/page.tsx @@ -31,6 +31,8 @@ export default async function EditArticle({ title: WebsiteArticles.title, shortStory: WebsiteArticles.shortStory, fullStory: WebsiteArticles.fullStory, + status: WebsiteArticles.status, + publishAt: WebsiteArticles.publishAt, image: WebsiteArticles.image, }) .from(WebsiteArticles) @@ -63,7 +65,7 @@ export default async function EditArticle({ action={async (fd) => { "use server"; fd.set("id", String(article.id)); - await updateArticle(fd); + return updateArticle(fd); }} defaultValues={{ title: article.title, @@ -71,6 +73,8 @@ export default async function EditArticle({ image: article.image, shortStory: article.shortStory, fullStory: article.fullStory, + status: article.status, + publishAt: article.publishAt?.toISOString(), }} />
diff --git a/src/app/admin/devops/cms-errors/page.tsx b/src/app/admin/devops/cms-errors/page.tsx index ea194223..375ab3fa 100644 --- a/src/app/admin/devops/cms-errors/page.tsx +++ b/src/app/admin/devops/cms-errors/page.tsx @@ -1,4 +1,5 @@ import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ErrorStore } from "@/lib/error-monitor"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -11,6 +12,7 @@ export default async function CmsErrorsPage({ const { session, permissions } = await getAdminContext(); if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) redirect("/admin"); + const t = await getTranslations("pages.admin.cmsErrors"); const params = await searchParams; const query = (params.q ?? "").trim().slice(0, 200).toLowerCase(); let result: Awaited>; @@ -19,14 +21,15 @@ export default async function CmsErrorsPage({ } catch { return (
- Error storage could not be read. Check the CMS storage volume and - permissions. + {t("storageError")}
); } const filtered = result.records.filter( (r) => (!params.source || r.source === params.source) && + (params.status !== "open" || !r.resolved) && + (params.status !== "resolved" || r.resolved) && (!query || JSON.stringify(r).toLowerCase().includes(query)), ); const groups = new Map(); @@ -43,62 +46,62 @@ export default async function CmsErrorsPage({ ), ); const pageHref = (next: number) => - `?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`; + `?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", status: params.status ?? "", page: String(next) })}`; return (
-

CMS error center

-

- Server and browser errors from the last 7 days. Browser reports are - unverified client observations. -

+

{t("title")}

+

{t("subtitle")}

DevOps - Emulator errors + {t("emulator")} - Refresh / clear filters + {t("refresh")}
+

- {filtered.length} occurrences · {groups.size} groups.{" "} - {result.truncated - ? "Showing the latest 1,000 events. Narrowing filters searches this retained window." - : ""}{" "} - Storage is limited to 10 MB per day; excess events remain in server logs - where available. + {t("counts", { count: filtered.length, groups: groups.size })}{" "} + {result.truncated ? t("truncated") : ""} {t("retention")}

{groups.size === 0 ? ( -
- No captured errors match these filters. Monitoring starts with this - deployment; older console logs are not imported. -
+
{t("empty")}
) : null} {[...groups.entries()] .slice((page - 1) * 25, page * 25) @@ -108,25 +111,24 @@ export default async function CmsErrorsPage({
- {r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "} - {events.length} occurrences · {r.at} + {t(r.resolved ? "resolved" : "open")} · {r.source} ·{" "} + {t("occurrences", { count: events.length })} · {r.at}

{r.event}

{r.message}

- Reference: {r.id} + {t("reference")}: {r.id}

- Release: {r.release} + {t("release")}: {r.release}

 									{JSON.stringify(r.context, null, 2)}
 								
-									{r.stack ||
-										"No stack supplied. Use the reference or digest to correlate server logs."}
+									{r.stack || t("noStack")}
 								
{!r.resolved && canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? ( @@ -137,16 +139,16 @@ export default async function CmsErrorsPage({ value={r.fingerprint} />

- A new occurrence reopens the group automatically. + {t("reopenHint")}

) : null}
- Occurrence references + {t("references")}
    {events.map((e) => ( @@ -156,23 +158,20 @@ export default async function CmsErrorsPage({ ))}
-

- Browser stack traces may reference compiled chunks. This view - does not infer a root cause or include request bodies. -

+

{t("stackHint")}

); })} -