diff --git a/src/actions/admin-ads.test.ts b/src/actions/admin-ads.test.ts index 0f53a96f..a2518a86 100644 --- a/src/actions/admin-ads.test.ts +++ b/src/actions/admin-ads.test.ts @@ -1,98 +1,62 @@ // @ts-nocheck - import { redirect } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { logger } from "@/lib/logger"; import { ActionError } from "@/lib/safe-action-shared"; -import { logStaffActivity } from "@/lib/services/staff-activity"; import { createAd, deleteAd } from "./admin-ads"; -const { insertValues, deleteWhere } = vi.hoisted(() => { - const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - return { insertValues, deleteWhere }; -}); - +const { execute } = vi.hoisted(() => ({ + execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" })), +})); +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ + contentMutationService: { execute }, + createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }), +})); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]), - })), - })), - delete: vi.fn(() => ({ where: deleteWhere })), - }, - WebsiteAds: { id: "id" }, -})); vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); -vi.mock("@/lib/safe-action", () => ({ - adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f), -})); -vi.mock("@/lib/safe-action-shared", () => ({ - ActionError: class extends Error {}, - actionOk: vi.fn(() => "ok"), -})); -vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); +vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler })); +vi.mock("@/lib/safe-action-shared", () => ({ ActionError: class ActionError extends Error {}, actionOk: () => "ok" })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (k: string) => data[k] ?? null, -}); +const fakeForm = (data) => ({ get: (key) => data[key] ?? null }); beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermission).mockResolvedValue(staff as never); - insertValues.mockResolvedValue([{ insertId: 1 }]); - deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); + vi.mocked(requirePermission).mockResolvedValue(staff); + execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" }); }); -describe("createAd", () => { - it("creates ad and redirects", async () => { - await createAd( - fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData, - ); - expect(insertValues).toHaveBeenCalled(); - expect(logStaffActivity).toHaveBeenCalled(); +describe("Content advertisement legacy wrappers", () => { + it("delegates creation and preserves redirect", async () => { + await createAd(fakeForm({ image: "https://example.com/ad.png" })); + expect(execute).toHaveBeenCalledWith(expect.objectContaining({ expectedActorId: 1, legacy: true }), "ad.change", { action: "create", image: "https://example.com/ad.png" }); expect(redirect).toHaveBeenCalledWith("/admin/ads"); }); - it("returns early when image empty", async () => { - await createAd(fakeForm({ image: "" }) as unknown as FormData); - expect(insertValues).not.toHaveBeenCalled(); + it("returns early when image is empty", async () => { + await createAd(fakeForm({ image: "" })); + expect(execute).not.toHaveBeenCalled(); }); - it("logs error on db failure", async () => { - insertValues.mockRejectedValue(new Error("db")); - await createAd(fakeForm({ image: "x" }) as unknown as FormData); - expect(logger.error).toHaveBeenCalled(); + it("logs a redacted service failure", async () => { + execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" }); + await createAd(fakeForm({ image: "x" })); + expect(logger.error).toHaveBeenCalledWith("Action failed: createAd", expect.objectContaining({ error: "errors.housekeeping.dependencyUnavailable" })); }); -}); -describe("deleteAd", () => { - it("deletes ad and returns ok", async () => { - const h = deleteAd as unknown as (ctx: { - data: { id: bigint }; - session: { user: { id: string } }; - }) => Promise; - expect( - await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }), - ).toBe("ok"); + it("delegates deletion and preserves action result", async () => { + const handler = deleteAd as unknown as (ctx: unknown) => Promise; + await expect(handler({ data: { id: 99n }, session: { user: { id: "1" } }, requestId: "delete" })).resolves.toBe("ok"); + expect(execute).toHaveBeenCalledWith(expect.objectContaining({ correlationId: "delete" }), "ad.change", { action: "delete", id: "99" }); }); - it("throws ActionError when not found", async () => { - deleteWhere.mockResolvedValue([{ affectedRows: 0 }]); - const h = deleteAd as unknown as (ctx: { - data: { id: bigint }; - session: { user: { id: string } }; - }) => Promise; - await expect( - h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }), - ).rejects.toThrow(ActionError); + it("preserves not-found ActionError", async () => { + execute.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" }, correlationId: "legacy" }); + const handler = deleteAd as unknown as (ctx: unknown) => Promise; + await expect(handler({ data: { id: 999n }, session: { user: { id: "1" } }, requestId: "missing" })).rejects.toThrow(ActionError); }); }); diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index f1bc2784..5b0abd82 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -1,48 +1,30 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteAds } from "@/lib/db"; import { logger } from "@/lib/logger"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; -import { logStaffActivity } from "@/lib/services/staff-activity"; - -// CRUD for website advertisements (website_ads). Emulator does not own this -// table; it only stores an image URL rendered in the site layout/widgets. export async function createAd(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const image = String(formData.get("image") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); + const image = String(formData.get("image") ?? "").normalize("NFC").trim().slice(0, 255); if (!image) return; - - const now = new Date(); - try { - const [result] = (await db.insert(WebsiteAds).values({ - image, - createdAt: now, - updatedAt: now, - })) as unknown as [ResultSetHeader]; - await logStaffActivity({ - staffId: staff.id, - action: "ad_create", - description: `Created advertisement #${result.insertId} (${image})`, - targetType: "website_ad", - targetId: Number(result.insertId), - }); - } catch (err) { - logger.error("Action failed: createAd", { - action: "createAd", - error: err instanceof Error ? err.message : "DB error", - }); + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "ad.change", + { action: "create", image }, + ); + if (!result.ok) { + logger.error("Action failed: createAd", { action: "createAd", error: result.error.messageKey }); revalidatePath("/admin/ads"); return; } @@ -52,66 +34,35 @@ export async function createAd(formData: FormData): Promise { export async function updateAd(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const raw = String(formData.get("id") ?? "").normalize("NFC"); - if (!/^\d+$/.test(raw)) return; - const id = BigInt(raw); - const image = String(formData.get("image") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); + if (!/^\d+$/u.test(raw)) return; + const image = String(formData.get("image") ?? "").normalize("NFC").trim().slice(0, 255); if (!image) return; - - try { - await db - .update(WebsiteAds) - .set({ image, updatedAt: new Date() }) - .where(eq(WebsiteAds.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "ad_update", - description: `Updated advertisement #${id} (${image})`, - targetType: "website_ad", - targetId: Number(id), - }); - } catch (err) { - logger.error("Action failed: updateAd", { - action: "updateAd", - id: Number(id), - error: err instanceof Error ? err.message : "DB error", - }); - revalidatePath(`/admin/ads/${id}`); + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "ad.change", + { action: "update", id: raw, image }, + ); + if (!result.ok) { + logger.error("Action failed: updateAd", { action: "updateAd", id: Number(raw), error: result.error.messageKey }); + revalidatePath("/admin/ads/" + raw); return; } redirect("/admin/ads"); } const deleteAdInput = z.object({ - id: z - .union([z.string(), z.number(), z.bigint()]) - .transform((v) => BigInt(String(v))), + id: z.union([z.string(), z.number(), z.bigint()]).transform((value) => BigInt(String(value))), }); export const deleteAd = adminAction( { permission: PERMS.PAGES_EDIT, schema: deleteAdInput }, async (ctx) => { - const id = ctx.data.id; - try { - const [result] = (await db - .delete(WebsiteAds) - .where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader]; - if (!result.affectedRows) { - throw new ActionError("Advertisement not found"); - } - } catch (err) { - if (err instanceof ActionError) throw err; - throw new ActionError("Advertisement not found"); - } - await logStaffActivity({ - staffId: Number(ctx.session.user.id), - action: "ad_delete", - description: `Deleted advertisement #${id}`, - targetType: "website_ad", - targetId: Number(id), - }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "ad.change", + { action: "delete", id: ctx.data.id.toString() }, + ); + if (!result.ok) throw new ActionError("Advertisement not found"); revalidatePath("/admin/ads"); return actionOk(); }, diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 5369ed9c..742f2e16 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -1,120 +1,61 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requirePermission } from "@/lib/admin/guard"; import { - db, - WebsiteArticleComments, - WebsiteArticleReactions, - WebsiteArticles, -} from "@/lib/db"; -import { slugify } from "@/lib/format"; + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; -async function uniqueSlug(title: string): Promise { - const base = slugify(title); - let slug = base; - let n = 2; - for (;;) { - const [existing] = await db - .select({ id: WebsiteArticles.id }) - .from(WebsiteArticles) - .where(eq(WebsiteArticles.slug, slug)) - .limit(1); - if (!existing) return slug; - slug = `${base}-${n++}`; - } +function articleInput(formData: FormData) { + return { + title: String(formData.get("title") ?? "").normalize("NFC").trim(), + shortStory: String(formData.get("shortStory") ?? "").normalize("NFC").trim(), + fullStory: String(formData.get("fullStory") ?? "").normalize("NFC").trim(), + image: String(formData.get("image") ?? "").normalize("NFC").trim(), + slug: String(formData.get("slug") ?? "").trim(), + }; } 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(); - if (!title) return; - - try { - const now = new Date(); - await db.insert(WebsiteArticles).values({ - slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title), - title: title.slice(0, 255), - shortStory: shortStory.slice(0, 255), - fullStory, - image: image.slice(0, 255), - userId: staff.id, - createdAt: now, - updatedAt: now, - }); - } catch { - // Database error — re-render unchanged with error. - redirect( - "/admin/articles/new?error=Database error while creating article. Please try again.", - ); + const input = articleInput(formData); + if (!input.title) return; + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "article.change", + { action: "create", ...input }, + ); + if (!result.ok) { + redirect("/admin/articles/new?error=Database error while creating article. Please try again."); } redirect("/admin/articles"); } export async function updateArticle(formData: FormData): Promise { - await requirePermission(PERMS.NEWS_EDIT); - const id = BigInt(String(formData.get("id"))); - const rawSlug = String(formData.get("slug") ?? "").trim(); - try { - 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), - updatedAt: new Date(), - }) - .where(eq(WebsiteArticles.id, id)); - } catch { - redirect("/admin/articles?error=Update failed"); - } - revalidatePath(`/admin/articles/${id}`); + const staff = await requirePermission(PERMS.NEWS_EDIT); + const id = String(formData.get("id") ?? ""); + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "article.change", + { action: "update", id, ...articleInput(formData) }, + ); + if (!result.ok) redirect("/admin/articles?error=Update failed"); + revalidatePath("/admin/articles/" + id); redirect("/admin/articles"); } export async function deleteArticle(formData: FormData): Promise { - await requirePermission(PERMS.NEWS_EDIT); - const id = BigInt(String(formData.get("id"))); - try { - await db.transaction(async (tx) => { - await tx - .delete(WebsiteArticleReactions) - .where(eq(WebsiteArticleReactions.articleId, id)); - await tx - .delete(WebsiteArticleComments) - .where(eq(WebsiteArticleComments.articleId, id)); - await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id)); - }); - } catch { - redirect("/admin/articles?error=Delete failed"); - } + const staff = await requirePermission(PERMS.NEWS_EDIT); + const id = String(formData.get("id") ?? ""); + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "article.change", + { action: "delete", id }, + ); + if (!result.ok) redirect("/admin/articles?error=Delete failed"); redirect("/admin/articles"); } diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts index 12e47254..8250ffb6 100644 --- a/src/actions/admin-email-templates.ts +++ b/src/actions/admin-email-templates.ts @@ -1,76 +1,49 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, EmailTemplates } from "@/lib/db"; -import { formPositiveBigInt } from "@/lib/form-data"; import { PERMS } from "@/lib/permissions"; -export async function createEmailTemplate(formData: FormData): Promise { - await requirePermission(PERMS.PAGES_EDIT); - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const subject = String(formData.get("subject") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const body = String(formData.get("body") ?? "").normalize("NFC"); - const variablesRaw = String(formData.get("variables") ?? "") - .normalize("NFC") - .trim(); - const isActive = formData.get("isActive") != null; - if (!name || !subject || !body) return; +function templateInput(formData: FormData) { + return { + name: String(formData.get("name") ?? "").normalize("NFC").trim().slice(0, 255), + subject: String(formData.get("subject") ?? "").normalize("NFC").trim().slice(0, 255), + body: String(formData.get("body") ?? "").normalize("NFC"), + variables: String(formData.get("variables") ?? "").normalize("NFC").trim(), + isActive: formData.get("isActive") != null, + }; +} - await db.insert(EmailTemplates).values({ - name, - subject, - body, - variables: variablesRaw || null, - isActive, - }); +export async function createEmailTemplate(formData: FormData): Promise { + const staff = await requirePermission(PERMS.PAGES_EDIT); + const input = templateInput(formData); + if (!input.name || !input.subject || !input.body) return; + const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "create", ...input }); + if (!result.ok) throw new Error("Email template creation failed"); revalidatePath("/admin/email-templates"); } export async function updateEmailTemplate(formData: FormData): Promise { - await requirePermission(PERMS.PAGES_EDIT); - const raw = String(formData.get("id") ?? "").normalize("NFC"); - if (!raw) return; - let id: bigint; - try { - id = BigInt(raw); - } catch { - return; - } - const subject = String(formData.get("subject") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - const body = String(formData.get("body") ?? "").normalize("NFC"); - const variablesRaw = String(formData.get("variables") ?? "") - .normalize("NFC") - .trim(); - const isActive = formData.get("isActive") != null; - if (!subject || !body) return; - - await db - .update(EmailTemplates) - .set({ - subject, - body, - variables: variablesRaw || null, - isActive, - }) - .where(eq(EmailTemplates.id, id)); + const staff = await requirePermission(PERMS.PAGES_EDIT); + const id = String(formData.get("id") ?? "").normalize("NFC"); + if (!/^\d+$/u.test(id)) return; + const input = templateInput(formData); + if (!input.subject || !input.body) return; + const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "update", id, ...input }); + if (!result.ok) throw new Error("Email template update failed"); revalidatePath("/admin/email-templates"); } export async function deleteEmailTemplate(formData: FormData): Promise { - await requirePermission(PERMS.PAGES_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id)); + const staff = await requirePermission(PERMS.PAGES_EDIT); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + if (!/^[1-9]\d*$/u.test(id)) return; + const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "delete", id }); + if (!result.ok) throw new Error("Email template deletion failed"); revalidatePath("/admin/email-templates"); } diff --git a/src/actions/admin-help.test.ts b/src/actions/admin-help.test.ts index 2009298b..678dcbc2 100644 --- a/src/actions/admin-help.test.ts +++ b/src/actions/admin-help.test.ts @@ -1,77 +1,41 @@ import { redirect } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { - createHelpQuestion, - deleteHelpQuestion, - updateHelpQuestion, -} from "./admin-help"; - -const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => { - const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]); - const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - return { insertValues, updateWhere, deleteWhere }; -}); +import { createHelpQuestion, deleteHelpQuestion, updateHelpQuestion } from "./admin-help"; +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ + contentMutationService: { execute }, + createContentMutationInvocation: (actor: { id: number }, correlationId: string) => ({ expectedActorId: actor.id, correlationId, legacy: true }), +})); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })), - delete: vi.fn(() => ({ where: deleteWhere })), - }, - WebsiteHelpCenterCategories: { id: "id" }, -})); -vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => (key in data ? data[key] : null), -}); +const fakeForm = (data: Record) => ({ get: (key: string) => key in data ? data[key] : null }); beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); - insertValues.mockResolvedValue([{ insertId: 5 }]); - updateWhere.mockResolvedValue([{ affectedRows: 1 }]); - deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); + execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "5" } }, correlationId: "legacy" }); }); -describe("createHelpQuestion", () => { - it("creates a help question and redirects", async () => { - await createHelpQuestion( - fakeForm({ - name: "FAQ", - content: "

Answer

", - }) as unknown as FormData, - ); - expect(insertValues).toHaveBeenCalled(); +describe("Content help legacy wrappers", () => { + it("delegates create and redirects", async () => { + await createHelpQuestion(fakeForm({ name: "FAQ", content: "

Answer

" }) as FormData); + expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "create", name: "FAQ" })); expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); }); -}); - -describe("updateHelpQuestion", () => { - it("updates and redirects", async () => { - await updateHelpQuestion( - fakeForm({ - id: "42", - name: "Updated", - content: "New", - }) as unknown as FormData, - ); - expect(updateWhere).toHaveBeenCalled(); + it("delegates update and redirects", async () => { + await updateHelpQuestion(fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData); + expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "update", id: "42" })); expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); }); -}); - -describe("deleteHelpQuestion", () => { - it("deletes and redirects", async () => { - await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData); - expect(deleteWhere).toHaveBeenCalled(); + it("delegates delete and redirects", async () => { + await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData); + expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", { action: "delete", id: "42" }); expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); }); }); diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index a59258ff..6a3b1cd1 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -1,63 +1,38 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteHelpCenterCategories } from "@/lib/db"; -import { formPositiveBigInt } from "@/lib/form-data"; import { canonicalize, sanitizeField } from "@/lib/foundation/security"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -// CRUD for help-center FAQ entries (website_help_center_categories). Each entry -// is a titled content block with an optional image and call-to-action button. - -function parsePosition(value: FormDataEntryValue | null): number { - const n = Number(value); - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1; +function helpInput(formData: FormData) { + return { + name: sanitizeField(formData.get("name")), + content: canonicalize(String(formData.get("content") ?? "")), + position: Number(formData.get("position")) > 0 ? Math.floor(Number(formData.get("position"))) : 1, + imageUrl: sanitizeField(formData.get("imageUrl")), + buttonText: sanitizeField(formData.get("buttonText")), + buttonUrl: sanitizeField(formData.get("buttonUrl")), + buttonColor: sanitizeField(formData.get("buttonColor"), 16) || "#eeb425", + buttonBorderColor: sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15", + smallBox: formData.get("smallBox") != null, + }; } export async function createHelpQuestion(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const name = sanitizeField(formData.get("name")); - const content = canonicalize(String(formData.get("content") ?? "")); - if (!name || !content) return; - - const imageUrl = sanitizeField(formData.get("imageUrl")); - const buttonText = sanitizeField(formData.get("buttonText")); - const buttonUrl = sanitizeField(formData.get("buttonUrl")); - const buttonColor = - sanitizeField(formData.get("buttonColor"), 16) || "#eeb425"; - const buttonBorderColor = - sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; - - try { - const [result] = (await db.insert(WebsiteHelpCenterCategories).values({ - name, - content, - position: parsePosition(formData.get("position")), - imageUrl: imageUrl || null, - buttonText: buttonText || null, - buttonUrl: buttonUrl || null, - buttonColor, - buttonBorderColor, - smallBox: formData.get("smallBox") != null, - })) as unknown as [ResultSetHeader]; - await logStaffActivity({ - staffId: staff.id, - action: "help_create", - description: `Created help-center entry #${result.insertId} (${name})`, - targetType: "help_center_category", - targetId: Number(result.insertId), - }); - } catch { - // Unique name collision or DB error — re-render unchanged with error. + const input = helpInput(formData); + if (!input.name || !input.content) return; + const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "create", ...input }); + if (!result.ok) { revalidatePath("/admin/help-questions"); - redirect( - "/admin/help-questions/new?error=Unique name collision or database error. Please try again.", - ); + redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again."); } revalidatePath("/admin/help-questions"); redirect("/admin/help-questions"); @@ -65,46 +40,13 @@ export async function createHelpQuestion(formData: FormData): Promise { export async function updateHelpQuestion(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - - const name = sanitizeField(formData.get("name")); - const content = canonicalize(String(formData.get("content") ?? "")); - if (!name || !content) return; - - const imageUrl = sanitizeField(formData.get("imageUrl")); - const buttonText = sanitizeField(formData.get("buttonText")); - const buttonUrl = sanitizeField(formData.get("buttonUrl")); - const buttonColor = - sanitizeField(formData.get("buttonColor"), 16) || "#eeb425"; - const buttonBorderColor = - sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; - - try { - await db - .update(WebsiteHelpCenterCategories) - .set({ - name, - content, - position: parsePosition(formData.get("position")), - imageUrl: imageUrl || null, - buttonText: buttonText || null, - buttonUrl: buttonUrl || null, - buttonColor, - buttonBorderColor, - smallBox: formData.get("smallBox") != null, - }) - .where(eq(WebsiteHelpCenterCategories.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "help_update", - description: `Updated help-center entry #${id} (${name})`, - targetType: "help_center_category", - targetId: Number(id), - }); - } catch { - // Not found, unique collision, or DB error — ignore. - revalidatePath(`/admin/help-questions/${id}`); + const id = String(formData.get("id") ?? "").trim(); + if (!/^[1-9]\d*$/u.test(id)) return; + const input = helpInput(formData); + if (!input.name || !input.content) return; + const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "update", id, ...input }); + if (!result.ok) { + revalidatePath("/admin/help-questions/" + id); return; } redirect("/admin/help-questions"); @@ -112,22 +54,8 @@ export async function updateHelpQuestion(formData: FormData): Promise { export async function deleteHelpQuestion(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const id = formPositiveBigInt(formData, "id"); - if (!id) return; - - try { - await db - .delete(WebsiteHelpCenterCategories) - .where(eq(WebsiteHelpCenterCategories.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "help_delete", - description: `Deleted help-center entry #${id}`, - targetType: "help_center_category", - targetId: Number(id), - }); - } catch { - // Not found or DB error — ignore. - } + const id = String(formData.get("id") ?? "").trim(); + if (!/^[1-9]\d*$/u.test(id)) return; + await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "delete", id }); redirect("/admin/help-questions"); } diff --git a/src/actions/admin-media.test.ts b/src/actions/admin-media.test.ts index 986e72b7..9d458550 100644 --- a/src/actions/admin-media.test.ts +++ b/src/actions/admin-media.test.ts @@ -1,59 +1,39 @@ // @ts-nocheck - -import path from "node:path"; import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { resolveMediaPath } from "@/lib/media-storage"; import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media"; +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); -vi.mock("@/lib/media-storage", () => { - const root = path.join("/tmp", "nexst-test-media"); - return { - MEDIA_ROOT: root, - resolveMediaPath: vi.fn((name: string) => path.join(root, name)), - }; -}); -vi.mock("node:fs/promises", () => ({ - mkdir: vi.fn(), - writeFile: vi.fn(), - unlink: vi.fn(), -})); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); -const staff = { id: 1, rank: 7, username: "admin" }; - beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermission).mockResolvedValue(staff as never); + vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); + execute.mockResolvedValue({ ok: true, data: { before: null, after: { name: "photo.png" }, output: { url: "/api/media/photo.png" } }, correlationId: "legacy" }); }); -describe("uploadMedia", () => { - it("returns error when no file provided", async () => { - const result = await uploadMedia(new FormData()); - expect(result.ok).toBe(false); - expect(result.error).toBe("No file provided"); - }); -}); - -describe("uploadMediaAndReturn", () => { - it("returns empty string when no file", async () => { +describe("Content media legacy wrappers", () => { + it("retains no-file validation", async () => { + expect(await uploadMedia(new FormData())).toEqual({ ok: false, error: "No file provided" }); expect(await uploadMediaAndReturn(new FormData())).toBe(""); + expect(execute).not.toHaveBeenCalled(); }); -}); - -describe("deleteMedia", () => { - it("deletes media file and revalidates", async () => { + it("delegates a valid upload and preserves both result shapes", async () => { + const file = new File(["bytes"], "photo.png", { type: "image/png" }); + const form = new FormData(); + form.set("file", file); + expect(await uploadMedia(form)).toEqual({ ok: true }); + expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png"); + expect(execute).toHaveBeenCalledWith(expect.anything(), "media.upload", { file }); + }); + it("delegates deletion and preserves revalidation", async () => { await deleteMedia("photo.png"); + expect(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", { filename: "photo.png" }); expect(revalidatePath).toHaveBeenCalledWith("/api/media"); - }); - - it("skips deletion when path is outside media root", async () => { - vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd"); - await deleteMedia("../../../etc/passwd"); - const { unlink } = await import("node:fs/promises"); - expect(unlink).not.toHaveBeenCalled(); + expect(revalidatePath).toHaveBeenCalledWith("/admin/media"); }); }); diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts index 02962c26..e5901b03 100644 --- a/src/actions/admin-media.ts +++ b/src/actions/admin-media.ts @@ -1,83 +1,67 @@ "use server"; -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; import { revalidatePath } from "next/cache"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage"; import { PERMS } from "@/lib/permissions"; -const MAX_SIZE = 5 * 1024 * 1024; // 5MB +const MAX_SIZE = 5 * 1024 * 1024; const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"]; -export async function uploadMedia( - formData: FormData, -): Promise<{ ok: boolean; error?: string }> { - await requirePermission(PERMS.PAGES_EDIT); - const file = formData.get("file") as File | null; - if (!file || file.size === 0) return { ok: false, error: "No file provided" }; - if (file.size > MAX_SIZE) - return { ok: false, error: "File too large (max 5MB)" }; - if (!ALLOWED.includes(file.type)) - return { - ok: false, - error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP", - }; +function mediaFile(formData: FormData): File | null { + const value = formData.get("file"); + return value && typeof value === "object" ? (value as File) : null; +} - const baseDir = MEDIA_ROOT; - // eslint-disable-next-line security/detect-non-literal-fs-filename - await mkdir(baseDir, { recursive: true }); - - const ext = file.name.split(".").pop() ?? "png"; - const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; - const bytes = await file.arrayBuffer(); - const filePath = resolveMediaPath(name); - if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path"); - // eslint-disable-next-line security/detect-non-literal-fs-filename - await writeFile(filePath, Buffer.from(bytes)); +function validateMediaFile(file: File | null): string | null { + if (!file || file.size === 0) return "No file provided"; + if (file.size > MAX_SIZE) return "File too large (max 5MB)"; + if (!ALLOWED.includes(file.type)) return "Invalid file type. Allowed: PNG, JPEG, GIF, WebP"; + return null; +} +export async function uploadMedia(formData: FormData): Promise<{ ok: boolean; error?: string }> { + const staff = await requirePermission(PERMS.PAGES_EDIT); + const file = mediaFile(formData); + const error = validateMediaFile(file); + if (error) return { ok: false, error }; + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "media.upload", + { file }, + ); + if (!result.ok) throw new Error("Media upload failed"); revalidatePath("/api/media"); revalidatePath("/admin/media"); return { ok: true }; } export async function deleteMedia(name: string): Promise { - await requirePermission(PERMS.PAGES_EDIT); - const { unlink } = await import("node:fs/promises"); - const baseDir = MEDIA_ROOT; - const filePath = resolveMediaPath(name); - if (!filePath.startsWith(baseDir + path.sep)) return; - try { - await unlink(filePath); - } catch { - // File may not exist - } + const staff = await requirePermission(PERMS.PAGES_EDIT); + await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "media.delete", + { filename: name }, + ); revalidatePath("/api/media"); revalidatePath("/admin/media"); } -export async function uploadMediaAndReturn( - formData: FormData, -): Promise { - await requirePermission(PERMS.PAGES_EDIT); - const file = formData.get("file") as File | null; - if (!file || file.size === 0) return ""; - if (file.size > MAX_SIZE) return ""; - if (!ALLOWED.includes(file.type)) return ""; - - const baseDir = MEDIA_ROOT; - // eslint-disable-next-line security/detect-non-literal-fs-filename - await mkdir(baseDir, { recursive: true }); - - const ext = file.name.split(".").pop() ?? "png"; - const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; - const bytes = await file.arrayBuffer(); - const filePath = resolveMediaPath(name); - if (!filePath.startsWith(baseDir + path.sep)) return ""; - // eslint-disable-next-line security/detect-non-literal-fs-filename - await writeFile(filePath, Buffer.from(bytes)); - +export async function uploadMediaAndReturn(formData: FormData): Promise { + const staff = await requirePermission(PERMS.PAGES_EDIT); + const file = mediaFile(formData); + if (validateMediaFile(file)) return ""; + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "media.upload", + { file }, + ); + if (!result.ok) return ""; revalidatePath("/api/media"); revalidatePath("/admin/media"); - return `/api/media/${name}`; + return typeof result.data.output?.url === "string" ? result.data.output.url : ""; } diff --git a/src/actions/admin-nav-menu.ts b/src/actions/admin-nav-menu.ts index 7460a81f..1b7f18b2 100644 --- a/src/actions/admin-nav-menu.ts +++ b/src/actions/admin-nav-menu.ts @@ -2,14 +2,9 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { - ADMIN_NAV_CONFIG_KEY, - type AdminNavConfig, - serializeAdminNavConfig, -} from "@/lib/admin-nav-config"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { actionOk, adminAction } from "@/lib/foundation/action"; import { PERMS } from "@/lib/permissions"; -import { siteSettings } from "@/lib/services/site-settings"; const schema = z.object({ groupOrder: z.array(z.string()), @@ -26,16 +21,12 @@ export const saveAdminNavConfig = adminAction( rateLimitMax: 30, }, async (ctx) => { - const config: AdminNavConfig = { - groupOrder: ctx.data.groupOrder, - hiddenGroups: ctx.data.hiddenGroups, - hiddenItems: ctx.data.hiddenItems, - itemOrder: ctx.data.itemOrder, - }; - await siteSettings.update( - ADMIN_NAV_CONFIG_KEY, - serializeAdminNavConfig(config), + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "navigation.update", + ctx.data, ); + if (!result.ok) throw new Error("Navigation update failed"); revalidatePath("/admin", "layout"); revalidatePath("/admin/menu"); return actionOk({ saved: true }); diff --git a/src/actions/admin-photos.test.ts b/src/actions/admin-photos.test.ts index d90175dc..ce8ac4f4 100644 --- a/src/actions/admin-photos.test.ts +++ b/src/actions/admin-photos.test.ts @@ -2,71 +2,29 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; -import { logStaffActivity } from "@/lib/services/staff-activity"; import { deletePhoto } from "./admin-photos"; -const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => { - const limit = vi.fn(); - const whereSelect = vi.fn(() => ({ limit })); - const from = vi.fn(() => ({ where: whereSelect })); - const select = vi.fn(() => ({ from })); - const whereDelete = vi.fn(); - const deleteFn = vi.fn(() => ({ where: whereDelete })); - return { select, deleteFn, limit, whereDelete, whereSelect, from }; -}); - +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); -vi.mock("@/lib/admin/photo-files", () => ({ - tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true), -})); -vi.mock("@/lib/services/staff-activity", () => ({ - logStaffActivity: vi.fn().mockResolvedValue(undefined), -})); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); -vi.mock("@/lib/db", () => ({ - db: { - select: (...args) => select(...args), - delete: (...args) => deleteFn(...args), - }, - CameraWeb: { id: "id", url: "url" }, -})); - -const fakeForm = (data) => ({ - get: (key) => data[key] ?? null, -}); beforeEach(() => { vi.clearAllMocks(); - limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]); - whereDelete.mockResolvedValue(undefined); - vi.mocked(requirePermission).mockResolvedValue({ - id: 1, - rank: 7, - username: "admin", - }); + vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); + execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" }); }); describe("deletePhoto", () => { - it("deletes a photo and revalidates", async () => { - await deletePhoto(fakeForm({ id: "42" })); - expect(select).toHaveBeenCalled(); - expect(deleteFn).toHaveBeenCalled(); - expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png"); - expect(logStaffActivity).toHaveBeenCalledWith( - expect.objectContaining({ - action: "photo_delete", - targetId: 42, - }), - ); + it("delegates deletion and preserves both revalidations", async () => { + await deletePhoto({ get: (key) => key === "id" ? "42" : null }); + expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 }); expect(revalidatePath).toHaveBeenCalledWith("/admin/photos"); expect(revalidatePath).toHaveBeenCalledWith("/photos"); }); - it("returns early when id is not positive", async () => { - await deletePhoto(fakeForm({ id: "0" })); - expect(select).not.toHaveBeenCalled(); - expect(deleteFn).not.toHaveBeenCalled(); + await deletePhoto({ get: () => "0" }); + expect(execute).not.toHaveBeenCalled(); }); }); diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts index 60a9161b..95640fc3 100644 --- a/src/actions/admin-photos.ts +++ b/src/actions/admin-photos.ts @@ -1,36 +1,19 @@ "use server"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; -import { CameraWeb, db } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; export async function deletePhoto(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const id = Number(formData.get("id")); if (!(id > 0)) return; - - const [row] = await db - .select({ id: CameraWeb.id, url: CameraWeb.url }) - .from(CameraWeb) - .where(eq(CameraWeb.id, id)) - .limit(1); - - if (row) { - await db.delete(CameraWeb).where(eq(CameraWeb.id, id)); - await tryRemoveLocalPhotoFile(row.url); - await logStaffActivity({ - staffId: staff.id, - action: "photo_delete", - description: `Deleted camera photo #${id}`, - targetType: "camera_web", - targetId: id, - }); - } - + await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "photo.delete", { id }); revalidatePath("/admin/photos"); revalidatePath("/photos"); } diff --git a/src/actions/admin-tags.test.ts b/src/actions/admin-tags.test.ts index 733c9853..ae1f469c 100644 --- a/src/actions/admin-tags.test.ts +++ b/src/actions/admin-tags.test.ts @@ -2,133 +2,55 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; -import { logStaffActivity } from "@/lib/services/staff-activity"; import { createTag, deleteTag, updateTag } from "./admin-tags"; -const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted( - () => { - const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]); - const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]); - const transaction = vi.fn(async (fn) => - fn({ - delete: vi.fn(() => ({ where: deleteWhere })), - }), - ); - return { insertValues, updateWhere, deleteWhere, transaction }; - }, -); - +const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })), - delete: vi.fn(() => ({ where: deleteWhere })), - transaction, - }, - Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" }, - Taggables: { tagId: "tagId" }, -})); -vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); -const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); - +const form = (data) => ({ get: (key) => data[key] ?? null }); beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermission).mockResolvedValue(staff as never); - insertValues.mockResolvedValue([{ insertId: 1 }]); - updateWhere.mockResolvedValue([{ affectedRows: 1 }]); - deleteWhere.mockResolvedValue([{ affectedRows: 1 }]); - transaction.mockImplementation(async (fn) => - fn({ - delete: vi.fn(() => ({ where: deleteWhere })), - }), - ); + vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); + execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" } }, correlationId: "legacy" }); }); -describe("createTag", () => { - it("creates a tag and revalidates", async () => { - await createTag( - fakeForm({ - name: "News", - backgroundColor: "#ff0000", - }) as unknown as FormData, - ); - - expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ name: "News" }), - ); - expect(logStaffActivity).toHaveBeenCalled(); +describe("Content tag legacy wrappers", () => { + it("delegates create with normalized values", async () => { + await createTag(form({ name: "News", backgroundColor: "#ff0000" })); + expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "create", name: "News", backgroundColor: "#ff0000" }); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); }); - - it("returns early when name is empty", async () => { - await createTag(fakeForm({ name: "" }) as unknown as FormData); - expect(insertValues).not.toHaveBeenCalled(); + it("uses the legacy default color", async () => { + await createTag(form({ name: "Test" })); + expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ backgroundColor: "#888888" })); }); - - it("uses default color when not provided", async () => { - await createTag(fakeForm({ name: "Test" }) as unknown as FormData); - - expect(insertValues).toHaveBeenCalledWith( - expect.objectContaining({ backgroundColor: "#888888" }), - ); + it("returns early for an empty name", async () => { + await createTag(form({ name: "" })); + expect(execute).not.toHaveBeenCalled(); }); - - it("handles db error gracefully", async () => { - insertValues.mockRejectedValue(new Error("DB error")); - - await expect( - createTag(fakeForm({ name: "News" }) as unknown as FormData), - ).resolves.toBeUndefined(); + it("revalidates after a fail-soft dependency result", async () => { + execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" }); + await createTag(form({ name: "News" })); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); }); -}); - -describe("updateTag", () => { - it("updates a tag and revalidates", async () => { - await updateTag( - fakeForm({ - id: "42", - name: "Updated", - backgroundColor: "#00ff00", - }) as unknown as FormData, - ); - - expect(updateWhere).toHaveBeenCalled(); - expect(logStaffActivity).toHaveBeenCalled(); - expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); + it("delegates update", async () => { + await updateTag(form({ id: "42", name: "Updated", backgroundColor: "#00ff00" })); + expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ action: "update", id: "42" })); }); - - it("returns early when id is invalid", async () => { - await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData); - expect(updateWhere).not.toHaveBeenCalled(); + it("rejects invalid update id or name", async () => { + await updateTag(form({ id: "", name: "Test" })); + await updateTag(form({ id: "42", name: "" })); + expect(execute).not.toHaveBeenCalled(); }); - - it("returns early when name is empty after update", async () => { - await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData); - expect(updateWhere).not.toHaveBeenCalled(); + it("delegates delete", async () => { + await deleteTag(form({ id: "42" })); + expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "delete", id: "42" }); }); -}); - -describe("deleteTag", () => { - it("deletes a tag and its taggables", async () => { - await deleteTag(fakeForm({ id: "42" }) as unknown as FormData); - - expect(transaction).toHaveBeenCalled(); - expect(deleteWhere).toHaveBeenCalled(); - expect(logStaffActivity).toHaveBeenCalled(); - expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); - }); - - it("returns early when id is invalid", async () => { - await deleteTag(fakeForm({ id: "" }) as unknown as FormData); - expect(transaction).not.toHaveBeenCalled(); + it("rejects invalid delete id", async () => { + await deleteTag(form({ id: "" })); + expect(execute).not.toHaveBeenCalled(); }); }); diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts index f372d642..e88395c7 100644 --- a/src/actions/admin-tags.ts +++ b/src/actions/admin-tags.ts @@ -1,113 +1,43 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, Taggables, Tags } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -// ── Helpers ──────────────────────────────────────────────────────────────── - -/** Parse a FormData field into a positive BigInt id, or null when invalid. */ -function parseId(raw: FormDataEntryValue | null): bigint | null { - if (typeof raw !== "string" || raw.trim() === "") return null; - try { - const id = BigInt(raw.trim()); - return id > 0n ? id : null; - } catch { - return null; - } +function tagInput(formData: FormData) { + return { + name: String(formData.get("name") ?? "").trim().slice(0, 255), + backgroundColor: String(formData.get("backgroundColor") ?? "").trim().slice(0, 10) || "#888888", + }; } -function str(raw: FormDataEntryValue | null): string { - return typeof raw === "string" ? raw : ""; -} - -/** Normalise a hex-ish colour into the 10-char background_color column. */ -function normaliseColor(raw: string): string { - const v = raw.trim().slice(0, 10); - return v || "#888888"; -} - -// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ────────── - export async function createTag(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const name = str(formData.get("name")).trim().slice(0, 255); - if (!name) return; - - const backgroundColor = normaliseColor(str(formData.get("backgroundColor"))); - const now = new Date(); - - try { - const [result] = (await db.insert(Tags).values({ - name, - backgroundColor, - createdAt: now, - updatedAt: now, - })) as unknown as [ResultSetHeader]; - await logStaffActivity({ - staffId: staff.id, - action: "tag_create", - description: `Created tag "${name}" (#${result.insertId})`, - targetType: "tag", - targetId: Number(result.insertId), - }); - } catch { - // Fail soft — DB unavailable or duplicate. - } + const input = tagInput(formData); + if (!input.name) return; + await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "create", ...input }); revalidatePath("/admin/tags"); } export async function updateTag(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const id = parseId(formData.get("id")); - if (id === null) return; - - const name = str(formData.get("name")).trim().slice(0, 255); - const backgroundColor = normaliseColor(str(formData.get("backgroundColor"))); - if (!name) return; - - try { - await db - .update(Tags) - .set({ name, backgroundColor, updatedAt: new Date() }) - .where(eq(Tags.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "tag_update", - description: `Updated tag #${id} → "${name}"`, - targetType: "tag", - targetId: Number(id), - }); - } catch { - // Row may be gone; ignore. - } + const id = String(formData.get("id") ?? "").trim(); + if (!/^[1-9]\d*$/u.test(id)) return; + const input = tagInput(formData); + if (!input.name) return; + await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "update", id, ...input }); revalidatePath("/admin/tags"); } export async function deleteTag(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); - const id = parseId(formData.get("id")); - if (id === null) return; - - try { - // Remove the tag and any taggable links pointing at it. - await db.transaction(async (tx) => { - await tx.delete(Taggables).where(eq(Taggables.tagId, id)); - await tx.delete(Tags).where(eq(Tags.id, id)); - }); - await logStaffActivity({ - staffId: staff.id, - action: "tag_delete", - description: `Deleted tag #${id}`, - targetType: "tag", - targetId: Number(id), - }); - } catch { - // Already deleted; ignore. - } + const id = String(formData.get("id") ?? "").trim(); + if (!/^[1-9]\d*$/u.test(id)) return; + await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "delete", id }); revalidatePath("/admin/tags"); } diff --git a/src/actions/admin-theme.ts b/src/actions/admin-theme.ts index 8dc3e1e7..65746772 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -2,214 +2,66 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteSetting } from "@/lib/db"; -import { PERMS } from "@/lib/permissions"; -import { siteSettings } from "@/lib/services/site-settings"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -import { ensureReadableThemeColors } from "@/lib/theme-contrast"; import { - deleteCustomThemeStore, - getCustomTheme, - snapshotCurrentTheme, - upsertCustomTheme, -} from "@/lib/theme-custom-store"; -import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets"; -import { presetSettings, settingKey } from "@/lib/theme-settings"; + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; -// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser). -const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/; -// Extra colour settings beyond the preset palette (buttons + links + gradients). -const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"]; -const CUSTOM_CSS_MAX = 20000; +function formValues(formData: FormData): Record { + return Object.fromEntries(Array.from(formData.entries(), ([key, value]) => [key, typeof value === "string" ? value : value.name])); +} -async function writeSetting(key: string, value: string): Promise { - await db - .insert(WebsiteSetting) - .values({ key, value, comment: "Theme (housekeeping)" }) - .onDuplicateKeyUpdate({ set: { value } }); +async function executeTheme(operation: "theme.update" | "theme.apply-preset" | "theme.custom-change" | "theme.apply-custom", input: Record) { + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + return contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), operation, input); } export async function saveTheme(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_EDIT); - - try { - for (const mode of ["light", "dark"] as const) { - const bag: Record = {}; - for (const key of THEME_COLOR_KEYS) { - const dbKey = settingKey(key, mode); - const raw = String(formData.get(dbKey) ?? "") - .normalize("NFC") - .trim(); - if (raw && COLOR_RE.test(raw)) bag[key] = raw; - } - const fixed = ensureReadableThemeColors(bag); - for (const [key, value] of Object.entries(fixed)) { - await writeSetting( - settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), - value, - ); - } - } - const ADMIN_KEYS = [ - "admin_canvas", - "admin_surface", - "admin_text", - "admin_text_muted", - "admin_border", - "admin_sidebar_bg", - ] as const; - const adminBag: Record = {}; - for (const key of ADMIN_KEYS) { - const raw = String(formData.get(key) ?? "") - .normalize("NFC") - .trim(); - if (raw && COLOR_RE.test(raw)) adminBag[key] = raw; - } - const adminFixed = ensureReadableThemeColors(adminBag); - for (const [key, value] of Object.entries(adminFixed)) { - await writeSetting(key, value); - } - - const radius = String(formData.get("border_radius") ?? "") - .normalize("NFC") - .trim(); - if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius); - - // Typography - const font = String(formData.get("font_family") ?? "") - .normalize("NFC") - .trim(); - if (font in FONTS) await writeSetting("font_family", font); - for (const key of HEADING_KEYS) { - const v = String(formData.get(key) ?? "") - .normalize("NFC") - .trim(); - if (/^\d{1,3}$/.test(v)) await writeSetting(key, v); - } - - // Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is). - if (formData.has("custom_css")) { - const cssRaw = String(formData.get("custom_css") ?? "") - .normalize("NFC") - .slice(0, CUSTOM_CSS_MAX); - await writeSetting("custom_css", cssRaw); - } - - siteSettings.reload(); - await logStaffActivity({ - staffId: staff.id, - action: "theme_update", - description: "Updated theme settings", - }); - revalidatePath("/", "layout"); - } catch { - // ignore — page re-renders current state - } + const result = await executeTheme("theme.update", { values: formValues(formData) }); + if (result.ok) revalidatePath("/", "layout"); redirect("/admin/theme?saved=1"); } export async function applyPreset(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_EDIT); const name = String(formData.get("preset") ?? "").normalize("NFC"); - // eslint-disable-next-line security/detect-object-injection -- guarded by null check below - const preset = PRESETS[name]; - if (!preset) redirect("/admin/theme"); - - try { - for (const [key, value] of presetSettings(preset)) - await writeSetting(key, value); - await writeSetting("theme_preset", name); - siteSettings.reload(); - await logStaffActivity({ - staffId: staff.id, - action: "theme_preset", - description: `Applied theme preset "${name}"`, - }); - revalidatePath("/", "layout"); - } catch { - // ignore - } - redirect(`/admin/theme?preset=${encodeURIComponent(name)}`); + const result = await executeTheme("theme.apply-preset", { preset: name }); + if (result.ok) revalidatePath("/", "layout"); + redirect(result.ok ? "/admin/theme?preset=" + encodeURIComponent(name) : "/admin/theme"); } export async function saveCustomTheme(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_EDIT); - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim(); + const name = String(formData.get("name") ?? "").normalize("NFC").trim(); if (!name) redirect("/admin/theme"); - const snapshot = await snapshotCurrentTheme(); - try { - await upsertCustomTheme(name, snapshot); - await logStaffActivity({ - staffId: staff.id, - action: "theme_preset", - description: `Saved custom theme "${name}"`, - }); - revalidatePath("/admin/theme"); - } catch { - // ignore - } + const result = await executeTheme("theme.custom-change", { action: "create", name }); + if (result.ok) revalidatePath("/admin/theme"); redirect("/admin/theme?savedTheme=1"); } export async function applyCustomTheme(formData: FormData): Promise { - const staff = await requirePermission(PERMS.SETTINGS_EDIT); - const id = String(formData.get("id") ?? "") - .normalize("NFC") - .trim(); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); if (!id) redirect("/admin/theme"); - const theme = await getCustomTheme(id); - if (!theme) redirect("/admin/theme"); - try { - for (const [key, value] of Object.entries(theme.settings)) { - if (value) await writeSetting(key, value); - } - await writeSetting("theme_preset", theme.name); - siteSettings.reload(); - await logStaffActivity({ - staffId: staff.id, - action: "theme_preset", - description: `Applied custom theme "${theme.name}"`, - }); - revalidatePath("/", "layout"); - } catch { - // ignore - } - redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`); + const result = await executeTheme("theme.apply-custom", { id }); + if (result.ok) revalidatePath("/", "layout"); + const name = result.ok && typeof result.data.output?.name === "string" ? result.data.output.name : ""; + redirect(result.ok ? "/admin/theme?theme=" + encodeURIComponent(name) : "/admin/theme"); } export async function renameCustomTheme(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - const id = String(formData.get("id") ?? "") - .normalize("NFC") - .trim(); - const name = String(formData.get("name") ?? "") - .normalize("NFC") - .trim(); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + const name = String(formData.get("name") ?? "").normalize("NFC").trim(); if (!id || !name) redirect("/admin/theme"); - const snapshot = await snapshotCurrentTheme(); - try { - await upsertCustomTheme(name, snapshot, id); - revalidatePath("/admin/theme"); - } catch { - // ignore - } + const result = await executeTheme("theme.custom-change", { action: "rename", id, name }); + if (result.ok) revalidatePath("/admin/theme"); redirect("/admin/theme?renamed=1"); } export async function deleteCustomTheme(formData: FormData): Promise { - await requirePermission(PERMS.SETTINGS_EDIT); - const id = String(formData.get("id") ?? "") - .normalize("NFC") - .trim(); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); if (!id) redirect("/admin/theme"); - try { - await deleteCustomThemeStore(id); - revalidatePath("/admin/theme"); - } catch { - // ignore - } + const result = await executeTheme("theme.custom-change", { action: "delete", id }); + if (result.ok) revalidatePath("/admin/theme"); redirect("/admin/theme?deletedTheme=1"); } diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts index d628221e..d6f4e317 100644 --- a/src/actions/admin-writeable-boxes.ts +++ b/src/actions/admin-writeable-boxes.ts @@ -1,177 +1,49 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; import { requirePermission } from "@/lib/admin/guard"; -import { db, WebsiteWriteableBoxes } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; -import { logStaffActivity } from "@/lib/services/staff-activity"; -// Writeable boxes (website_writeable_boxes). CMS-owned table backing the -// content panels rendered on the public home page. Active boxes (is_active) -// are the ones shown publicly, ordered by `position`. - -/** Parse a non-negative Int form value, falling back to 0. */ -function reqInt(formData: FormData, key: string): number { - const raw = String(formData.get(key) ?? "") - .normalize("NFC") - .trim(); - if (raw === "") return 0; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) return 0; - return Math.floor(n); +function boxInput(formData: FormData) { + const position = Number(formData.get("position")); + return { + title: String(formData.get("title") ?? "").normalize("NFC").trim().slice(0, 255), + icon: String(formData.get("icon") ?? "").normalize("NFC").trim().slice(0, 255), + content: String(formData.get("content") ?? "").normalize("NFC"), + position: Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0, + isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", + }; } -/** Parse the BigInt `id` form value, returning null when blank/invalid. */ -function parseId(formData: FormData): bigint | null { - const raw = String(formData.get("id") ?? "") - .normalize("NFC") - .trim(); - if (!raw) return null; - try { - return BigInt(raw); - } catch { - return null; - } -} - -function revalidate(): void { +function refreshBoxes(): void { revalidatePath("/admin/writeable-boxes"); - // Active boxes render on the public home page (root layout). revalidatePath("/", "layout"); } +async function executeBox(formData: FormData, action: "create" | "update" | "delete" | "toggle"): Promise { + const staff = await requirePermission(PERMS.PAGES_EDIT); + const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + if (action !== "create" && !/^\d+$/u.test(id)) return false; + const input = boxInput(formData); + if ((action === "create" || action === "update") && !input.title) return false; + const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "writeable-box.change", { action, ...(id ? { id } : {}), ...input, next: formData.get("next") }); + return result.ok; +} + export async function createBox(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PAGES_EDIT); - - const title = String(formData.get("title") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - if (!title) return; - - const now = new Date(); - try { - const [result] = (await db.insert(WebsiteWriteableBoxes).values({ - title, - icon: - String(formData.get("icon") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255) || null, - content: String(formData.get("content") ?? "").normalize("NFC"), - position: reqInt(formData, "position"), - isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", - createdAt: now, - updatedAt: now, - })) as unknown as [ResultSetHeader]; - await logStaffActivity({ - staffId: staff.id, - action: "writeable_box_create", - description: `Created writeable box "${title}" (#${result.insertId})`, - targetType: "writeable_box", - targetId: Number(result.insertId), - }); - } catch { - // DB unavailable — swallow and re-render. - return; - } - - revalidate(); + if (await executeBox(formData, "create")) refreshBoxes(); } - export async function updateBox(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PAGES_EDIT); - - const id = parseId(formData); - if (id == null) return; - - const title = String(formData.get("title") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255); - if (!title) return; - - try { - await db - .update(WebsiteWriteableBoxes) - .set({ - title, - icon: - String(formData.get("icon") ?? "") - .normalize("NFC") - .trim() - .slice(0, 255) || null, - content: String(formData.get("content") ?? "").normalize("NFC"), - position: reqInt(formData, "position"), - isActive: - String(formData.get("isActive") ?? "").normalize("NFC") === "1", - updatedAt: new Date(), - }) - .where(eq(WebsiteWriteableBoxes.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "writeable_box_update", - description: `Updated writeable box #${id} ("${title}")`, - targetType: "writeable_box", - targetId: Number(id), - }); - } catch { - return; - } - - revalidate(); + if (await executeBox(formData, "update")) refreshBoxes(); } - export async function deleteBox(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PAGES_EDIT); - - const id = parseId(formData); - if (id == null) return; - - try { - await db - .delete(WebsiteWriteableBoxes) - .where(eq(WebsiteWriteableBoxes.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "writeable_box_delete", - description: `Deleted writeable box #${id}`, - targetType: "writeable_box", - targetId: Number(id), - }); - } catch { - return; - } - - revalidate(); + if (await executeBox(formData, "delete")) refreshBoxes(); } - export async function toggleBox(formData: FormData): Promise { - const staff = await requirePermission(PERMS.PAGES_EDIT); - - const id = parseId(formData); - if (id == null) return; - - // `next` carries the desired state ("1" to activate, anything else to hide). - const next = String(formData.get("next") ?? "").normalize("NFC") === "1"; - - try { - await db - .update(WebsiteWriteableBoxes) - .set({ isActive: next, updatedAt: new Date() }) - .where(eq(WebsiteWriteableBoxes.id, id)); - await logStaffActivity({ - staffId: staff.id, - action: "writeable_box_toggle", - description: `${next ? "Activated" : "Hid"} writeable box #${id}`, - targetType: "writeable_box", - targetId: Number(id), - }); - } catch { - return; - } - - revalidate(); + if (await executeBox(formData, "toggle")) refreshBoxes(); } diff --git a/src/actions/banners.ts b/src/actions/banners.ts index e73fcbf4..98616e47 100644 --- a/src/actions/banners.ts +++ b/src/actions/banners.ts @@ -1,13 +1,10 @@ "use server"; -import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { z } from "zod"; -import { db, WebsiteBanner } from "@/lib/db"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; -import { logAudit } from "@/lib/services/audit"; const bannerSchema = z.object({ title: z.string().min(1).max(255), @@ -21,46 +18,31 @@ const bannerSchema = z.object({ endDate: z.string().max(50).nullable().optional(), }); +async function runBanner(ctx: { data: Record; requestId: unknown; session: { user: { id: number } } }, action: "create" | "update" | "delete") { + return contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "banner.change", + { action, ...ctx.data }, + ); +} + export const createBanner = adminAction( { permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, async (ctx) => { - const [result] = (await db - .insert(WebsiteBanner) - .values(ctx.data)) as unknown as [ResultSetHeader]; - const id = Number(result.insertId); - logAudit({ - userId: ctx.session.user.id, - action: "banner_create", - target: "WebsiteBanner", - targetId: id, - after: { title: ctx.data.title }, - }); - return actionOk({ id }); + const result = await runBanner(ctx, "create"); + if (!result.ok) throw new ActionError("Banner creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); -const updateBannerInput = bannerSchema - .partial() - .extend({ id: z.coerce.number().int().positive() }); +const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() }); export const updateBanner = adminAction( { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, async (ctx) => { - const { id, ...data } = ctx.data; - const [existing] = await db - .select({ id: WebsiteBanner.id }) - .from(WebsiteBanner) - .where(eq(WebsiteBanner.id, id)) - .limit(1); - if (!existing) throw new ActionError("Banner not found"); - await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id)); - logAudit({ - userId: ctx.session.user.id, - action: "banner_update", - target: "WebsiteBanner", - targetId: id, - }); - return actionOk({ id }); + const result = await runBanner(ctx, "update"); + if (!result.ok) throw new ActionError("Banner not found"); + return actionOk({ id: ctx.data.id }); }, ); @@ -69,13 +51,8 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() }); export const deleteBanner = adminAction( { permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput }, async (ctx) => { - await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id)); - logAudit({ - userId: ctx.session.user.id, - action: "banner_delete", - target: "WebsiteBanner", - targetId: ctx.data.id, - }); + const result = await runBanner(ctx, "delete"); + if (!result.ok) throw new ActionError("Banner not found"); return actionOk(); }, ); diff --git a/src/actions/content-legacy-parity.test.ts b/src/actions/content-legacy-parity.test.ts new file mode 100644 index 00000000..a36a5763 --- /dev/null +++ b/src/actions/content-legacy-parity.test.ts @@ -0,0 +1,168 @@ +// @ts-nocheck +import { readFileSync } from "node:fs"; +import { redirect } from "next/navigation"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requirePermission } from "@/lib/admin/guard"; +import { createAd } from "./admin-ads"; +import { createArticle } from "./admin-articles"; +import { uploadMedia } from "./admin-media"; +import { saveFavicon } from "./save-favicon"; + +const { execute } = vi.hoisted(() => ({ + execute: vi.fn(async () => ({ + ok: true, + data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } }, + correlationId: "legacy", + })), +})); + +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ + contentMutationService: { execute }, + createContentMutationInvocation: (actor, correlationId) => ({ + expectedActorId: actor.id, + correlationId, + legacy: true, + }), +})); +vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); +vi.mock("@/lib/safe-action", () => ({ + adminAction: (_options: unknown, handler: unknown) => handler, +})); +vi.mock("@/lib/safe-action-shared", () => ({ + ActionError: class ActionError extends Error {}, + actionOk: (data: unknown = {}) => ({ ok: true, data }), +})); +vi.mock("@/lib/logger", () => ({ + logger: { error: vi.fn() }, +})); +vi.mock("@/lib/permissions", () => ({ + PERMS: { + NEWS_EDIT: "news.edit", + PAGES_EDIT: "pages.edit", + SETTINGS_EDIT: "settings.edit", + }, +})); +vi.mock("@/lib/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit: vi.fn(async () => []) })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(async () => [{ insertId: 1 }]), + })), + }, + WebsiteArticles: { id: "id", slug: "slug" }, + WebsiteAds: { id: "id" }, + WebsiteSetting: { key: "key" }, +})); +vi.mock("@/lib/services/staff-activity", () => ({ + logStaffActivity: vi.fn(), +})); +vi.mock("@/lib/services/site-settings", () => ({ + siteSettings: { get: vi.fn(), reload: vi.fn() }, +})); +vi.mock("@/lib/media-storage", () => ({ + MEDIA_ROOT: "C:\\media", + resolveMediaPath: vi.fn((name: string) => `C:\\media\\${name}`), +})); +vi.mock("node:fs/promises", () => ({ + mkdir: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), +})); +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("next/navigation", () => ({ redirect: vi.fn() })); + +const staff = { id: 42, rank: 7, username: "operator" }; +const form = (data: Record) => ({ + get: (key: string) => data[key] ?? null, + has: (key: string) => key in data, +}); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requirePermission).mockResolvedValue(staff as never); + execute.mockResolvedValue({ + ok: true, + data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } }, + correlationId: "legacy", + }); +}); + +describe("Content legacy wrappers", () => { + it("delegates article creation and preserves redirect ordering", async () => { + await createArticle( + form({ + title: "Launch", + shortStory: "Summary", + fullStory: "Body", + image: "/image.png", + }) as FormData, + ); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ expectedActorId: 42, legacy: true }), + "article.change", + expect.objectContaining({ action: "create", title: "Launch" }), + ); + expect(redirect).toHaveBeenCalledWith("/admin/articles"); + }); + + it("delegates ad creation and keeps the legacy void/redirect contract", async () => { + expect( + await createAd(form({ image: "https://example.test/ad.png" }) as FormData), + ).toBeUndefined(); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ expectedActorId: 42, legacy: true }), + "ad.change", + expect.objectContaining({ action: "create" }), + ); + expect(redirect).toHaveBeenCalledWith("/admin/ads"); + }); + + it("delegates media and favicon uploads while retaining public result shapes", async () => { + const file = new File(["bytes"], "image.png", { type: "image/png" }); + const media = await uploadMedia(form({ file }) as FormData); + const favicon = await saveFavicon(form({ file }) as FormData); + expect(media).toEqual({ ok: true }); + expect(favicon).toEqual({ success: true, url: "/api/media/x" }); + expect(execute).toHaveBeenCalledWith( + expect.anything(), + "media.upload", + expect.objectContaining({ file }), + ); + expect(execute).toHaveBeenCalledWith( + expect.anything(), + "favicon.save", + expect.objectContaining({ file }), + ); + }); + + it("keeps every listed legacy action as a thin shared-service wrapper", () => { + for (const path of [ + "src/actions/admin-ads.ts", + "src/actions/admin-articles.ts", + "src/actions/admin-email-templates.ts", + "src/actions/admin-help.ts", + "src/actions/admin-media.ts", + "src/actions/admin-nav-menu.ts", + "src/actions/admin-photos.ts", + "src/actions/admin-tags.ts", + "src/actions/admin-theme.ts", + "src/actions/admin-writeable-boxes.ts", + "src/actions/banners.ts", + "src/actions/events.ts", + "src/actions/polls.ts", + "src/actions/prefixes.ts", + "src/actions/save-favicon.ts", + "src/actions/save-logo.ts", + "src/actions/translations.ts", + "src/actions/emulator.ts", + ]) { + expect(readFileSync(path, "utf8"), path).toContain( + "contentMutationService", + ); + } + }); +}); diff --git a/src/actions/emulator.test.ts b/src/actions/emulator.test.ts index b8673932..69ff94b8 100644 --- a/src/actions/emulator.test.ts +++ b/src/actions/emulator.test.ts @@ -1,48 +1,17 @@ // @ts-nocheck import { describe, expect, it, vi } from "vitest"; -import { rcon } from "@/lib/services/rcon"; -const { insertValues } = vi.hoisted(() => { - const insertValues = vi.fn(() => ({ - onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]), - })); - return { insertValues }; -}); - -vi.mock("@/lib/permissions", () => ({ - PERMS: { SETTINGS_EDIT: "settings.edit" }, -})); -vi.mock("@/lib/db", () => ({ - db: { - insert: vi.fn(() => ({ values: insertValues })), - }, - EmulatorSettings: { key: "key", value: "value" }, -})); -vi.mock("@/lib/safe-action", () => ({ - adminAction: vi.fn( - (_opts: unknown, fn: (...args: unknown[]) => unknown) => fn, - ), -})); -vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") })); -vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() })); -vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } })); +const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { keys: ["key1", "key2"] } }, correlationId: "emulator" })) })); +vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute } })); +vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" } })); +vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler })); +vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" })); describe("saveEmulatorSettings", () => { - it("saves settings and calls rcon update", async () => { - const handler = (await import("./emulator").then( - (m) => m.saveEmulatorSettings, - )) as unknown as (ctx: { - data: { settings: Record }; - session: { user: { id: string } }; - }) => Promise; - - const result = await handler({ - data: { settings: { key1: "val1", key2: "val2" } }, - session: { user: { id: "1" } }, - }); - - expect(insertValues).toHaveBeenCalledTimes(2); - expect(rcon.updateConfig).toHaveBeenCalled(); + it("delegates the third translation store and preserves result shape", async () => { + const handler = (await import("./emulator")).saveEmulatorSettings as unknown as (ctx: unknown) => Promise; + const result = await handler({ data: { settings: { key1: "val1", key2: "val2" } }, session: { user: { id: "1" } }, requestId: "emulator" }); + expect(execute).toHaveBeenCalledWith({ correlationId: "emulator", expectedActorId: 1, legacy: true }, "translation.emulator.save", { settings: { key1: "val1", key2: "val2" } }); expect(result).toBe("ok"); }); }); diff --git a/src/actions/emulator.ts b/src/actions/emulator.ts index 4c420aad..d1ec0259 100644 --- a/src/actions/emulator.ts +++ b/src/actions/emulator.ts @@ -1,38 +1,22 @@ "use server"; import { z } from "zod"; -import { db, EmulatorSettings } from "@/lib/db"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { actionOk } from "@/lib/safe-action-shared"; -import { logAudit } from "@/lib/services/audit"; -import { rcon } from "@/lib/services/rcon"; -const saveEmulatorSettingsSchema = z.object({ - settings: z.record(z.string(), z.string()), -}); +const saveEmulatorSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) }); export const saveEmulatorSettings = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema }, async (ctx) => { - const entries = Object.entries(ctx.data.settings); - - for (const [key, value] of entries) { - await db - .insert(EmulatorSettings) - .values({ key, value: String(value) }) - .onDuplicateKeyUpdate({ set: { value: String(value) } }); - } - - await rcon.updateConfig(); - - logAudit({ - userId: ctx.session.user.id, - action: "emulator_settings_update", - target: "EmulatorSettings", - after: ctx.data.settings, - }); - + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "translation.emulator.save", + ctx.data, + ); + if (!result.ok) throw new Error(result.error.messageKey); return actionOk(); }, ); diff --git a/src/actions/events.ts b/src/actions/events.ts index 634812f5..8a68924c 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -3,18 +3,16 @@ import { and, count, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { db, WebsiteEvent, - WebsiteEventPrize, WebsiteEventRegistration, WebsiteEventType, - WebsiteEventWinner, } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { adminAction, authAction } from "@/lib/safe-action"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; -import { logAudit } from "@/lib/services/audit"; import { createEventSchema, eventPrizeSchema, @@ -29,16 +27,13 @@ import { export const createEventType = adminAction( { permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema }, async (ctx) => { - const [result] = await db.insert(WebsiteEventType).values(ctx.data); - const eventTypeId = Number(result.insertId); - logAudit({ - userId: ctx.session.user.id, - action: "event_type_create", - target: "WebsiteEventType", - targetId: eventTypeId, - after: { name: ctx.data.name }, - }); - return actionOk({ id: eventTypeId }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event-type.change", + { action: "create", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event type creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); @@ -49,27 +44,13 @@ const updateEventTypeInput = eventTypeSchema.partial().extend({ export const updateEventType = adminAction( { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, async (ctx) => { - const { id, ...data } = ctx.data; - const [existing] = await db - .select({ id: WebsiteEventType.id, name: WebsiteEventType.name }) - .from(WebsiteEventType) - .where(eq(WebsiteEventType.id, id)) - .limit(1); - if (!existing) throw new ActionError("Event type not found"); - - await db - .update(WebsiteEventType) - .set(data) - .where(eq(WebsiteEventType.id, id)); - logAudit({ - userId: ctx.session.user.id, - action: "event_type_update", - target: "WebsiteEventType", - targetId: id, - before: { name: existing.name }, - after: data, - }); - return actionOk({ id }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event-type.change", + { action: "update", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event type not found"); + return actionOk({ id: ctx.data.id }); }, ); @@ -80,23 +61,12 @@ const deleteEventTypeInput = z.object({ export const deleteEventType = adminAction( { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, async (ctx) => { - const [existing] = await db - .select({ id: WebsiteEventType.id, name: WebsiteEventType.name }) - .from(WebsiteEventType) - .where(eq(WebsiteEventType.id, ctx.data.id)) - .limit(1); - if (!existing) throw new ActionError("Event type not found"); - - await db - .delete(WebsiteEventType) - .where(eq(WebsiteEventType.id, ctx.data.id)); - logAudit({ - userId: ctx.session.user.id, - action: "event_type_delete", - target: "WebsiteEventType", - targetId: ctx.data.id, - before: { name: existing.name }, - }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event-type.change", + { action: "delete", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event type not found"); return actionOk(); }, ); @@ -106,21 +76,13 @@ export const deleteEventType = adminAction( export const createEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, async (ctx) => { - const now = new Date(); - const [result] = await db.insert(WebsiteEvent).values({ - ...ctx.data, - hostUserId: Number(ctx.session.user.id), - updatedAt: now, - }); - const eventId = Number(result.insertId); - logAudit({ - userId: ctx.session.user.id, - action: "event_create", - target: "WebsiteEvent", - targetId: eventId, - after: { title: ctx.data.title }, - }); - return actionOk({ id: eventId }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event.change", + { action: "create", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); @@ -131,31 +93,13 @@ const updateEventInput = updateEventSchema.extend({ export const updateEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: updateEventInput }, async (ctx) => { - const { id, ...data } = ctx.data; - const [existing] = await db - .select({ - id: WebsiteEvent.id, - title: WebsiteEvent.title, - status: WebsiteEvent.status, - }) - .from(WebsiteEvent) - .where(eq(WebsiteEvent.id, id)) - .limit(1); - if (!existing) throw new ActionError("Event not found"); - - await db - .update(WebsiteEvent) - .set({ ...data, updatedAt: new Date() }) - .where(eq(WebsiteEvent.id, id)); - logAudit({ - userId: ctx.session.user.id, - action: "event_update", - target: "WebsiteEvent", - targetId: id, - before: { title: existing.title, status: existing.status }, - after: data, - }); - return actionOk({ id }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event.change", + { action: "update", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event not found"); + return actionOk({ id: ctx.data.id }); }, ); @@ -166,21 +110,12 @@ const deleteEventInput = z.object({ export const deleteEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, async (ctx) => { - const [existing] = await db - .select({ id: WebsiteEvent.id, title: WebsiteEvent.title }) - .from(WebsiteEvent) - .where(eq(WebsiteEvent.id, ctx.data.id)) - .limit(1); - if (!existing) throw new ActionError("Event not found"); - - await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id)); - logAudit({ - userId: ctx.session.user.id, - action: "event_delete", - target: "WebsiteEvent", - targetId: ctx.data.id, - before: { title: existing.title }, - }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event.change", + { action: "delete", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event not found"); return actionOk(); }, ); @@ -190,8 +125,13 @@ export const deleteEvent = adminAction( export const addEventPrize = adminAction( { permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema }, async (ctx) => { - const [result] = await db.insert(WebsiteEventPrize).values(ctx.data); - return actionOk({ id: Number(result.insertId) }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event-prize.change", + { action: "create", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event prize creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); @@ -200,9 +140,12 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() }); export const deleteEventPrize = adminAction( { permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput }, async (ctx) => { - await db - .delete(WebsiteEventPrize) - .where(eq(WebsiteEventPrize.id, ctx.data.id)); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event-prize.change", + { action: "delete", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Event prize deletion failed"); return actionOk(); }, ); @@ -212,20 +155,13 @@ export const deleteEventPrize = adminAction( export const addEventWinner = adminAction( { permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema }, async (ctx) => { - const [result] = await db.insert(WebsiteEventWinner).values(ctx.data); - const winnerId = Number(result.insertId); - logAudit({ - userId: ctx.session.user.id, - action: "event_winner_add", - target: "WebsiteEventWinner", - targetId: winnerId, - after: { - eventId: ctx.data.eventId, - userId: ctx.data.userId, - position: ctx.data.position, - }, - }); - return actionOk({ id: winnerId }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "event-winner.add", + ctx.data, + ); + if (!result.ok) throw new ActionError("Event winner creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); diff --git a/src/actions/polls.ts b/src/actions/polls.ts index 63ea030c..760a894e 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { db, WebsitePoll, @@ -12,7 +13,6 @@ import { import { PERMS } from "@/lib/permissions"; import { adminAction, authAction } from "@/lib/safe-action"; import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared"; -import { logAudit } from "@/lib/services/audit"; import { createPollSchema, pollQuestionSchema, @@ -25,20 +25,13 @@ import { export const createPoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: createPollSchema }, async (ctx) => { - const now = new Date(); - const [result] = await db.insert(WebsitePoll).values({ - ...ctx.data, - updatedAt: now, - }); - const pollId = Number(result.insertId); - logAudit({ - userId: ctx.session.user.id, - action: "poll_create", - target: "WebsitePoll", - targetId: pollId, - after: { title: ctx.data.title }, - }); - return actionOk({ id: pollId }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "poll.change", + { action: "create", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Poll creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); @@ -49,31 +42,13 @@ const updatePollInput = updatePollSchema.extend({ export const updatePoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: updatePollInput }, async (ctx) => { - const { id, ...data } = ctx.data; - const [existing] = await db - .select({ - id: WebsitePoll.id, - title: WebsitePoll.title, - status: WebsitePoll.status, - }) - .from(WebsitePoll) - .where(eq(WebsitePoll.id, id)) - .limit(1); - if (!existing) throw new ActionError("Poll not found"); - - await db - .update(WebsitePoll) - .set({ ...data, updatedAt: new Date() }) - .where(eq(WebsitePoll.id, id)); - logAudit({ - userId: ctx.session.user.id, - action: "poll_update", - target: "WebsitePoll", - targetId: id, - before: { title: existing.title, status: existing.status }, - after: data, - }); - return actionOk({ id }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "poll.change", + { action: "update", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Poll not found"); + return actionOk({ id: ctx.data.id }); }, ); @@ -84,21 +59,12 @@ const deletePollInput = z.object({ export const deletePoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: deletePollInput }, async (ctx) => { - const [existing] = await db - .select({ id: WebsitePoll.id, title: WebsitePoll.title }) - .from(WebsitePoll) - .where(eq(WebsitePoll.id, ctx.data.id)) - .limit(1); - if (!existing) throw new ActionError("Poll not found"); - - await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id)); - logAudit({ - userId: ctx.session.user.id, - action: "poll_delete", - target: "WebsitePoll", - targetId: ctx.data.id, - before: { title: existing.title }, - }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "poll.change", + { action: "delete", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Poll not found"); return actionOk(); }, ); @@ -108,8 +74,13 @@ export const deletePoll = adminAction( export const addPollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, async (ctx) => { - const [result] = await db.insert(WebsitePollQuestion).values(ctx.data); - return actionOk({ id: Number(result.insertId) }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "poll-question.change", + { action: "create", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Poll question creation failed"); + return actionOk({ id: Number(result.data.output?.id) }); }, ); @@ -120,12 +91,13 @@ const updateQuestionInput = pollQuestionSchema.partial().extend({ export const updatePollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, async (ctx) => { - const { id, ...data } = ctx.data; - await db - .update(WebsitePollQuestion) - .set(data) - .where(eq(WebsitePollQuestion.id, id)); - return actionOk({ id }); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "poll-question.change", + { action: "update", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Poll question update failed"); + return actionOk({ id: ctx.data.id }); }, ); @@ -136,9 +108,12 @@ const deleteQuestionInput = z.object({ export const deletePollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, async (ctx) => { - await db - .delete(WebsitePollQuestion) - .where(eq(WebsitePollQuestion.id, ctx.data.id)); + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "poll-question.change", + { action: "delete", ...ctx.data }, + ); + if (!result.ok) throw new ActionError("Poll question deletion failed"); return actionOk(); }, ); diff --git a/src/actions/prefixes.ts b/src/actions/prefixes.ts index d1263311..b2b8c6de 100644 --- a/src/actions/prefixes.ts +++ b/src/actions/prefixes.ts @@ -1,17 +1,11 @@ "use server"; -import { eq, sql } from "drizzle-orm"; import { z } from "zod"; -import { db, User } from "@/lib/db"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; -// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings -// are not represented in src/db/schema.ts yet — we use parameterized raw SQL. - -// ── Create prefix ─────────────────────────────────────────────────── - const createPrefixSchema = z.object({ username: z.string().min(1), text: z.string().min(1), @@ -20,30 +14,6 @@ const createPrefixSchema = z.object({ effect: z.string().optional(), active: z.coerce.number().int().min(0).max(1).default(1), }); - -export const createPrefix = adminAction( - { permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, - async (ctx) => { - const { username, text, color, icon, effect, active } = ctx.data; - - const [user] = await db - .select({ id: User.id }) - .from(User) - .where(eq(User.username, username)) - .limit(1); - if (!user) throw new ActionError("User not found"); - - await db.execute(sql` - INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) - VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active}) - `); - - return actionOk(); - }, -); - -// ── Update prefix ─────────────────────────────────────────────────── - const updatePrefixSchema = z.object({ id: z.coerce.number().int().positive(), text: z.string().min(1), @@ -52,101 +22,25 @@ const updatePrefixSchema = z.object({ effect: z.string().optional(), active: z.coerce.number().int().min(0).max(1).optional(), }); +const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() }); +const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) }); +const removeBlacklistWordSchema = z.object({ id: z.coerce.number().int().positive() }); +const updatePrefixSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) }); -export const updatePrefix = adminAction( - { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, - async (ctx) => { - const { id, text, color, icon, effect, active } = ctx.data; +async function run(ctx: { data: Record; requestId: unknown; session: { user: { id: number } } }, operation: "prefix.change" | "prefix-blacklist.change" | "prefix-settings.update", input: Record) { + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + operation, + input, + ); + if (!result.ok && result.error.code === "NOT_FOUND") throw new ActionError("User not found"); + if (!result.ok) throw new Error(result.error.messageKey); + return actionOk(); +} - await db.execute(sql` - UPDATE custom_prefixes - SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1} - WHERE id = ${id} - `); - - return actionOk(); - }, -); - -// ── Delete prefix ─────────────────────────────────────────────────── - -const deletePrefixSchema = z.object({ - id: z.coerce.number().int().positive(), -}); - -export const deletePrefix = adminAction( - { permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, - async (ctx) => { - await db.execute( - sql`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`, - ); - return actionOk(); - }, -); - -// ── Add blacklist word ────────────────────────────────────────────── - -const addBlacklistWordSchema = z.object({ - word: z.string().min(1).max(100), -}); - -export const addBlacklistWord = adminAction( - { permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, - async (ctx) => { - await db.execute(sql` - INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()}) - `); - return actionOk(); - }, -); - -// ── Remove blacklist word ─────────────────────────────────────────── - -const removeBlacklistWordSchema = z.object({ - id: z.coerce.number().int().positive(), -}); - -export const removeBlacklistWord = adminAction( - { permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, - async (ctx) => { - await db.execute( - sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`, - ); - return actionOk(); - }, -); - -// ── Update prefix settings ────────────────────────────────────────── - -const SETTINGS_WHITELIST = new Set([ - "enabled", - "max_length", - "min_rank", - "min_rank_to_buy", - "allow_colors", - "allow_bold", - "allow_italic", - "default_color", - "price_credits", - "price_points", - "points_type", -]); - -const updatePrefixSettingsSchema = z.object({ - settings: z.record(z.string(), z.string()), -}); - -export const updatePrefixSettings = adminAction( - { permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, - async (ctx) => { - for (const [key, value] of Object.entries(ctx.data.settings)) { - if (!SETTINGS_WHITELIST.has(key)) continue; - await db.execute(sql` - INSERT INTO custom_prefix_settings (\`key\`, \`value\`) - VALUES (${key}, ${value}) - ON DUPLICATE KEY UPDATE \`value\` = ${value} - `); - } - return actionOk(); - }, -); +export const createPrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data })); +export const updatePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data })); +export const deletePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data })); +export const addBlacklistWord = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, (ctx) => run(ctx, "prefix-blacklist.change", { action: "add", ...ctx.data })); +export const removeBlacklistWord = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, (ctx) => run(ctx, "prefix-blacklist.change", { action: "remove", ...ctx.data })); +export const updatePrefixSettings = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, (ctx) => run(ctx, "prefix-settings.update", ctx.data)); diff --git a/src/actions/save-favicon.ts b/src/actions/save-favicon.ts index 6e3e54fe..64705537 100644 --- a/src/actions/save-favicon.ts +++ b/src/actions/save-favicon.ts @@ -1,136 +1,46 @@ "use server"; -import { mkdir, unlink, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; -import { db, WebsiteSetting } from "@/lib/db"; -import { resolveMediaPath } from "@/lib/media-storage"; -import { siteSettings } from "@/lib/services/site-settings"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; -const FAVICON_DIR = resolveMediaPath("favicon"); -const MAX_SIZE = 2 * 1024 * 1024; // 2MB -const ALLOWED = [ - "image/png", - "image/jpeg", - "image/gif", - "image/webp", - "image/x-icon", - "image/svg+xml", -]; +const MAX_SIZE = 2 * 1024 * 1024; +const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"]; -export async function saveFavicon( - formData: FormData, -): Promise<{ success: boolean; url?: string; error?: string }> { - try { - const file = formData.get("file") as File | null; - if (!file || file.size === 0) - return { success: false, error: "No file provided" }; - if (file.size > MAX_SIZE) - return { success: false, error: "File too large (max 2MB)" }; - if (!ALLOWED.includes(file.type)) - return { - success: false, - error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG", - }; - - const mimeExt: Record = { - "image/png": "png", - "image/jpeg": "jpg", - "image/gif": "gif", - "image/webp": "webp", - "image/x-icon": "ico", - "image/svg+xml": "svg", - }; - const ext = mimeExt[file.type] ?? "png"; - const filename = `favicon-${Date.now()}.${ext}`; - const baseDir = FAVICON_DIR; - const filePath = path.resolve(baseDir, filename); - if (!filePath.startsWith(baseDir + path.sep)) { - return { success: false, error: "Invalid path" }; - } - - const buffer = Buffer.from(await file.arrayBuffer()); - // eslint-disable-next-line security/detect-non-literal-fs-filename - await mkdir(baseDir, { recursive: true }); - // eslint-disable-next-line security/detect-non-literal-fs-filename - await writeFile(filePath, buffer); - - const url = `/api/media/favicon/${filename}`; - - // Remove old favicon file if it exists - const oldUrl = await siteSettings.get("cms_favicon"); - if (oldUrl?.startsWith("/api/media/favicon/")) { - const oldName = oldUrl.replace("/api/media/favicon/", ""); - if (!oldName.includes("..") && !oldName.includes("/")) { - const oldPath = path.resolve(baseDir, oldName); - if (oldPath.startsWith(baseDir + path.sep)) { - try { - // eslint-disable-next-line security/detect-non-literal-fs-filename - await unlink(oldPath); - } catch { - /* ignore if file doesn't exist */ - } - } - } - } - - await db - .insert(WebsiteSetting) - .values({ key: "cms_favicon", value: url, comment: "Favicon URL" }) - .onDuplicateKeyUpdate({ set: { value: url } }); - - siteSettings.reload(); - revalidatePath("/", "layout"); - revalidatePath("/admin/favicon"); - - return { success: true, url }; - } catch (e) { - return { - success: false, - error: e instanceof Error ? e.message : "Unknown error", - }; - } +export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> { + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + const file = formData.get("file") as File | null; + if (!file || file.size === 0) return { success: false, error: "No file provided" }; + if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" }; + if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" }; + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "favicon.save", + { file }, + ); + if (!result.ok) return { success: false, error: result.error.messageKey }; + siteRevalidate(); + return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) }; } -export async function deleteFavicon(): Promise<{ - success: boolean; - error?: string; -}> { - try { - const oldUrl = await siteSettings.get("cms_favicon"); - if (oldUrl?.startsWith("/api/media/favicon/")) { - const baseDir = FAVICON_DIR; - const oldName = oldUrl.replace("/api/media/favicon/", ""); - if (!oldName.includes("..") && !oldName.includes("/")) { - const oldPath = path.resolve(baseDir, oldName); - if (oldPath.startsWith(baseDir + path.sep)) { - try { - // eslint-disable-next-line security/detect-non-literal-fs-filename - await unlink(oldPath); - } catch { - /* ignore */ - } - } - } - } +export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> { + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "favicon.delete", + {}, + ); + if (!result.ok) return { success: false, error: result.error.messageKey }; + siteRevalidate(); + return { success: true }; +} - try { - await db - .delete(WebsiteSetting) - .where(eq(WebsiteSetting.key, "cms_favicon")); - } catch { - /* ignore missing row */ - } - siteSettings.reload(); - revalidatePath("/", "layout"); - revalidatePath("/admin/favicon"); - - return { success: true }; - } catch (e) { - return { - success: false, - error: e instanceof Error ? e.message : "Unknown error", - }; - } +function siteRevalidate(): void { + revalidatePath("/", "layout"); + revalidatePath("/admin/favicon"); } diff --git a/src/actions/save-logo.ts b/src/actions/save-logo.ts index 6fe690cd..5d520255 100644 --- a/src/actions/save-logo.ts +++ b/src/actions/save-logo.ts @@ -1,59 +1,24 @@ "use server"; -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; import { revalidatePath } from "next/cache"; -import { db, WebsiteSetting } from "@/lib/db"; -import { resolveMediaPath } from "@/lib/media-storage"; -import { siteSettings } from "@/lib/services/site-settings"; +import { + contentMutationService, + createContentMutationInvocation, +} from "@/features/housekeeping/domains/content/services/mutations"; +import { createCorrelationId } from "@/features/housekeeping/foundation/contracts"; +import { requirePermission } from "@/lib/admin/guard"; +import { PERMS } from "@/lib/permissions"; -const MEDIA_DIR = resolveMediaPath("logo"); - -export async function saveLogo( - formData: FormData, -): Promise<{ success: boolean; url?: string; error?: string }> { - try { - const file = formData.get("file") as File | null; - if (!file) return { success: false, error: "No file provided" }; - - const ext = - file.type === "image/png" - ? "png" - : file.type === "image/gif" - ? "gif" - : file.type === "image/jpeg" - ? "jpg" - : file.type === "image/webp" - ? "webp" - : "png"; - const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`; - const baseDir = MEDIA_DIR; - const filePath = path.resolve(baseDir, filename); - if (!filePath.startsWith(baseDir + path.sep)) { - return { success: false, error: "Invalid path" }; - } - - const buffer = Buffer.from(await file.arrayBuffer()); - // eslint-disable-next-line security/detect-non-literal-fs-filename - await mkdir(baseDir, { recursive: true }); - // eslint-disable-next-line security/detect-non-literal-fs-filename - await writeFile(filePath, buffer); - - const url = `/api/media/logo/${filename}`; - - await db - .insert(WebsiteSetting) - .values({ key: "cms_logo", value: url, comment: "Logo (generator)" }) - .onDuplicateKeyUpdate({ set: { value: url } }); - - siteSettings.reload(); - revalidatePath("/", "layout"); - - return { success: true, url }; - } catch (e) { - return { - success: false, - error: e instanceof Error ? e.message : "Unknown error", - }; - } +export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> { + const staff = await requirePermission(PERMS.SETTINGS_EDIT); + const file = formData.get("file") as File | null; + if (!file) return { success: false, error: "No file provided" }; + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "logo.save", + { file }, + ); + if (!result.ok) return { success: false, error: result.error.messageKey }; + revalidatePath("/", "layout"); + return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) }; } diff --git a/src/actions/set-trade-lock.test.ts b/src/actions/set-trade-lock.test.ts index 622a7619..b5052ac6 100644 --- a/src/actions/set-trade-lock.test.ts +++ b/src/actions/set-trade-lock.test.ts @@ -2,14 +2,20 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; -describe("admin-photos drizzle contract", () => { - const src = readFileSync("src/actions/admin-photos.ts", "utf8"); +describe("admin-photos Content service contract", () => { + const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8"); + const runtime = readFileSync( + "src/features/housekeeping/domains/content/services/mutation-runtime-external.ts", + "utf8", + ); - it("deletes via Drizzle CameraWeb and attempts local file purge", () => { - expect(src).toContain("@/lib/db"); - expect(src).toContain("CameraWeb"); - expect(src).toContain("tryRemoveLocalPhotoFile"); - expect(src).toContain('revalidatePath("/photos")'); + it("delegates while the runtime deletes CameraWeb and purges local files", () => { + expect(wrapper).toContain("contentMutationService.execute"); + expect(wrapper).toContain('"photo.delete"'); + expect(wrapper).toContain('revalidatePath("/photos")'); + expect(runtime).toContain("@/lib/db"); + expect(runtime).toContain("CameraWeb"); + expect(runtime).toContain("tryRemoveLocalPhotoFile"); }); }); diff --git a/src/actions/translations.ts b/src/actions/translations.ts index 3163ec76..93cecfd8 100644 --- a/src/actions/translations.ts +++ b/src/actions/translations.ts @@ -1,124 +1,46 @@ "use server"; -import fs from "node:fs/promises"; -import path from "node:path"; -import * as JSONC from "jsonc-parser"; import { z } from "zod"; -import { - CLIENT_TRANSLATION_FILES, - getClientTranslationFile, -} from "@/lib/client-translation-files"; -import { patchJson5 } from "@/lib/json5-patch"; +import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations"; +import { CLIENT_TRANSLATION_FILES } from "@/lib/client-translation-files"; 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", - ]), + 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()), }); +const saveClientTranslationsSchema = z.object({ + fileId: z.enum(CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]]), + data: z.record(z.string(), z.string()), +}); export const saveTranslations = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema }, async (ctx) => { - const filePath = path.join( - process.cwd(), - "src", - "messages", - `${ctx.data.locale}.json`, + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "translation.cms.save", + ctx.data, ); - await fs.writeFile( - filePath, - JSON.stringify(ctx.data.data, null, 2), - "utf-8", - ); - + if (!result.ok) throw new ActionError("Translation save failed"); return actionOk(); }, ); -const saveClientTranslationsSchema = z.object({ - fileId: z.enum( - CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]], - ), - data: z.record(z.string(), z.string()), -}); - export const saveClientTranslations = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema }, async (ctx) => { - const file = getClientTranslationFile(ctx.data.fileId); - if (!file) throw new ActionError("Unknown file"); - if (file.readOnly) throw new ActionError("File is read-only"); - - // file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but - // Turbopack's static tracer can't prove that — without the hint it - // pulls the entire project into the NFT list. - const absPath = path.join( - /*turbopackIgnore: true*/ process.cwd(), - file.relPath, + const result = await contentMutationService.execute( + { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + "translation.client.save", + ctx.data, ); - const raw = await fs.readFile(absPath, "utf-8"); - - if (file.format === "json") { - // Plain JSON — no comments to preserve, just round-trip. - await fs.writeFile( - absPath, - JSON.stringify(ctx.data.data, null, 4), - "utf-8", - ); - return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] }); - } - - // JSON5: surgical line-level patch keeps headers and section comments - // intact. Falls back to a full re-serialization (which DOES drop comments) - // only when an edited key cannot be located via the patch contract. - const original: Record = {}; - const parsed = JSONC.parse(raw); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - for (const [k, v] of Object.entries(parsed)) { - original[k] = v == null ? "" : String(v); - } - } - - const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data); - - if (unpatchedKeys.length === 0) { - await fs.writeFile(absPath, content, "utf-8"); - return actionOk({ commentsLost: false, unpatchedKeys }); - } - - // At least one key could not be patched surgically (e.g. unusual - // formatting or a brand-new key). Fall back to a full re-serialization - // and warn the caller that comments were lost. - await fs.writeFile( - absPath, - JSON.stringify(ctx.data.data, null, 4), - "utf-8", - ); - return actionOk({ commentsLost: true, unpatchedKeys }); + if (!result.ok) throw new ActionError("Translation save failed"); + return actionOk({ + commentsLost: result.data.output?.commentsLost === true, + unpatchedKeys: Array.isArray(result.data.output?.unpatchedKeys) ? result.data.output.unpatchedKeys : [], + }); }, ); diff --git a/src/features/housekeeping/domains/content/commands/content-commands.test.ts b/src/features/housekeeping/domains/content/commands/content-commands.test.ts new file mode 100644 index 00000000..0cbe6314 --- /dev/null +++ b/src/features/housekeeping/domains/content/commands/content-commands.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { + CONTENT_COMMAND_IDS, + createContentCommands, +} from "./content-commands"; + +const expected = [ + ["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT], + ["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT], + ["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT], + [ + "content.engagement.event-type.change", + "event-type.change", + PERMS.EVENTS_EDIT, + ], + ["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT], + [ + "content.engagement.event-prize.change", + "event-prize.change", + PERMS.EVENTS_EDIT, + ], + [ + "content.engagement.event-winner.add", + "event-winner.add", + PERMS.EVENTS_EDIT, + ], + ["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT], + [ + "content.engagement.poll-question.change", + "poll-question.change", + PERMS.POLLS_EDIT, + ], + ["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT], + ["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT], + ["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT], + [ + "content.editorial.navigation.update", + "navigation.update", + PERMS.SETTINGS_EDIT, + ], + ["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT], + [ + "content.engagement.prefix.change", + "prefix.change", + PERMS.PREFIXES_EDIT, + ], + [ + "content.engagement.prefix-blacklist.change", + "prefix-blacklist.change", + PERMS.PREFIXES_EDIT, + ], + [ + "content.engagement.prefix-settings.update", + "prefix-settings.update", + PERMS.PREFIXES_EDIT, + ], + ["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT], + [ + "content.editorial.writeable-box.change", + "writeable-box.change", + PERMS.PAGES_EDIT, + ], + [ + "content.help.email-template.change", + "email-template.change", + PERMS.PAGES_EDIT, + ], + ["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT], + [ + "content.brand.theme.apply-preset", + "theme.apply-preset", + PERMS.SETTINGS_EDIT, + ], + [ + "content.brand.theme.custom-change", + "theme.custom-change", + PERMS.SETTINGS_EDIT, + ], + [ + "content.brand.theme.apply-custom", + "theme.apply-custom", + PERMS.SETTINGS_EDIT, + ], + ["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT], + ["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT], + ["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT], + [ + "content.localization.cms.save", + "translation.cms.save", + PERMS.SETTINGS_EDIT, + ], + [ + "content.localization.client.save", + "translation.client.save", + PERMS.SETTINGS_EDIT, + ], + [ + "content.localization.emulator.save", + "translation.emulator.save", + PERMS.SETTINGS_EDIT, + ], +] as const; + +function context(): HousekeepingCapabilityContext { + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: () => true, + hasAny: () => true, + hasAll: () => true, + }; +} + +describe("Content commands", () => { + it("registers the exact complete operation matrix with existing ACLs", () => { + const service = { execute: vi.fn() }; + const commands = createContentCommands(service as never); + + expect(CONTENT_COMMAND_IDS).toEqual(expected.map(([id]) => id)); + expect( + commands.map((command) => [ + command.id, + command.operation, + command.capability.slugs[0], + ]), + ).toEqual(expected); + expect(commands.every((command) => command.owner === "content")).toBe(true); + }); + + it("marks global brand and localization writes sensitive with reason confirmation", () => { + const commands = createContentCommands({ execute: vi.fn() } as never); + const globalCommands = commands.filter( + (command) => + command.id.startsWith("content.brand.") || + command.id.startsWith("content.localization."), + ); + + expect(globalCommands.length).toBeGreaterThan(0); + expect( + globalCommands.every( + (command) => command.risk === "sensitive" && command.requiresReason, + ), + ).toBe(true); + expect( + globalCommands.every( + (command) => + command.capability.mode === "any" && + command.capability.slugs[0] === PERMS.SETTINGS_EDIT, + ), + ).toBe(true); + }); + + it("executes the real mutation service with actor-bound authority", async () => { + const execute = vi.fn(async () => ({ + ok: true as const, + data: { before: null, after: { id: "1" } }, + correlationId: "command-correlation", + })); + const [command] = createContentCommands({ execute } as never); + + const result = await command.execute( + { + capability: context(), + correlationId: "command-correlation", + ipAddress: "127.0.0.1", + }, + { action: "create", title: "Launch" }, + ); + + expect(execute).toHaveBeenCalledWith( + { + correlationId: "command-correlation", + expectedActorId: 42, + }, + "article.change", + { action: "create", title: "Launch" }, + ); + expect(result).toMatchObject({ ok: true }); + }); + + it("isolates command validation schemas from later caller mutation", () => { + const commands = createContentCommands({ execute: vi.fn() } as never); + for (const command of commands) { + expect(command.input.safeParse(null).success, command.id).toBe(false); + expect(command.rateLimit.attempts).toBeGreaterThan(0); + expect(command.rateLimit.windowMs).toBeGreaterThan(0); + } + }); +}); diff --git a/src/features/housekeeping/domains/content/commands/content-commands.ts b/src/features/housekeeping/domains/content/commands/content-commands.ts new file mode 100644 index 00000000..1ae16a68 --- /dev/null +++ b/src/features/housekeeping/domains/content/commands/content-commands.ts @@ -0,0 +1,84 @@ +import "server-only"; + +import { z } from "zod"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCommand } from "../../../foundation/commands/registry"; +import { anyCapability } from "../../../foundation/contracts"; +import { + type ContentMutationOperation, + type ContentMutationService, + contentMutationService, +} from "../services/mutations"; + +const CONTENT_COMMAND_DEFINITIONS = [ + ["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT], + ["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT], + ["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT], + ["content.engagement.event-type.change", "event-type.change", PERMS.EVENTS_EDIT], + ["content.engagement.event.change", "event.change", PERMS.EVENTS_EDIT], + ["content.engagement.event-prize.change", "event-prize.change", PERMS.EVENTS_EDIT], + ["content.engagement.event-winner.add", "event-winner.add", PERMS.EVENTS_EDIT], + ["content.engagement.poll.change", "poll.change", PERMS.POLLS_EDIT], + ["content.engagement.poll-question.change", "poll-question.change", PERMS.POLLS_EDIT], + ["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT], + ["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT], + ["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT], + ["content.editorial.navigation.update", "navigation.update", PERMS.SETTINGS_EDIT], + ["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT], + ["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT], + ["content.engagement.prefix-blacklist.change", "prefix-blacklist.change", PERMS.PREFIXES_EDIT], + ["content.engagement.prefix-settings.update", "prefix-settings.update", PERMS.PREFIXES_EDIT], + ["content.help.question.change", "help-question.change", PERMS.PAGES_EDIT], + ["content.editorial.writeable-box.change", "writeable-box.change", PERMS.PAGES_EDIT], + ["content.help.email-template.change", "email-template.change", PERMS.PAGES_EDIT], + ["content.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT], + ["content.brand.theme.apply-preset", "theme.apply-preset", PERMS.SETTINGS_EDIT], + ["content.brand.theme.custom-change", "theme.custom-change", PERMS.SETTINGS_EDIT], + ["content.brand.theme.apply-custom", "theme.apply-custom", PERMS.SETTINGS_EDIT], + ["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT], + ["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT], + ["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT], + ["content.localization.cms.save", "translation.cms.save", PERMS.SETTINGS_EDIT], + ["content.localization.client.save", "translation.client.save", PERMS.SETTINGS_EDIT], + ["content.localization.emulator.save", "translation.emulator.save", PERMS.SETTINGS_EDIT], +] as const; + +export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map( + ([id]) => id, +) as ReadonlyArray<(typeof CONTENT_COMMAND_DEFINITIONS)[number][0]>; + +type ContentCommand = HousekeepingCommand, unknown> & { + readonly operation: ContentMutationOperation; +}; + +const commandInput = z.object({}).catchall(z.unknown()); + +export function createContentCommands( + service: Pick, +): readonly ContentCommand[] { + return CONTENT_COMMAND_DEFINITIONS.map( + ([id, operation, permission]): ContentCommand => ({ + id, + owner: "content", + operation, + risk: "sensitive", + capability: anyCapability(permission), + input: commandInput, + requiresReason: + id.startsWith("content.brand.") || + id.startsWith("content.localization."), + rateLimit: { attempts: 10, windowMs: 60_000 }, + execute: (context, input) => + service.execute( + { + correlationId: context.correlationId, + expectedActorId: context.capability.actor.id, + }, + operation, + input, + ), + }), + ); +} + +export const CONTENT_COMMANDS = createContentCommands(contentMutationService); diff --git a/src/features/housekeeping/domains/content/content-providers-production.test.ts b/src/features/housekeeping/domains/content/content-providers-production.test.ts new file mode 100644 index 00000000..97d41c18 --- /dev/null +++ b/src/features/housekeeping/domains/content/content-providers-production.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; +import { loadContentInboxItems } from "./inbox-production"; +import { loadContentSearchCandidates } from "./search-production"; +import { loadContentWidget } from "./widgets-production"; + +const { run } = vi.hoisted(() => ({ run: vi.fn() })); + +vi.mock("./queries/content-queries", () => ({ + contentQuery: { run }, +})); + +const context = { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: () => true, + hasAny: () => true, + hasAll: () => true, +} satisfies HousekeepingCapabilityContext; + +function result( + routeId: string, + total: number, + items: readonly Record[] = [], +) { + return { + ok: true as const, + data: { + kind: routeId.split(".")[1], + items, + total, + partialDependencies: [], + }, + correlationId: "provider-production", + }; +} + +describe("Content production providers", () => { + beforeEach(() => { + vi.clearAllMocks(); + run.mockImplementation(async (_context, input) => + result(input.routeId, input.routeId.includes("articles") ? 7 : 3), + ); + }); + + it("returns only truthful persisted counts from editorial and localization widgets", async () => { + const signal = new AbortController().signal; + await expect(loadContentWidget("editorial", context, signal)).resolves.toEqual( + { articles: 7 }, + ); + await expect( + loadContentWidget("localization", context, signal), + ).resolves.toEqual({ stores: 3 }); + }); + + it("loads real search candidates from bounded query routes", async () => { + run.mockImplementation(async (_context, input) => + result(input.routeId, 1, [ + { + id: "9", + title: "Launch", + description: "Published", + href: "/ase/content/editorial/articles/9", + }, + ]), + ); + + const candidates = await loadContentSearchCandidates( + "articles", + context, + "launch", + 25, + ); + expect(candidates).toEqual([ + expect.objectContaining({ + id: "content.editorial.articles:9", + href: "/ase/content/editorial/articles/9", + }), + ]); + expect(run).toHaveBeenCalledWith( + context, + expect.objectContaining({ list: { search: "launch", pageSize: 25, offset: 0 } }), + ); + }); + + it("builds publication inbox items only from real query rows", async () => { + run.mockImplementation(async (_context, input) => + result(input.routeId, 1, [ + { + id: "5", + title: "Release", + status: "published", + updatedAt: new Date().toISOString(), + href: "/ase/content/editorial/articles/5", + }, + ]), + ); + + const items = await loadContentInboxItems( + "publication", + context, + new AbortController().signal, + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + itemId: "5", + sourceId: "content.publication", + href: "/ase/content/editorial/articles/5", + }); + }); +}); diff --git a/src/features/housekeeping/domains/content/content-providers.test.ts b/src/features/housekeeping/domains/content/content-providers.test.ts new file mode 100644 index 00000000..948c5dd5 --- /dev/null +++ b/src/features/housekeeping/domains/content/content-providers.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + type HousekeepingCapabilityContext, +} from "../../foundation/contracts"; +import { + CONTENT_INBOX_SOURCE_IDS, + createContentInboxSources, +} from "./inbox"; +import { + CONTENT_SEARCH_PROVIDER_IDS, + createContentSearchProviders, +} from "./search"; +import { CONTENT_WIDGET_IDS, createContentWidgets } from "./widgets"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 7 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +describe("Content search providers", () => { + it("uses the four exact IDs, filters item capabilities, and caps results at 25", async () => { + const visible = anyCapability(PERMS.NEWS_VIEW); + const hidden = anyCapability(PERMS.EVENTS_VIEW); + const candidates = Array.from({ length: 30 }, (_, index) => ({ + id: `article-${index}`, + title: `Article ${index}`, + href: `/ase/content/editorial/articles/${index + 1}`, + capability: index === 0 ? hidden : visible, + })); + const providerAdapters = { + articles: vi.fn(async () => candidates), + events: vi.fn(async () => []), + media: vi.fn(async () => []), + help: vi.fn(async () => []), + }; + const providers = createContentSearchProviders(providerAdapters); + + expect(CONTENT_SEARCH_PROVIDER_IDS).toEqual([ + "content.articles", + "content.events", + "content.media", + "content.help", + ]); + expect(providers.map((provider) => provider.id)).toEqual( + CONTENT_SEARCH_PROVIDER_IDS, + ); + const result = await providers[0].search(context([PERMS.NEWS_VIEW]), { + term: " launch ", + limit: 999, + }); + expect(providerAdapters.articles).toHaveBeenCalledWith( + expect.anything(), + "launch", + 25, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data).toHaveLength(25); + expect(result.data.some((item) => item.id === "article-0")).toBe(false); + }); + + it.each([ + "/ase/content/%2e%2e/system", + "/ase/content/%252e%252e/system", + "/ase/content/%252f..%252fsystem", + "/ase/content/%255c..%255csystem", + "https://example.test/ase/content/editorial", + "//example.test/ase/content/editorial", + ])("rejects normalized and double-encoded traversal href %s", async (href) => { + const adapters = { + articles: async () => [ + { + id: "unsafe", + title: "Unsafe", + href, + capability: anyCapability(PERMS.NEWS_VIEW), + }, + ], + events: async () => [], + media: async () => [], + help: async () => [], + }; + const [provider] = createContentSearchProviders(adapters); + const result = await provider.search(context([PERMS.NEWS_VIEW]), { + term: "", + limit: 25, + }); + expect(result).toMatchObject({ ok: true, data: [] }); + }); +}); + +describe("Content inbox and widgets", () => { + it("provides capability-selective publication and attention sources", async () => { + const publication = vi.fn(async () => []); + const attention = vi.fn(async () => []); + const sources = createContentInboxSources({ publication, attention }); + + expect(CONTENT_INBOX_SOURCE_IDS).toEqual([ + "content.publication", + "content.attention", + ]); + const controller = new AbortController(); + const news = context([PERMS.NEWS_VIEW]); + await sources[0].getItems(news, controller.signal); + const forbidden = await sources[1].getItems(news, controller.signal); + expect(publication).toHaveBeenCalledTimes(1); + expect(attention).not.toHaveBeenCalled(); + expect(forbidden).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + }); + }); + + it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => { + const adapters = { + editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })), + media: vi.fn(async () => ({ items: 4 })), + localization: vi.fn(async () => ({ stores: 3, pending: 0 })), + }; + const widgets = createContentWidgets(adapters); + + expect(CONTENT_WIDGET_IDS).toEqual([ + "content.editorial-summary", + "content.media-summary", + "content.localization-summary", + ]); + expect(widgets.map((widget) => widget.kind)).toEqual([ + "mandatory", + "optional", + "optional", + ]); + const result = await widgets[0].load( + context([PERMS.NEWS_VIEW]), + new AbortController().signal, + ); + expect(result).toMatchObject({ + ok: true, + data: { drafts: 2, scheduled: 1 }, + }); + }); +}); diff --git a/src/features/housekeeping/domains/content/inbox-production.ts b/src/features/housekeeping/domains/content/inbox-production.ts new file mode 100644 index 00000000..ab7f8923 --- /dev/null +++ b/src/features/housekeeping/domains/content/inbox-production.ts @@ -0,0 +1,69 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + type HousekeepingCapabilityContext, + type HousekeepingWorkItem, +} from "../../foundation/contracts"; +import { contentQuery } from "./queries/content-queries"; + +type ContentInboxKind = "publication" | "attention"; + +function time(value: string | null | undefined) { + if (!value) return null; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return null; + const ageMs = Math.max(0, Date.now() - timestamp); + return { + occurredAt: new Date(timestamp).toISOString(), + ageMs, + freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const), + }; +} + +export async function loadContentInboxItems( + kind: ContentInboxKind, + context: HousekeepingCapabilityContext, + signal: AbortSignal, +): Promise { + if (signal.aborted) throw new Error("aborted Content inbox"); + const definitions = + kind === "publication" + ? ([ + ["content.editorial.articles", PERMS.NEWS_VIEW, "content.publication"], + ] as const) + : ([ + ["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"], + ["content.engagement.polls", PERMS.POLLS_VIEW, "content.attention"], + ] as const); + const items: HousekeepingWorkItem[] = []; + for (const [routeId, permission, sourceId] of definitions) { + const result = await contentQuery.run(context, { + routeId, + list: { pageSize: 25, offset: 0 }, + }); + if (!result.ok) continue; + for (const item of result.data.items) { + const date = time(item.updatedAt); + if (!date || !item.href) continue; + items.push({ + sourceId, + itemId: item.id, + deduplicationKey: sourceId + ":" + routeId + ":" + item.id, + domain: "content", + capability: anyCapability(permission), + severity: item.status === "failed" ? "warning" : "info", + priority: item.status === "failed" ? "high" : "normal", + ...date, + state: item.status ?? "ready", + titleKey: "pages.housekeeping.items.content", + context: { title: item.title }, + href: item.href as `/ase/${string}`, + actions: [], + }); + if (items.length >= 25) return items; + } + } + return items; +} diff --git a/src/features/housekeeping/domains/content/inbox.ts b/src/features/housekeeping/domains/content/inbox.ts new file mode 100644 index 00000000..cb20b5d3 --- /dev/null +++ b/src/features/housekeeping/domains/content/inbox.ts @@ -0,0 +1,99 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../foundation/authorization"; +import { satisfiesCapability } from "../../foundation/capability-context"; +import { + anyCapability, + type CapabilityRequirement, + fail, + type HousekeepingCapabilityContext, + type HousekeepingInboxSource, + type HousekeepingWorkItem, + ok, +} from "../../foundation/contracts"; +import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href"; + +export const CONTENT_INBOX_SOURCE_IDS = [ + "content.publication", + "content.attention", +] as const; + +type ContentInboxLoader = ( + context: HousekeepingCapabilityContext, + signal: AbortSignal, +) => Promise; + +export interface ContentInboxAdapters { + readonly publication: ContentInboxLoader; + readonly attention: ContentInboxLoader; +} + +function createSource( + id: (typeof CONTENT_INBOX_SOURCE_IDS)[number], + capability: CapabilityRequirement, + load: ContentInboxLoader, +): HousekeepingInboxSource { + return { + id, + owner: "content", + capability, + async getItems(context, signal) { + const authorization = authorizeHousekeeping(context, capability); + if (!authorization.ok) return authorization; + try { + const items = await load(context, signal); + return ok( + { + availability: "available" as const, + items: items + .filter( + (item) => + isSafeHousekeepingHref(item.href) && + satisfiesCapability(context, item.capability), + ) + .slice(0, 25), + }, + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, + }; +} + +export function createContentInboxSources( + adapters: ContentInboxAdapters, +): readonly HousekeepingInboxSource[] { + return [ + createSource( + "content.publication", + anyCapability(PERMS.NEWS_VIEW), + adapters.publication, + ), + createSource( + "content.attention", + anyCapability( + PERMS.EVENTS_VIEW, + PERMS.POLLS_VIEW, + PERMS.PAGES_VIEW, + PERMS.BANNERS_VIEW, + ), + adapters.attention, + ), + ]; +} + +export const CONTENT_INBOX_SOURCES = createContentInboxSources({ + async publication(context, signal) { + const { loadContentInboxItems } = await import("./inbox-production"); + return loadContentInboxItems("publication", context, signal); + }, + async attention(context, signal) { + const { loadContentInboxItems } = await import("./inbox-production"); + return loadContentInboxItems("attention", context, signal); + }, +}); diff --git a/src/features/housekeeping/domains/content/manifest.ts b/src/features/housekeeping/domains/content/manifest.ts index 418995ff..29032976 100644 --- a/src/features/housekeeping/domains/content/manifest.ts +++ b/src/features/housekeeping/domains/content/manifest.ts @@ -3,6 +3,10 @@ import { anyCapability, type HousekeepingDomainManifest, } from "../../foundation/contracts"; +import { CONTENT_INBOX_SOURCES } from "./inbox"; +import { CONTENT_ROUTES } from "./routes"; +import { CONTENT_SEARCH_PROVIDERS } from "./search"; +import { CONTENT_WIDGETS } from "./widgets"; export const contentManifest = { id: "content", @@ -26,8 +30,8 @@ export const contentManifest = { PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT, ), - routes: [], - searchProviders: [], - inboxSources: [], - widgets: [], + routes: CONTENT_ROUTES, + searchProviders: CONTENT_SEARCH_PROVIDERS, + inboxSources: CONTENT_INBOX_SOURCES, + widgets: CONTENT_WIDGETS, } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/content/pages/brand.tsx b/src/features/housekeeping/domains/content/pages/brand.tsx new file mode 100644 index 00000000..6b6fe5ca --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/brand.tsx @@ -0,0 +1,28 @@ +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingPageInput } from "../../../route-handlers"; +import { contentQuery } from "../queries/content-queries"; +import { ContentCommandForm } from "./content-command-form"; +import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame"; + +export function ContentBrandPage({ context, result, routeId }: ContentPageProps) { + const forms = context.has(PERMS.SETTINGS_EDIT) ?
+ {routeId === "content.brand.theme" ? <> + + + + + : null} + {routeId === "content.brand.favicon" ? <> + + + + : null} +
: null; + return ; +} + +export async function renderContentBrandPage(input: HousekeepingPageInput) { + const routeId = input.match.routeId as ContentPageProps["routeId"]; + const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.brand.theme", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) }); + return ; +} diff --git a/src/features/housekeeping/domains/content/pages/content-command-form.tsx b/src/features/housekeeping/domains/content/pages/content-command-form.tsx new file mode 100644 index 00000000..c49bf17f --- /dev/null +++ b/src/features/housekeeping/domains/content/pages/content-command-form.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { useActionState } from "react"; +import type { HousekeepingResult } from "../../../foundation/contracts"; + +export interface ContentCommandField { + readonly name: string; + readonly label: string; + readonly type: + | "identifier" + | "json" + | "text" + | "textarea" + | "number" + | "checkbox" + | "select" + | "file"; + readonly required?: boolean; + readonly min?: number; + readonly max?: number; + readonly maxLength?: number; + readonly defaultValue?: string | number; + readonly options?: readonly Readonly<{ + value: string | number; + label: string; + }>[]; +} + +export interface ContentCommandSubmission { + readonly commandId: string; + readonly input: Readonly>; + readonly fields?: readonly ContentCommandField[]; + readonly requiresReason?: boolean; +} + +interface ContentCommandFormProps extends ContentCommandSubmission { + readonly buttonLabel: string; +} + +function parseField(field: ContentCommandField, formData: FormData): unknown { + const rawValue = formData.get(field.name); + if (field.type === "checkbox") return rawValue === "on"; + if (field.type === "file") return rawValue instanceof File ? rawValue : null; + const raw = String(rawValue ?? "").normalize("NFC").trim(); + if (field.type === "number") { + const value = Number(raw); + if (!Number.isSafeInteger(value)) return 0; + return Math.min(field.max ?? value, Math.max(field.min ?? value, value)); + } + if (field.type === "select") { + const selected = field.options?.find( + (option) => String(option.value) === raw, + )?.value; + return selected ?? raw.slice(0, 500); + } + if (field.type === "json") { + try { + return JSON.parse(raw.slice(0, field.maxLength ?? 20_000)); + } catch { + return null; + } + } + return raw.slice(0, field.maxLength ?? (field.type === "textarea" ? 20_000 : 500)); +} + +const initialState: HousekeepingResult | null = null; + +export async function submitContentCommandForm( + configuration: ContentCommandSubmission, + _previous: HousekeepingResult | null, + formData: FormData, +): Promise> { + const input = { + ...configuration.input, + ...Object.fromEntries( + (configuration.fields ?? []).map((field) => [ + field.name, + parseField(field, formData), + ]), + ), + }; + const reason = String(formData.get("reason") ?? "") + .normalize("NFC") + .trim() + .slice(0, 1000); + const { executeHousekeepingCommand } = await import( + "@/actions/housekeeping-command" + ); + return executeHousekeepingCommand({ + commandId: configuration.commandId, + input, + ...(configuration.requiresReason ? { reason } : {}), + }); +} + +export function ContentCommandForm({ + commandId, + buttonLabel, + input, + fields = [], + requiresReason = false, +}: ContentCommandFormProps) { + const [result, submit, pending] = useActionState( + submitContentCommandForm.bind(null, { + commandId, + input, + fields, + requiresReason, + }), + initialState, + ); + return ( +
+ {fields.map((field) => ( +