diff --git a/src/actions/admin-ads.test.ts b/src/actions/admin-ads.test.ts index a2518a86..c36275c0 100644 --- a/src/actions/admin-ads.test.ts +++ b/src/actions/admin-ads.test.ts @@ -7,17 +7,30 @@ import { ActionError } from "@/lib/safe-action-shared"; import { createAd, deleteAd } from "./admin-ads"; const { execute } = vi.hoisted(() => ({ - execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" })), + 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 }), + 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/logger", () => ({ logger: { error: 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("@/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() })); @@ -27,13 +40,21 @@ const fakeForm = (data) => ({ get: (key) => data[key] ?? null }); beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff); - execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" }); + execute.mockResolvedValue({ + ok: true, + data: { before: null, after: { id: "1" }, output: { id: "1" } }, + correlationId: "legacy", + }); }); 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(execute).toHaveBeenCalledWith( + expect.objectContaining({ expectedActorId: 1, legacy: true }), + "ad.change", + { action: "create", image: "https://example.com/ad.png" }, + ); expect(redirect).toHaveBeenCalledWith("/admin/ads"); }); @@ -43,20 +64,52 @@ describe("Content advertisement legacy wrappers", () => { }); it("logs a redacted service failure", async () => { - execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" }); + 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" })); + expect(logger.error).toHaveBeenCalledWith( + "Action failed: createAd", + expect.objectContaining({ + error: "errors.housekeeping.dependencyUnavailable", + }), + ); }); 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" }); + 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("preserves not-found ActionError", async () => { - execute.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" }, correlationId: "legacy" }); + 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); + 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 5b0abd82..cfe0139b 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -16,7 +16,10 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared"; 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 result = await contentMutationService.execute( createContentMutationInvocation(staff, createCorrelationId()), @@ -24,7 +27,10 @@ export async function createAd(formData: FormData): Promise { { action: "create", image }, ); if (!result.ok) { - logger.error("Action failed: createAd", { action: "createAd", error: result.error.messageKey }); + logger.error("Action failed: createAd", { + action: "createAd", + error: result.error.messageKey, + }); revalidatePath("/admin/ads"); return; } @@ -35,7 +41,10 @@ export async function updateAd(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const raw = String(formData.get("id") ?? "").normalize("NFC"); if (!/^\d+$/u.test(raw)) return; - 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 result = await contentMutationService.execute( createContentMutationInvocation(staff, createCorrelationId()), @@ -43,7 +52,11 @@ export async function updateAd(formData: FormData): Promise { { action: "update", id: raw, image }, ); if (!result.ok) { - logger.error("Action failed: updateAd", { action: "updateAd", id: Number(raw), error: result.error.messageKey }); + logger.error("Action failed: updateAd", { + action: "updateAd", + id: Number(raw), + error: result.error.messageKey, + }); revalidatePath("/admin/ads/" + raw); return; } @@ -51,14 +64,20 @@ export async function updateAd(formData: FormData): Promise { } const deleteAdInput = z.object({ - id: z.union([z.string(), z.number(), z.bigint()]).transform((value) => BigInt(String(value))), + 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 result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "ad.change", { action: "delete", id: ctx.data.id.toString() }, ); diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 742f2e16..4ae7a907 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -12,10 +12,18 @@ import { PERMS } from "@/lib/permissions"; 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(), + 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(), }; } @@ -30,7 +38,9 @@ export async function createArticle(formData: FormData): Promise { { action: "create", ...input }, ); if (!result.ok) { - redirect("/admin/articles/new?error=Database error while creating article. Please try again."); + redirect( + "/admin/articles/new?error=Database error while creating article. Please try again.", + ); } redirect("/admin/articles"); } diff --git a/src/actions/admin-bans.test.ts b/src/actions/admin-bans.test.ts index 66d77318..0de0f700 100644 --- a/src/actions/admin-bans.test.ts +++ b/src/actions/admin-bans.test.ts @@ -51,9 +51,7 @@ describe("legacy admin ban wrappers", () => { }); it("returns early when userId is invalid", async () => { - await createBan( - fakeForm({ userId: "0", hours: "1", type: "account" }), - ); + await createBan(fakeForm({ userId: "0", hours: "1", type: "account" })); expect(execute).not.toHaveBeenCalled(); }); diff --git a/src/actions/admin-email-templates.ts b/src/actions/admin-email-templates.ts index 8250ffb6..a01d5c57 100644 --- a/src/actions/admin-email-templates.ts +++ b/src/actions/admin-email-templates.ts @@ -11,10 +11,18 @@ import { PERMS } from "@/lib/permissions"; 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), + 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(), + variables: String(formData.get("variables") ?? "") + .normalize("NFC") + .trim(), isActive: formData.get("isActive") != null, }; } @@ -23,7 +31,11 @@ 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 }); + 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"); } @@ -34,16 +46,26 @@ export async function updateEmailTemplate(formData: FormData): Promise { 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 }); + 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 { const staff = await requirePermission(PERMS.PAGES_EDIT); - const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + 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 }); + 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-tickets.ts b/src/actions/admin-help-tickets.ts index 90a0b13d..df93ef9f 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -47,10 +47,7 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) { async function execute( staff: { readonly id: number }, - operation: - | "help-ticket.reply" - | "help-ticket.status" - | "help-ticket.unban", + operation: "help-ticket.reply" | "help-ticket.status" | "help-ticket.unban", input: unknown, ) { return peopleMutationService.execute( diff --git a/src/actions/admin-help.test.ts b/src/actions/admin-help.test.ts index 678dcbc2..d824417e 100644 --- a/src/actions/admin-help.test.ts +++ b/src/actions/admin-help.test.ts @@ -1,12 +1,19 @@ 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"; +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 }), + 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" } })); @@ -14,28 +21,50 @@ 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); - execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "5" } }, correlationId: "legacy" }); + execute.mockResolvedValue({ + ok: true, + data: { before: null, after: { id: "5" } }, + correlationId: "legacy", + }); }); 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" })); + 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"); }); 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" })); + 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"); }); 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(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 6a3b1cd1..e1bd8497 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -15,12 +15,16 @@ 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, + 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", + buttonBorderColor: + sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15", smallBox: formData.get("smallBox") != null, }; } @@ -29,10 +33,16 @@ export async function createHelpQuestion(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const input = helpInput(formData); if (!input.name || !input.content) return; - const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "create", ...input }); + 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"); @@ -44,7 +54,11 @@ export async function updateHelpQuestion(formData: FormData): Promise { 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 }); + const result = await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "help-question.change", + { action: "update", id, ...input }, + ); if (!result.ok) { revalidatePath("/admin/help-questions/" + id); return; @@ -56,6 +70,10 @@ export async function deleteHelpQuestion(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); 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 }); + 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 9d458550..7e79441b 100644 --- a/src/actions/admin-media.test.ts +++ b/src/actions/admin-media.test.ts @@ -5,20 +5,42 @@ import { requirePermission } from "@/lib/admin/guard"; 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("@/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("next/cache", () => ({ revalidatePath: vi.fn() })); beforeEach(() => { vi.clearAllMocks(); - 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" }); + 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("Content media legacy wrappers", () => { it("retains no-file validation", async () => { - expect(await uploadMedia(new FormData())).toEqual({ ok: false, error: "No file provided" }); + expect(await uploadMedia(new FormData())).toEqual({ + ok: false, + error: "No file provided", + }); expect(await uploadMediaAndReturn(new FormData())).toBe(""); expect(execute).not.toHaveBeenCalled(); }); @@ -28,11 +50,15 @@ describe("Content media legacy wrappers", () => { 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 }); + 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(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", { + filename: "photo.png", + }); expect(revalidatePath).toHaveBeenCalledWith("/api/media"); expect(revalidatePath).toHaveBeenCalledWith("/admin/media"); }); diff --git a/src/actions/admin-media.ts b/src/actions/admin-media.ts index e5901b03..d6a71688 100644 --- a/src/actions/admin-media.ts +++ b/src/actions/admin-media.ts @@ -20,11 +20,14 @@ function mediaFile(formData: FormData): File | null { 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"; + 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 }> { +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); @@ -51,7 +54,9 @@ export async function deleteMedia(name: string): Promise { revalidatePath("/admin/media"); } -export async function uploadMediaAndReturn(formData: FormData): Promise { +export async function uploadMediaAndReturn( + formData: FormData, +): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const file = mediaFile(formData); if (validateMediaFile(file)) return ""; @@ -63,5 +68,7 @@ export async function uploadMediaAndReturn(formData: FormData): Promise if (!result.ok) return ""; revalidatePath("/api/media"); revalidatePath("/admin/media"); - return typeof result.data.output?.url === "string" ? result.data.output.url : ""; + 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 1b7f18b2..fce650d3 100644 --- a/src/actions/admin-nav-menu.ts +++ b/src/actions/admin-nav-menu.ts @@ -22,7 +22,11 @@ export const saveAdminNavConfig = adminAction( }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "navigation.update", ctx.data, ); diff --git a/src/actions/admin-photos.ts b/src/actions/admin-photos.ts index 95640fc3..76f9bfe5 100644 --- a/src/actions/admin-photos.ts +++ b/src/actions/admin-photos.ts @@ -13,7 +13,11 @@ export async function deletePhoto(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const id = Number(formData.get("id")); if (!(id > 0)) return; - await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "photo.delete", { 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 ae1f469c..87562053 100644 --- a/src/actions/admin-tags.test.ts +++ b/src/actions/admin-tags.test.ts @@ -5,7 +5,14 @@ import { requirePermission } from "@/lib/admin/guard"; import { createTag, deleteTag, updateTag } from "./admin-tags"; 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("@/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("next/cache", () => ({ revalidatePath: vi.fn() })); @@ -13,32 +20,61 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); const form = (data) => ({ get: (key) => data[key] ?? null }); beforeEach(() => { vi.clearAllMocks(); - vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); - execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" } }, correlationId: "legacy" }); + vi.mocked(requirePermission).mockResolvedValue({ + id: 1, + rank: 7, + username: "admin", + }); + execute.mockResolvedValue({ + ok: true, + data: { before: null, after: { id: "1" } }, + correlationId: "legacy", + }); }); 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(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { + action: "create", + name: "News", + backgroundColor: "#ff0000", + }); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); }); it("uses the legacy default color", async () => { await createTag(form({ name: "Test" })); - expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ backgroundColor: "#888888" })); + expect(execute).toHaveBeenCalledWith( + expect.anything(), + "tag.change", + expect.objectContaining({ backgroundColor: "#888888" }), + ); }); it("returns early for an empty name", async () => { await createTag(form({ name: "" })); expect(execute).not.toHaveBeenCalled(); }); it("revalidates after a fail-soft dependency result", async () => { - execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" }); + execute.mockResolvedValue({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + correlationId: "legacy", + }); await createTag(form({ name: "News" })); 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" })); + await updateTag( + form({ id: "42", name: "Updated", backgroundColor: "#00ff00" }), + ); + expect(execute).toHaveBeenCalledWith( + expect.anything(), + "tag.change", + expect.objectContaining({ action: "update", id: "42" }), + ); }); it("rejects invalid update id or name", async () => { await updateTag(form({ id: "", name: "Test" })); @@ -47,7 +83,10 @@ describe("Content tag legacy wrappers", () => { }); it("delegates delete", async () => { await deleteTag(form({ id: "42" })); - expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "delete", id: "42" }); + expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { + action: "delete", + id: "42", + }); }); it("rejects invalid delete id", async () => { await deleteTag(form({ id: "" })); diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts index e88395c7..235de3e9 100644 --- a/src/actions/admin-tags.ts +++ b/src/actions/admin-tags.ts @@ -11,8 +11,13 @@ import { PERMS } from "@/lib/permissions"; function tagInput(formData: FormData) { return { - name: String(formData.get("name") ?? "").trim().slice(0, 255), - backgroundColor: String(formData.get("backgroundColor") ?? "").trim().slice(0, 10) || "#888888", + name: String(formData.get("name") ?? "") + .trim() + .slice(0, 255), + backgroundColor: + String(formData.get("backgroundColor") ?? "") + .trim() + .slice(0, 10) || "#888888", }; } @@ -20,7 +25,11 @@ export async function createTag(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); const input = tagInput(formData); if (!input.name) return; - await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "create", ...input }); + await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "tag.change", + { action: "create", ...input }, + ); revalidatePath("/admin/tags"); } @@ -30,7 +39,11 @@ export async function updateTag(formData: FormData): Promise { 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 }); + await contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + "tag.change", + { action: "update", id, ...input }, + ); revalidatePath("/admin/tags"); } @@ -38,6 +51,10 @@ export async function deleteTag(formData: FormData): Promise { const staff = await requirePermission(PERMS.PAGES_EDIT); 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 }); + 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 65746772..bcc5cbf8 100644 --- a/src/actions/admin-theme.ts +++ b/src/actions/admin-theme.ts @@ -11,16 +11,34 @@ import { requirePermission } from "@/lib/admin/guard"; import { PERMS } from "@/lib/permissions"; function formValues(formData: FormData): Record { - return Object.fromEntries(Array.from(formData.entries(), ([key, value]) => [key, typeof value === "string" ? value : value.name])); + return Object.fromEntries( + Array.from(formData.entries(), ([key, value]) => [ + key, + typeof value === "string" ? value : value.name, + ]), + ); } -async function executeTheme(operation: "theme.update" | "theme.apply-preset" | "theme.custom-change" | "theme.apply-custom", input: Record) { +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); + return contentMutationService.execute( + createContentMutationInvocation(staff, createCorrelationId()), + operation, + input, + ); } export async function saveTheme(formData: FormData): Promise { - const result = await executeTheme("theme.update", { values: formValues(formData) }); + const result = await executeTheme("theme.update", { + values: formValues(formData), + }); if (result.ok) revalidatePath("/", "layout"); redirect("/admin/theme?saved=1"); } @@ -29,39 +47,70 @@ export async function applyPreset(formData: FormData): Promise { const name = String(formData.get("preset") ?? "").normalize("NFC"); const result = await executeTheme("theme.apply-preset", { preset: name }); if (result.ok) revalidatePath("/", "layout"); - redirect(result.ok ? "/admin/theme?preset=" + encodeURIComponent(name) : "/admin/theme"); + redirect( + result.ok + ? "/admin/theme?preset=" + encodeURIComponent(name) + : "/admin/theme", + ); } export async function saveCustomTheme(formData: FormData): Promise { - const name = String(formData.get("name") ?? "").normalize("NFC").trim(); + const name = String(formData.get("name") ?? "") + .normalize("NFC") + .trim(); if (!name) redirect("/admin/theme"); - const result = await executeTheme("theme.custom-change", { action: "create", name }); + 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 id = String(formData.get("id") ?? "").normalize("NFC").trim(); + const id = String(formData.get("id") ?? "") + .normalize("NFC") + .trim(); if (!id) redirect("/admin/theme"); 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"); + 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 { - 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 result = await executeTheme("theme.custom-change", { action: "rename", id, name }); + 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 { - const id = String(formData.get("id") ?? "").normalize("NFC").trim(); + const id = String(formData.get("id") ?? "") + .normalize("NFC") + .trim(); if (!id) redirect("/admin/theme"); - const result = await executeTheme("theme.custom-change", { action: "delete", id }); + 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 d6f4e317..f8643324 100644 --- a/src/actions/admin-writeable-boxes.ts +++ b/src/actions/admin-writeable-boxes.ts @@ -12,10 +12,17 @@ import { PERMS } from "@/lib/permissions"; 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), + 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, + position: + Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0, isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", }; } @@ -25,13 +32,23 @@ function refreshBoxes(): void { revalidatePath("/", "layout"); } -async function executeBox(formData: FormData, action: "create" | "update" | "delete" | "toggle"): Promise { +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(); + 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") }); + 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; } diff --git a/src/actions/banners.ts b/src/actions/banners.ts index 98616e47..60685b68 100644 --- a/src/actions/banners.ts +++ b/src/actions/banners.ts @@ -18,9 +18,20 @@ 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") { +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 }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "banner.change", { action, ...ctx.data }, ); @@ -35,7 +46,9 @@ export const createBanner = adminAction( }, ); -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 }, diff --git a/src/actions/emulator.test.ts b/src/actions/emulator.test.ts index 69ff94b8..967a97a9 100644 --- a/src/actions/emulator.test.ts +++ b/src/actions/emulator.test.ts @@ -1,17 +1,38 @@ // @ts-nocheck import { describe, expect, it, vi } from "vitest"; -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 })); +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("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" } }); + 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 d1ec0259..e668cf52 100644 --- a/src/actions/emulator.ts +++ b/src/actions/emulator.ts @@ -6,13 +6,19 @@ import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { actionOk } from "@/lib/safe-action-shared"; -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 result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "translation.emulator.save", ctx.data, ); diff --git a/src/actions/events.ts b/src/actions/events.ts index 8a68924c..666d77b0 100644 --- a/src/actions/events.ts +++ b/src/actions/events.ts @@ -28,7 +28,11 @@ export const createEventType = adminAction( { permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event-type.change", { action: "create", ...ctx.data }, ); @@ -45,7 +49,11 @@ export const updateEventType = adminAction( { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event-type.change", { action: "update", ...ctx.data }, ); @@ -62,7 +70,11 @@ export const deleteEventType = adminAction( { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event-type.change", { action: "delete", ...ctx.data }, ); @@ -77,7 +89,11 @@ export const createEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event.change", { action: "create", ...ctx.data }, ); @@ -94,7 +110,11 @@ export const updateEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: updateEventInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event.change", { action: "update", ...ctx.data }, ); @@ -111,7 +131,11 @@ export const deleteEvent = adminAction( { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event.change", { action: "delete", ...ctx.data }, ); @@ -126,7 +150,11 @@ export const addEventPrize = adminAction( { permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event-prize.change", { action: "create", ...ctx.data }, ); @@ -141,7 +169,11 @@ export const deleteEventPrize = adminAction( { permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event-prize.change", { action: "delete", ...ctx.data }, ); @@ -156,7 +188,11 @@ export const addEventWinner = adminAction( { permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "event-winner.add", ctx.data, ); diff --git a/src/actions/people-support-moderation-wrappers.test.ts b/src/actions/people-support-moderation-wrappers.test.ts index 60d9e947..858a0753 100644 --- a/src/actions/people-support-moderation-wrappers.test.ts +++ b/src/actions/people-support-moderation-wrappers.test.ts @@ -11,12 +11,15 @@ function wrapper( options: { permission: string | readonly string[]; schema?: { - safeParse(value: unknown): - | { success: true; data: unknown } - | { success: false; error: unknown }; + safeParse( + value: unknown, + ): { success: true; data: unknown } | { success: false; error: unknown }; }; }, - handler: (context: { data: unknown; session: { user: typeof staff } }) => unknown, + handler: (context: { + data: unknown; + session: { user: typeof staff }; + }) => unknown, ) { registrations.push(options); return async (data: unknown) => { @@ -174,9 +177,10 @@ describe("legacy People support and moderation wrappers", () => { await call(replyHelpCenterTicket, { ticketId, content: " Handled " }); await call(closeHelpCenterTicket, { ticketId }); await call(reopenHelpCenterTicket, { ticketId }); - await expect( - call(liftBanFromHelpTicket, { ticketId }), - ).resolves.toEqual({ ok: true, data: { removed: 2, userId: 7 } }); + await expect(call(liftBanFromHelpTicket, { ticketId })).resolves.toEqual({ + ok: true, + data: { removed: 2, userId: 7 }, + }); expect(execute.mock.calls.map((entry) => entry[2])).toEqual([ { ticketId: "9007199254740993", content: "Handled" }, diff --git a/src/actions/polls.ts b/src/actions/polls.ts index 760a894e..c2a16a41 100644 --- a/src/actions/polls.ts +++ b/src/actions/polls.ts @@ -26,7 +26,11 @@ export const createPoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: createPollSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "poll.change", { action: "create", ...ctx.data }, ); @@ -43,7 +47,11 @@ export const updatePoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: updatePollInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "poll.change", { action: "update", ...ctx.data }, ); @@ -60,7 +68,11 @@ export const deletePoll = adminAction( { permission: PERMS.POLLS_EDIT, schema: deletePollInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "poll.change", { action: "delete", ...ctx.data }, ); @@ -75,7 +87,11 @@ export const addPollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "poll-question.change", { action: "create", ...ctx.data }, ); @@ -92,7 +108,11 @@ export const updatePollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "poll-question.change", { action: "update", ...ctx.data }, ); @@ -109,7 +129,11 @@ export const deletePollQuestion = adminAction( { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "poll-question.change", { action: "delete", ...ctx.data }, ); diff --git a/src/actions/prefixes.ts b/src/actions/prefixes.ts index b2b8c6de..a1e44807 100644 --- a/src/actions/prefixes.ts +++ b/src/actions/prefixes.ts @@ -24,23 +24,62 @@ const updatePrefixSchema = z.object({ }); 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()) }); +const removeBlacklistWordSchema = z.object({ + id: z.coerce.number().int().positive(), +}); +const updatePrefixSettingsSchema = z.object({ + settings: z.record(z.string(), z.string()), +}); -async function run(ctx: { data: Record; requestId: unknown; session: { user: { id: number } } }, operation: "prefix.change" | "prefix-blacklist.change" | "prefix-settings.update", input: Record) { +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 }, + { + 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 && result.error.code === "NOT_FOUND") + throw new ActionError("User not found"); if (!result.ok) throw new Error(result.error.messageKey); 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)); +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/ticket-templates.ts b/src/actions/ticket-templates.ts index e4b1e352..164fcd09 100644 --- a/src/actions/ticket-templates.ts +++ b/src/actions/ticket-templates.ts @@ -17,10 +17,7 @@ const templateSchema = z.object({ sortOrder: z.coerce.number().int().min(0).default(0), }); -async function execute( - staff: { readonly id: number }, - input: unknown, -) { +async function execute(staff: { readonly id: number }, input: unknown) { const result = await peopleMutationService.execute( createPeopleMutationInvocation(staff, createCorrelationId()), "ticket-template.change", diff --git a/src/actions/tickets.ts b/src/actions/tickets.ts index 6cc91950..8af60d5d 100644 --- a/src/actions/tickets.ts +++ b/src/actions/tickets.ts @@ -31,7 +31,9 @@ async function execute( ); if (!result.ok) { throw new ActionError( - result.error.code === "NOT_FOUND" ? "Ticket not found" : "Ticket update failed", + result.error.code === "NOT_FOUND" + ? "Ticket not found" + : "Ticket update failed", ); } } diff --git a/src/actions/translations.ts b/src/actions/translations.ts index 93cecfd8..919f0a16 100644 --- a/src/actions/translations.ts +++ b/src/actions/translations.ts @@ -8,11 +8,36 @@ 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[]]), + fileId: z.enum( + CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]], + ), data: z.record(z.string(), z.string()), }); @@ -20,7 +45,11 @@ export const saveTranslations = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "translation.cms.save", ctx.data, ); @@ -33,14 +62,20 @@ export const saveClientTranslations = adminAction( { permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema }, async (ctx) => { const result = await contentMutationService.execute( - { correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true }, + { + correlationId: String(ctx.requestId), + expectedActorId: Number(ctx.session.user.id), + legacy: true, + }, "translation.client.save", ctx.data, ); 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 : [], + 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 index 0cbe6314..b278fc68 100644 --- a/src/features/housekeeping/domains/content/commands/content-commands.test.ts +++ b/src/features/housekeeping/domains/content/commands/content-commands.test.ts @@ -1,10 +1,7 @@ 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"; +import { CONTENT_COMMAND_IDS, createContentCommands } from "./content-commands"; const expected = [ ["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT], @@ -41,11 +38,7 @@ const expected = [ PERMS.SETTINGS_EDIT, ], ["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT], - [ - "content.engagement.prefix.change", - "prefix.change", - PERMS.PREFIXES_EDIT, - ], + ["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT], [ "content.engagement.prefix-blacklist.change", "prefix-blacklist.change", diff --git a/src/features/housekeeping/domains/content/commands/content-commands.ts b/src/features/housekeeping/domains/content/commands/content-commands.ts index 1ae16a68..09d71c28 100644 --- a/src/features/housekeeping/domains/content/commands/content-commands.ts +++ b/src/features/housekeeping/domains/content/commands/content-commands.ts @@ -14,33 +14,93 @@ 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-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.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.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.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.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.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.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], + [ + "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( diff --git a/src/features/housekeeping/domains/content/pages/brand.tsx b/src/features/housekeeping/domains/content/pages/brand.tsx index 6b6fe5ca..1e0b2cd4 100644 --- a/src/features/housekeeping/domains/content/pages/brand.tsx +++ b/src/features/housekeeping/domains/content/pages/brand.tsx @@ -2,27 +2,142 @@ 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"; +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 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 ; + 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-page-frame.tsx b/src/features/housekeeping/domains/content/pages/content-page-frame.tsx index e6579273..77017b47 100644 --- a/src/features/housekeeping/domains/content/pages/content-page-frame.tsx +++ b/src/features/housekeeping/domains/content/pages/content-page-frame.tsx @@ -1,7 +1,5 @@ import type { ReactNode } from "react"; -import type { - HousekeepingResult, -} from "../../../foundation/contracts"; +import type { HousekeepingResult } from "../../../foundation/contracts"; import type { ContentQueryData } from "../queries/content-queries"; export interface ContentPageProps { @@ -65,7 +63,9 @@ export function ContentPageFrame({ } export function parseContentListInput( - searchParams: Readonly>, + searchParams: Readonly< + Record + >, ) { const first = (value: string | readonly string[] | undefined) => Array.isArray(value) ? value[0] : value; diff --git a/src/features/housekeeping/domains/content/pages/editorial.tsx b/src/features/housekeeping/domains/content/pages/editorial.tsx index 3acc957c..b7306cfa 100644 --- a/src/features/housekeeping/domains/content/pages/editorial.tsx +++ b/src/features/housekeeping/domains/content/pages/editorial.tsx @@ -8,7 +8,11 @@ import { parseContentListInput, } from "./content-page-frame"; -export function ContentEditorialPage({ context, result, routeId }: ContentPageProps) { +export function ContentEditorialPage({ + context, + result, + routeId, +}: ContentPageProps) { const canNews = context.has(PERMS.NEWS_EDIT); const canPages = context.has(PERMS.PAGES_EDIT); const canSettings = context.has(PERMS.SETTINGS_EDIT); @@ -23,11 +27,25 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr ) : null} @@ -36,7 +54,15 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr commandId="content.editorial.navigation.update" buttonLabel="Save navigation" input={{}} - fields={[{ name: "items", label: "Navigation JSON", type: "json", required: true, maxLength: 20_000 }]} + fields={[ + { + name: "items", + label: "Navigation JSON", + type: "json", + required: true, + maxLength: 20_000, + }, + ]} /> ) : null} {canPages && routeId === "content.editorial.tags" ? ( @@ -46,7 +72,13 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr input={{ action: "update" }} fields={[ { name: "id", label: "Tag ID", type: "identifier" }, - { name: "name", label: "Name", type: "text", required: true, maxLength: 100 }, + { + name: "name", + label: "Name", + type: "text", + required: true, + maxLength: 100, + }, ]} /> ) : null} @@ -57,8 +89,19 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr input={{ action: "update" }} fields={[ { name: "id", label: "Box ID", type: "identifier" }, - { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }, - { name: "content", label: "Content", type: "textarea", maxLength: 20_000 }, + { + name: "title", + label: "Title", + type: "text", + required: true, + maxLength: 255, + }, + { + name: "content", + label: "Content", + type: "textarea", + maxLength: 20_000, + }, ]} /> ) : null} @@ -75,5 +118,11 @@ export async function renderContentEditorialPage(input: HousekeepingPageInput) { params: input.match.params, list: parseContentListInput(input.searchParams ?? {}), }); - return ; + return ( + + ); } diff --git a/src/features/housekeeping/domains/content/pages/help.tsx b/src/features/housekeeping/domains/content/pages/help.tsx index 57d955ca..cdcfdab9 100644 --- a/src/features/housekeeping/domains/content/pages/help.tsx +++ b/src/features/housekeeping/domains/content/pages/help.tsx @@ -2,18 +2,96 @@ 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"; +import { + ContentPageFrame, + type ContentPageProps, + parseContentListInput, +} from "./content-page-frame"; -export function ContentHelpPage({ context, result, routeId }: ContentPageProps) { - const forms = context.has(PERMS.PAGES_EDIT) ?
- {routeId?.includes("question") ? : null} - {routeId === "content.help.email-templates" ? : null} -
: null; - return ; +export function ContentHelpPage({ + context, + result, + routeId, +}: ContentPageProps) { + const forms = context.has(PERMS.PAGES_EDIT) ? ( +
+ {routeId?.includes("question") ? ( + + ) : null} + {routeId === "content.help.email-templates" ? ( + + ) : null} +
+ ) : null; + return ( + + ); } export async function renderContentHelpPage(input: HousekeepingPageInput) { const routeId = input.match.routeId as ContentPageProps["routeId"]; - const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.help.questions", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) }); - return ; + const result = await contentQuery.run(input.context, { + routeId: routeId ?? "content.help.questions", + params: input.match.params, + list: parseContentListInput(input.searchParams ?? {}), + }); + return ( + + ); } diff --git a/src/features/housekeeping/domains/content/pages/media.tsx b/src/features/housekeeping/domains/content/pages/media.tsx index e8650bf6..06dbc0bc 100644 --- a/src/features/housekeeping/domains/content/pages/media.tsx +++ b/src/features/housekeeping/domains/content/pages/media.tsx @@ -2,9 +2,17 @@ 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"; +import { + ContentPageFrame, + type ContentPageProps, + parseContentListInput, +} from "./content-page-frame"; -export function ContentMediaPage({ context, result, routeId }: ContentPageProps) { +export function ContentMediaPage({ + context, + result, + routeId, +}: ContentPageProps) { const canPages = context.has(PERMS.PAGES_EDIT); const canBanners = context.has(PERMS.BANNERS_EDIT); return ( @@ -12,21 +20,108 @@ export function ContentMediaPage({ context, result, routeId }: ContentPageProps) title="Media" description="Manage photos, uploaded media, banners, and advertisements." result={result} - forms={
- {canPages && routeId === "content.media.photos" ? : null} - {canPages && routeId === "content.media.library" ? <> - - - : null} - {canBanners && routeId === "content.media.banners" ? : null} - {canPages && routeId?.includes("ad") ? : null} -
} + forms={ +
+ {canPages && routeId === "content.media.photos" ? ( + + ) : null} + {canPages && routeId === "content.media.library" ? ( + <> + + + + ) : null} + {canBanners && routeId === "content.media.banners" ? ( + + ) : null} + {canPages && routeId?.includes("ad") ? ( + + ) : null} +
+ } /> ); } export async function renderContentMediaPage(input: HousekeepingPageInput) { const routeId = input.match.routeId as ContentPageProps["routeId"]; - const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.media.photos", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) }); - return ; + const result = await contentQuery.run(input.context, { + routeId: routeId ?? "content.media.photos", + params: input.match.params, + list: parseContentListInput(input.searchParams ?? {}), + }); + return ( + + ); } diff --git a/src/features/housekeeping/domains/content/routes.test.ts b/src/features/housekeeping/domains/content/routes.test.ts index 0eb80848..d321dfa0 100644 --- a/src/features/housekeeping/domains/content/routes.test.ts +++ b/src/features/housekeeping/domains/content/routes.test.ts @@ -12,7 +12,11 @@ import { } from "./routes"; const expected = [ - ["content.editorial.articles", "/ase/content/editorial/articles", "editorial"], + [ + "content.editorial.articles", + "/ase/content/editorial/articles", + "editorial", + ], [ "content.editorial.article-create", "/ase/content/editorial/articles/new", @@ -57,16 +61,8 @@ const expected = [ "engagement", ], ["content.help.questions", "/ase/content/help/questions", "help"], - [ - "content.help.question-create", - "/ase/content/help/questions/new", - "help", - ], - [ - "content.help.question-detail", - "/ase/content/help/questions/:id", - "help", - ], + ["content.help.question-create", "/ase/content/help/questions/new", "help"], + ["content.help.question-detail", "/ase/content/help/questions/:id", "help"], ["content.editorial.tags", "/ase/content/editorial/tags", "editorial"], [ "content.engagement.prefixes", @@ -78,14 +74,14 @@ const expected = [ "/ase/content/editorial/writeable-boxes", "editorial", ], - [ - "content.help.email-templates", - "/ase/content/help/email-templates", - "help", - ], + ["content.help.email-templates", "/ase/content/help/email-templates", "help"], ["content.brand.theme", "/ase/content/brand/theme", "brand"], ["content.brand.favicon", "/ase/content/brand/favicon", "brand"], - ["content.localization.overview", "/ase/content/localization", "localization"], + [ + "content.localization.overview", + "/ase/content/localization", + "localization", + ], [ "content.localization.client", "/ase/content/localization/client", diff --git a/src/features/housekeeping/domains/content/routes.ts b/src/features/housekeeping/domains/content/routes.ts index ea590b37..e040ec44 100644 --- a/src/features/housekeeping/domains/content/routes.ts +++ b/src/features/housekeeping/domains/content/routes.ts @@ -64,9 +64,11 @@ function contentRoute( } export const CONTENT_ROUTES = [ - contentRoute("content.editorial.articles", "/ase/content/editorial/articles", [ - PERMS.NEWS_VIEW, - ]), + contentRoute( + "content.editorial.articles", + "/ase/content/editorial/articles", + [PERMS.NEWS_VIEW], + ), contentRoute( "content.editorial.article-create", "/ase/content/editorial/articles/new", @@ -95,11 +97,9 @@ export const CONTENT_ROUTES = [ contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [ PERMS.PAGES_VIEW, ]), - contentRoute( - "content.engagement.events", - "/ase/content/engagement/events", - [PERMS.EVENTS_VIEW], - ), + contentRoute("content.engagement.events", "/ase/content/engagement/events", [ + PERMS.EVENTS_VIEW, + ]), contentRoute( "content.engagement.event-create", "/ase/content/engagement/events/create", @@ -115,11 +115,9 @@ export const CONTENT_ROUTES = [ "/ase/content/engagement/events/:id", [PERMS.EVENTS_EDIT], ), - contentRoute( - "content.engagement.polls", - "/ase/content/engagement/polls", - [PERMS.POLLS_VIEW], - ), + contentRoute("content.engagement.polls", "/ase/content/engagement/polls", [ + PERMS.POLLS_VIEW, + ]), contentRoute( "content.engagement.poll-create", "/ase/content/engagement/polls/create", @@ -167,11 +165,9 @@ export const CONTENT_ROUTES = [ contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [ PERMS.SETTINGS_VIEW, ]), - contentRoute( - "content.localization.overview", - "/ase/content/localization", - [PERMS.SETTINGS_VIEW], - ), + contentRoute("content.localization.overview", "/ase/content/localization", [ + PERMS.SETTINGS_VIEW, + ]), contentRoute( "content.localization.client", "/ase/content/localization/client", diff --git a/src/features/housekeeping/domains/content/search-production.ts b/src/features/housekeeping/domains/content/search-production.ts index 534488a0..5130e722 100644 --- a/src/features/housekeeping/domains/content/search-production.ts +++ b/src/features/housekeeping/domains/content/search-production.ts @@ -11,12 +11,8 @@ import type { ContentSearchCandidate } from "./search"; type ContentSearchKind = "articles" | "events" | "media" | "help"; const SEARCH_ROUTES = { - articles: [ - ["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)], - ], - events: [ - ["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)], - ], + articles: [["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)]], + events: [["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)]], media: [ ["content.media.photos", anyCapability(PERMS.PAGES_VIEW)], ["content.media.library", anyCapability(PERMS.PAGES_VIEW)], diff --git a/src/features/housekeeping/domains/content/services/mutations-production.ts b/src/features/housekeeping/domains/content/services/mutations-production.ts index 0734b9ed..72d26b6a 100644 --- a/src/features/housekeeping/domains/content/services/mutations-production.ts +++ b/src/features/housekeeping/domains/content/services/mutations-production.ts @@ -48,7 +48,9 @@ export const CONTENT_MIXED_OPERATIONS = [ type TransactionToken = unknown; export interface ContentProductionMutationDependencies { - transaction(run: (transaction: TransactionToken) => Promise): Promise; + transaction( + run: (transaction: TransactionToken) => Promise, + ): Promise; writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise; executeOperation( operation: ContentMutationOperation, @@ -111,7 +113,8 @@ async function writeOutcomeOrMarkUnavailable( } catch { const completion = { status: "partial" as const, - external: outcome === "partial" ? ("failed" as const) : ("completed" as const), + external: + outcome === "partial" ? ("failed" as const) : ("completed" as const), audit: "unavailable" as const, }; return { ...snapshot, completion }; diff --git a/src/features/housekeeping/domains/content/services/mutations.test.ts b/src/features/housekeeping/domains/content/services/mutations.test.ts index af3cb365..78f65bc5 100644 --- a/src/features/housekeeping/domains/content/services/mutations.test.ts +++ b/src/features/housekeeping/domains/content/services/mutations.test.ts @@ -24,9 +24,8 @@ function context( describe("Content mutation service authority", () => { it("rehydrates server authority and rejects forged actor identity", async () => { const adapter = { execute: vi.fn() }; - const service = createContentMutationService( - adapter, - async () => context([PERMS.NEWS_EDIT], 42), + const service = createContentMutationService(adapter, async () => + context([PERMS.NEWS_EDIT], 42), ); const result = await service.execute( @@ -44,9 +43,8 @@ describe("Content mutation service authority", () => { it("requires the exact operation ACL even after dispatcher authorization", async () => { const adapter = { execute: vi.fn() }; - const service = createContentMutationService( - adapter, - async () => context([PERMS.NEWS_EDIT]), + const service = createContentMutationService(adapter, async () => + context([PERMS.NEWS_EDIT]), ); const result = await service.execute( @@ -67,9 +65,8 @@ describe("Content mutation service authority", () => { before: null, after: { actorId: mutationContext.capability.actor.id }, })); - const service = createContentMutationService( - { execute }, - async () => context(Object.values(PERMS)), + const service = createContentMutationService({ execute }, async () => + context(Object.values(PERMS)), ); const invocation = createContentMutationInvocation( { id: 42 }, diff --git a/src/features/housekeeping/domains/content/services/mutations.ts b/src/features/housekeeping/domains/content/services/mutations.ts index 6b71f19d..a7c7b082 100644 --- a/src/features/housekeeping/domains/content/services/mutations.ts +++ b/src/features/housekeeping/domains/content/services/mutations.ts @@ -125,9 +125,7 @@ const OPERATION_PERMISSION = Object.freeze({ "translation.emulator.save": PERMS.SETTINGS_EDIT, } satisfies Record); -export function contentMutationCapability( - operation: ContentMutationOperation, -) { +export function contentMutationCapability(operation: ContentMutationOperation) { return anyCapability(OPERATION_PERMISSION[operation]); } diff --git a/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts b/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts index cc8809a7..d20074ed 100644 --- a/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts +++ b/src/features/housekeeping/domains/people/commands/moderation-commands.test.ts @@ -47,7 +47,9 @@ describe("People moderation commands", () => { const command = commands.find((entry) => entry.id === id); if (!command) throw new Error("command missing"); expect(command.requiresReason).toBe(true); - expect(confirmHousekeepingCommand(command, " ", "moderation")).toMatchObject({ + expect( + confirmHousekeepingCommand(command, " ", "moderation"), + ).toMatchObject({ ok: false, error: { code: "VALIDATION" }, }); @@ -63,9 +65,7 @@ describe("People moderation commands", () => { const created = createModerationCommands({ execute, }) as unknown as readonly HousekeepingCommand[]; - const command = created.find( - (entry) => entry.id === "people.cfh.sanction", - ); + const command = created.find((entry) => entry.id === "people.cfh.sanction"); if (!command) throw new Error("command missing"); expect( command.input.safeParse({ @@ -106,16 +106,26 @@ describe("People moderation commands", () => { it.each([ ["people.cfh.resolve", { ticketId: 9, state: 2 }, "cfh.resolve"], - ["people.ban.create", { userId: 7, reason: "spam", hours: 24, type: "account" }, "ban.create"], + [ + "people.ban.create", + { userId: 7, reason: "spam", hours: 24, type: "account" }, + "ban.create", + ], ["people.ban.lift", { id: 12 }, "ban.lift"], - ["people.moderation.action", { action: "alert", userId: 7, message: "Stop" }, "moderation.action"], + [ + "people.moderation.action", + { action: "alert", userId: 7, message: "Stop" }, + "moderation.action", + ], ] as const)("delegates %s to %s", async (id, input, operation) => { const execute = vi.fn(async (invocation) => ({ ok: true as const, data: { before: null, after: null }, correlationId: invocation.correlationId, })); - const created = createModerationCommands({ execute }) as unknown as readonly HousekeepingCommand[]; + const created = createModerationCommands({ + execute, + }) as unknown as readonly HousekeepingCommand[]; const command = created.find((entry) => entry.id === id); if (!command) throw new Error("command missing"); const parsed = command.input.parse(input); diff --git a/src/features/housekeeping/domains/people/commands/moderation-commands.ts b/src/features/housekeeping/domains/people/commands/moderation-commands.ts index 9f25a0d4..8e810c35 100644 --- a/src/features/housekeeping/domains/people/commands/moderation-commands.ts +++ b/src/features/housekeeping/domains/people/commands/moderation-commands.ts @@ -128,5 +128,6 @@ export function createModerationCommands( ] as const; } -export const MODERATION_COMMANDS = - createModerationCommands(peopleMutationService); +export const MODERATION_COMMANDS = createModerationCommands( + peopleMutationService, +); diff --git a/src/features/housekeeping/domains/people/commands/support-commands.test.ts b/src/features/housekeeping/domains/people/commands/support-commands.test.ts index 912639ee..a9793a92 100644 --- a/src/features/housekeeping/domains/people/commands/support-commands.test.ts +++ b/src/features/housekeeping/domains/people/commands/support-commands.test.ts @@ -58,47 +58,78 @@ describe("People support commands", () => { }); it.each([ - ["people.ticket.reply", { ticketId: 7, message: "Handled" }, "ticket.reply"], + [ + "people.ticket.reply", + { ticketId: 7, message: "Handled" }, + "ticket.reply", + ], ["people.ticket.assign", { ticketId: 7, assigneeId: 42 }, "ticket.assign"], - ["people.ticket.status", { ticketId: 7, status: "closed" }, "ticket.status"], - ["people.ticket-template.change", { action: "create", title: "Greeting", content: "Hello" }, "ticket-template.change"], - ["people.help-ticket.reply", { ticketId: "9007199254740993", content: "Handled" }, "help-ticket.reply"], - ["people.help-ticket.status", { ticketId: "9007199254740993", status: "close" }, "help-ticket.status"], - ["people.help-ticket.status", { ticketId: "9007199254740993", status: "reopen" }, "help-ticket.status"], - ["people.help-ticket.unban", { ticketId: "9007199254740993" }, "help-ticket.unban"], - ] as const)("delegates %s to the redirect-free %s operation", async (id, input, operation) => { - const execute = vi.fn(async (invocation) => ({ - ok: true as const, - data: { before: null, after: null }, - correlationId: invocation.correlationId, - })); - const created = createSupportCommands({ - execute, - }) as unknown as readonly HousekeepingCommand[]; - const command = created.find((entry) => entry.id === id); - if (!command) throw new Error("command missing"); - const parsed = command.input.parse(input); - await command.execute( - { - capability: { - actor: { id: 42, username: "mod", rank: 4 }, - isSuperAdmin: false, - has: () => false, - hasAny: () => true, - hasAll: () => false, + [ + "people.ticket.status", + { ticketId: 7, status: "closed" }, + "ticket.status", + ], + [ + "people.ticket-template.change", + { action: "create", title: "Greeting", content: "Hello" }, + "ticket-template.change", + ], + [ + "people.help-ticket.reply", + { ticketId: "9007199254740993", content: "Handled" }, + "help-ticket.reply", + ], + [ + "people.help-ticket.status", + { ticketId: "9007199254740993", status: "close" }, + "help-ticket.status", + ], + [ + "people.help-ticket.status", + { ticketId: "9007199254740993", status: "reopen" }, + "help-ticket.status", + ], + [ + "people.help-ticket.unban", + { ticketId: "9007199254740993" }, + "help-ticket.unban", + ], + ] as const)( + "delegates %s to the redirect-free %s operation", + async (id, input, operation) => { + const execute = vi.fn(async (invocation) => ({ + ok: true as const, + data: { before: null, after: null }, + correlationId: invocation.correlationId, + })); + const created = createSupportCommands({ + execute, + }) as unknown as readonly HousekeepingCommand[]; + const command = created.find((entry) => entry.id === id); + if (!command) throw new Error("command missing"); + const parsed = command.input.parse(input); + await command.execute( + { + capability: { + actor: { id: 42, username: "mod", rank: 4 }, + isSuperAdmin: false, + has: () => false, + hasAny: () => true, + hasAll: () => false, + }, + correlationId: "support-red", + ipAddress: "198.51.100.8", }, - correlationId: "support-red", - ipAddress: "198.51.100.8", - }, - parsed, - ); - expect(execute).toHaveBeenCalledWith( - expect.objectContaining({ - correlationId: "support-red", - expectedActorId: 42, - }), - operation, - parsed, - ); - }); + parsed, + ); + expect(execute).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "support-red", + expectedActorId: 42, + }), + operation, + parsed, + ); + }, + ); }); diff --git a/src/features/housekeeping/domains/people/commands/support-commands.ts b/src/features/housekeeping/domains/people/commands/support-commands.ts index b0f0fc54..1bca7433 100644 --- a/src/features/housekeeping/domains/people/commands/support-commands.ts +++ b/src/features/housekeeping/domains/people/commands/support-commands.ts @@ -72,7 +72,10 @@ export function createSupportCommands( id: "people.ticket.assign", operation: "ticket.assign", capability: ticketEdit, - input: z.object({ ticketId: positiveId, assigneeId: positiveId.nullable() }), + input: z.object({ + ticketId: positiveId, + assigneeId: positiveId.nullable(), + }), }), command(service, { id: "people.ticket.status", diff --git a/src/features/housekeeping/domains/people/inbox.ts b/src/features/housekeeping/domains/people/inbox.ts index e52beb86..c6117705 100644 --- a/src/features/housekeeping/domains/people/inbox.ts +++ b/src/features/housekeeping/domains/people/inbox.ts @@ -127,27 +127,39 @@ const productionAdapters: PeopleInboxAdapters = { routeId: "people.support.tickets", list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" }, }); - if (!result.ok || result.data.kind !== "tickets") throw new Error("tickets"); - const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW); + if (!result.ok || result.data.kind !== "tickets") + throw new Error("tickets"); + const capability = anyCapability( + PERMS.TICKETS_VIEW, + PERMS.MOD_TICKETS_VIEW, + ); return result.data.page.items.flatMap((ticket) => { const date = time(ticket.updatedAt); return date === null ? [] - : [{ - sourceId: "people.tickets", - itemId: String(ticket.id), - deduplicationKey: `${ticket.source}-ticket:${ticket.id}`, - domain: "people" as const, - capability, - severity: ticket.priority === "urgent" ? "critical" as const : "info" as const, - priority: ticket.priority === "urgent" ? "critical" as const : "normal" as const, - ...date, - state: ticket.status, - titleKey: "pages.housekeeping.items.ticket", - context: { subject: ticket.subject }, - href: ticket.href, - actions: [], - }]; + : [ + { + sourceId: "people.tickets", + itemId: String(ticket.id), + deduplicationKey: `${ticket.source}-ticket:${ticket.id}`, + domain: "people" as const, + capability, + severity: + ticket.priority === "urgent" + ? ("critical" as const) + : ("info" as const), + priority: + ticket.priority === "urgent" + ? ("critical" as const) + : ("normal" as const), + ...date, + state: ticket.status, + titleKey: "pages.housekeeping.items.ticket", + context: { subject: ticket.subject }, + href: ticket.href, + actions: [], + }, + ]; }); }, async helpTickets(context) { @@ -155,25 +167,33 @@ const productionAdapters: PeopleInboxAdapters = { routeId: "people.support.help-tickets", list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" }, }); - if (!result.ok || result.data.kind !== "help-tickets") throw new Error("help"); - const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW); + if (!result.ok || result.data.kind !== "help-tickets") + throw new Error("help"); + const capability = anyCapability( + PERMS.TICKETS_VIEW, + PERMS.MOD_TICKETS_VIEW, + ); return result.data.page.items.flatMap((ticket) => { const date = time(ticket.updatedAt); - return date === null ? [] : [{ - sourceId: "people.help-tickets", - itemId: ticket.id, - deduplicationKey: `help-ticket:${ticket.id}`, - domain: "people" as const, - capability, - severity: "info" as const, - priority: "normal" as const, - ...date, - state: ticket.open ? "open" : "closed", - titleKey: "pages.housekeeping.items.helpTicket", - context: { title: ticket.title }, - href: ticket.href, - actions: [], - }]; + return date === null + ? [] + : [ + { + sourceId: "people.help-tickets", + itemId: ticket.id, + deduplicationKey: `help-ticket:${ticket.id}`, + domain: "people" as const, + capability, + severity: "info" as const, + priority: "normal" as const, + ...date, + state: ticket.open ? "open" : "closed", + titleKey: "pages.housekeeping.items.helpTicket", + context: { title: ticket.title }, + href: ticket.href, + actions: [], + }, + ]; }); }, async cfh(context) { @@ -185,21 +205,25 @@ const productionAdapters: PeopleInboxAdapters = { const capability = anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW); return result.data.page.items.flatMap((ticket) => { const date = time(ticket.createdAt); - return date === null ? [] : [{ - sourceId: "people.cfh", - itemId: String(ticket.id), - deduplicationKey: `cfh:${ticket.id}`, - domain: "people" as const, - capability, - severity: "warning" as const, - priority: "high" as const, - ...date, - state: String(ticket.state), - titleKey: "pages.housekeeping.items.cfh", - context: { issue: ticket.issue }, - href: ticket.href, - actions: [], - }]; + return date === null + ? [] + : [ + { + sourceId: "people.cfh", + itemId: String(ticket.id), + deduplicationKey: `cfh:${ticket.id}`, + domain: "people" as const, + capability, + severity: "warning" as const, + priority: "high" as const, + ...date, + state: String(ticket.state), + titleKey: "pages.housekeeping.items.cfh", + context: { issue: ticket.issue }, + href: ticket.href, + actions: [], + }, + ]; }); }, async activeBans(context) { @@ -211,21 +235,25 @@ const productionAdapters: PeopleInboxAdapters = { const capability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW); return result.data.page.items.flatMap((ban) => { const date = time(ban.createdAt); - return date === null ? [] : [{ - sourceId: "people.active-bans", - itemId: String(ban.id), - deduplicationKey: `ban:${ban.id}`, - domain: "people" as const, - capability, - severity: "warning" as const, - priority: "normal" as const, - ...date, - state: "active", - titleKey: "pages.housekeeping.items.activeBan", - context: { userId: ban.userId, reason: ban.reason }, - href: `/ase/people/users/${ban.userId}` as const, - actions: [], - }]; + return date === null + ? [] + : [ + { + sourceId: "people.active-bans", + itemId: String(ban.id), + deduplicationKey: `ban:${ban.id}`, + domain: "people" as const, + capability, + severity: "warning" as const, + priority: "normal" as const, + ...date, + state: "active", + titleKey: "pages.housekeeping.items.activeBan", + context: { userId: ban.userId, reason: ban.reason }, + href: `/ase/people/users/${ban.userId}` as const, + actions: [], + }, + ]; }); }, }; diff --git a/src/features/housekeeping/domains/people/pages/cfh-detail.tsx b/src/features/housekeeping/domains/people/pages/cfh-detail.tsx index 81f068bb..0db00e28 100644 --- a/src/features/housekeeping/domains/people/pages/cfh-detail.tsx +++ b/src/features/housekeeping/domains/people/pages/cfh-detail.tsx @@ -27,46 +27,82 @@ export function PeopleCfhDetailPage({ context, result }: Props) { result={result} isEmpty={(data) => data.kind !== "cfh-detail"} > - {(data) => data.kind === "cfh-detail" ? ( -
-

CFH #{data.ticket.id}

-

{data.ticket.issue}

-

Reporter: {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}

-

Reported: {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}

- {canEdit ? ( - <> - - ({ value, label: value }))}, - {name: "duration", label: "Duration", type: "number", min: 0, max: 525600, defaultValue: 0}, - {name: "message", label: "Message", type: "text", maxLength: 500}, - ]} - requiresReason - includeReasonInInput - /> - - ) : null} -
- ) : null} + {(data) => + data.kind === "cfh-detail" ? ( +
+

CFH #{data.ticket.id}

+

{data.ticket.issue}

+

+ Reporter:{" "} + {data.ticket.senderUsername ?? `#${data.ticket.senderId}`} +

+

+ Reported:{" "} + {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`} +

+ {canEdit ? ( + <> + + ({ + value, + label: value, + })), + }, + { + name: "duration", + label: "Duration", + type: "number", + min: 0, + max: 525600, + defaultValue: 0, + }, + { + name: "message", + label: "Message", + type: "text", + maxLength: 500, + }, + ]} + requiresReason + includeReasonInInput + /> + + ) : null} +
+ ) : null + } ); } export async function renderPeopleCfhDetailPage(input: HousekeepingPageInput) { const id = Number(input.match.params.id); - const result = Number.isSafeInteger(id) && id > 0 - ? await peopleModerationQuery.run(input.context, { - routeId: "people.moderation.cfh-detail", - id, - }) - : fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId()); + const result = + Number.isSafeInteger(id) && id > 0 + ? await peopleModerationQuery.run(input.context, { + routeId: "people.moderation.cfh-detail", + id, + }) + : fail( + "VALIDATION", + "errors.housekeeping.validation", + createCorrelationId(), + ); return ; } diff --git a/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx b/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx index a0cb8a57..a11cb97a 100644 --- a/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx +++ b/src/features/housekeeping/domains/people/pages/help-ticket-detail.tsx @@ -28,44 +28,67 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) { result={result} isEmpty={(data) => data.kind !== "help-ticket"} > - {(data) => data.kind === "help-ticket" ? ( -
-

{data.ticket.title}

{data.ticket.content}

-
    - {data.ticket.replies.map((reply) => ( -
  • {reply.username ?? `User #${reply.userId}`}: {reply.content}
  • - ))} -
- {canEdit ? ( - <> + {(data) => + data.kind === "help-ticket" ? ( +
+
+

{data.ticket.title}

+

{data.ticket.content}

+
+
    + {data.ticket.replies.map((reply) => ( +
  • + {reply.username ?? `User #${reply.userId}`}:{" "} + {reply.content} +
  • + ))} +
+ {canEdit ? ( + <> + + + + ) : null} + {data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? ( - - - ) : null} - {data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? ( - - ) : null} -
- ) : null} + ) : null} +
+ ) : null + } ); } -export async function renderPeopleHelpTicketDetailPage(input: HousekeepingPageInput) { +export async function renderPeopleHelpTicketDetailPage( + input: HousekeepingPageInput, +) { const raw = input.match.params.id ?? ""; let id: string | null = null; try { @@ -78,6 +101,10 @@ export async function renderPeopleHelpTicketDetailPage(input: HousekeepingPageIn routeId: "people.support.help-ticket-detail", id, }) - : fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId()); + : fail( + "VALIDATION", + "errors.housekeeping.validation", + createCorrelationId(), + ); return ; } diff --git a/src/features/housekeeping/domains/people/pages/moderation.tsx b/src/features/housekeeping/domains/people/pages/moderation.tsx index 5faa1891..e1685f60 100644 --- a/src/features/housekeeping/domains/people/pages/moderation.tsx +++ b/src/features/housekeeping/domains/people/pages/moderation.tsx @@ -29,12 +29,38 @@ function QuickAction() { buttonLabel="Run moderation action" input={{}} fields={[ - {name: "action", label: "Action", type: "select", options: ["kick", "mute", "unmute", "alert", "room-kick", "broadcast"].map((value) => ({ value, label: value }))}, - {name: "userId", label: "User ID", type: "number", min: 1}, - {name: "roomId", label: "Room ID", type: "number", min: 1}, - {name: "duration", label: "Duration", type: "number", min: 0, max: 525600}, - {name: "message", label: "Message", type: "text", maxLength: 500}, - {name: "type", label: "Audience", type: "select", options: [{value: "hotel", label: "hotel"}, {value: "staff", label: "staff"}]}, + { + name: "action", + label: "Action", + type: "select", + options: [ + "kick", + "mute", + "unmute", + "alert", + "room-kick", + "broadcast", + ].map((value) => ({ value, label: value })), + }, + { name: "userId", label: "User ID", type: "number", min: 1 }, + { name: "roomId", label: "Room ID", type: "number", min: 1 }, + { + name: "duration", + label: "Duration", + type: "number", + min: 0, + max: 525600, + }, + { name: "message", label: "Message", type: "text", maxLength: 500 }, + { + name: "type", + label: "Audience", + type: "select", + options: [ + { value: "hotel", label: "hotel" }, + { value: "staff", label: "staff" }, + ], + }, ]} /> ); @@ -50,50 +76,115 @@ export function PeopleModerationPage({ context, result }: Props) { isEmpty={isEmpty} > {(data) => { - if (data.kind === "overview") return ( -
-
- {Object.entries(data.snapshot).map(([label, value]) => ( -
{label}
{value}
+ if (data.kind === "overview") + return ( +
+
+ {Object.entries(data.snapshot).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ {canAct ? : null} +
+ ); + if (data.kind === "cfh") + return ( +
    + {data.page.items.map((ticket) => ( +
  • + CFH #{ticket.id} · {ticket.issue} +
  • ))} -
- {canAct ? : null} -
- ); - if (data.kind === "cfh") return ( - - ); - if (data.kind === "bans") return ( -
- {context.has(PERMS.USERS_BAN) ? ( - ({value, label: value}))}, - ]} - requiresReason - includeReasonInInput - /> - ) : null} -
    {data.page.items.map((ban) => ( -
  • - User #{ban.userId}: {ban.reason} - {context.has(PERMS.USERS_BAN) ? ( - - ) : null} -
  • - ))}
-
- ); - if (data.kind === "ip-rules") return
{JSON.stringify({blacklist: data.blacklist, whitelist: data.whitelist}, null, 2)}
; - if (data.kind === "vpn") return
    {data.settings.map((setting) =>
  • {setting.key}: {setting.value}
  • )}
; - if (data.kind === "word-filter") return
    {data.page.items.map((entry) =>
  • {entry.word}
  • )}
; + + ); + if (data.kind === "bans") + return ( +
+ {context.has(PERMS.USERS_BAN) ? ( + ({ value, label: value }), + ), + }, + ]} + requiresReason + includeReasonInInput + /> + ) : null} +
    + {data.page.items.map((ban) => ( +
  • + User #{ban.userId}: {ban.reason} + {context.has(PERMS.USERS_BAN) ? ( + + ) : null} +
  • + ))} +
+
+ ); + if (data.kind === "ip-rules") + return ( +
+							{JSON.stringify(
+								{ blacklist: data.blacklist, whitelist: data.whitelist },
+								null,
+								2,
+							)}
+						
+ ); + if (data.kind === "vpn") + return ( +
    + {data.settings.map((setting) => ( +
  • + {setting.key}: {setting.value} +
  • + ))} +
+ ); + if (data.kind === "word-filter") + return ( +
    + {data.page.items.map((entry) => ( +
  • {entry.word}
  • + ))} +
+ ); return null; }} @@ -118,6 +209,10 @@ export async function renderPeopleModerationPage(input: HousekeepingPageInput) { routeId: queryRoute, list: parsePeopleListInput(input.searchParams ?? {}), }) - : fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId()); + : fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + createCorrelationId(), + ); return ; } diff --git a/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx b/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx index 99ed9428..4efa3cdd 100644 --- a/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx +++ b/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx @@ -288,9 +288,9 @@ describe("People primary route registration", () => { HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId), ).toEqual(expect.arrayContaining(expected)); expect( - ( - HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[] - ).flatMap((manifest) => manifest.routes.map((route) => route.id)), + (HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[]).flatMap( + (manifest) => manifest.routes.map((route) => route.id), + ), ).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId)); }); diff --git a/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx b/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx index 06e68e39..628dee67 100644 --- a/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx +++ b/src/features/housekeeping/domains/people/pages/people-support-pages.test.tsx @@ -7,10 +7,7 @@ import { PeopleCfhDetailPage } from "./cfh-detail"; import { PeopleHelpTicketDetailPage } from "./help-ticket-detail"; import { PeopleModerationPage } from "./moderation"; import { submitPeopleCommandForm } from "./people-command-form"; -import { - PeopleSupportPage, - ticketTemplateUpdateSubmission, -} from "./support"; +import { PeopleSupportPage, ticketTemplateUpdateSubmission } from "./support"; import { PeopleTicketDetailPage } from "./ticket-detail"; const executeHousekeepingCommand = vi.hoisted(() => vi.fn()); @@ -38,17 +35,19 @@ describe("People support/moderation pages", () => { { kind: "tickets" as const, page: { - items: [{ - source: "cms" as const, - id: 7, - subject: "Need help", - status: "open", - priority: "normal", - creatorId: 9, - creatorUsername: "visitor", - updatedAt: null, - href: "/ase/people/support/tickets/7" as const, - }], + items: [ + { + source: "cms" as const, + id: 7, + subject: "Need help", + status: "open", + priority: "normal", + creatorId: 9, + creatorUsername: "visitor", + updatedAt: null, + href: "/ase/people/support/tickets/7" as const, + }, + ], total: 1, pageSize: 20, offset: 0, @@ -90,7 +89,9 @@ describe("People support/moderation pages", () => { />, ); expect(detail).toContain('data-housekeeping-command="people.ticket.reply"'); - expect(detail).toContain('data-housekeeping-command="people.ticket.assign"'); + expect(detail).toContain( + 'data-housekeeping-command="people.ticket.assign"', + ); }); it("renders CFH and quick moderation forms with mod.* only", () => { @@ -101,11 +102,18 @@ describe("People support/moderation pages", () => { { kind: "cfh-detail" as const, ticket: { - id: 4, state: 0, senderId: 2, senderUsername: "sender", - reportedId: 3, reportedUsername: "reported", moderatorId: 0, - issue: "spam", createdAt: null, + id: 4, + state: 0, + senderId: 2, + senderUsername: "sender", + reportedId: 3, + reportedUsername: "reported", + moderatorId: 0, + issue: "spam", + createdAt: null, href: "/ase/people/moderation/cfh/4" as const, - roomId: 0, activeBan: null, + roomId: 0, + activeBan: null, }, }, "cfh", @@ -122,8 +130,12 @@ describe("People support/moderation pages", () => { { kind: "overview" as const, snapshot: { - tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1, - staffOnline: 1, recentActions: 1, + tickets: 1, + helpTickets: 1, + cfh: 1, + activeBans: 1, + staffOnline: 1, + recentActions: 1, }, }, "moderation", @@ -155,13 +167,15 @@ describe("People support/moderation pages", () => { categoryId: "2", categoryName: "Appeal", content: "Please review", - replies: [{ - id: "9007199254740994", - userId: 7, - username: "visitor", - content: "More context", - createdAt: "2026-08-29T00:00:00.000Z", - }], + replies: [ + { + id: "9007199254740994", + userId: 7, + username: "visitor", + content: "More context", + createdAt: "2026-08-29T00:00:00.000Z", + }, + ], queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 }, staff: [], activeBan: { @@ -182,9 +196,15 @@ describe("People support/moderation pages", () => { )} />, ); - expect(html).toContain('data-housekeeping-command="people.help-ticket.reply"'); - expect(html).toContain('data-housekeeping-command="people.help-ticket.status"'); - expect(html).toContain('data-housekeeping-command="people.help-ticket.unban"'); + expect(html).toContain( + 'data-housekeeping-command="people.help-ticket.reply"', + ); + expect(html).toContain( + 'data-housekeeping-command="people.help-ticket.status"', + ); + expect(html).toContain( + 'data-housekeeping-command="people.help-ticket.unban"', + ); expect(html).not.toContain("/admin"); expect(html).not.toContain("/mod/"); }); @@ -212,9 +232,13 @@ describe("People support/moderation pages", () => { }, "templates", )} - /> + />, ); - expect(html.match(/data-housekeeping-command="people\.ticket-template\.change"/gu)).toHaveLength(3); + expect( + html.match( + /data-housekeeping-command="people\.ticket-template\.change"/gu, + ), + ).toHaveLength(3); expect(html).toContain("Update template"); expect(html).toContain('value="Greeting"'); expect(html).toContain('value="Hello"'); @@ -263,7 +287,7 @@ describe("People support/moderation pages", () => { }, "read-only-templates", )} - /> + />, ); expect(readOnly).not.toContain("Update template"); expect(readOnly).not.toContain("Delete template"); diff --git a/src/features/housekeeping/domains/people/pages/support.tsx b/src/features/housekeeping/domains/people/pages/support.tsx index 02a6d1d4..9368d719 100644 --- a/src/features/housekeeping/domains/people/pages/support.tsx +++ b/src/features/housekeeping/domains/people/pages/support.tsx @@ -25,12 +25,25 @@ function empty(data: PeopleSupportQueryData): boolean { return "page" in data ? data.page.items.length === 0 : false; } -function Queue({ queue }: { readonly queue: { tickets: number; helpTickets: number; cfh: number; activeBans: number } }) { +function Queue({ + queue, +}: { + readonly queue: { + tickets: number; + helpTickets: number; + cfh: number; + activeBans: number; + }; +}) { return (
{Object.entries(queue).map(([label, value]) => ( -
-
{label}
{value}
+
+
{label}
+
{value}
))}
@@ -102,16 +115,43 @@ export function PeopleSupportPage({ context, result }: Props) { buttonLabel="Create template" input={{ action: "create" }} fields={[ - { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }, - { name: "content", label: "Content", type: "text", required: true, maxLength: 5000 }, - { name: "category", label: "Category", type: "text", maxLength: 50, defaultValue: "general" }, - { name: "sortOrder", label: "Sort order", type: "number", min: 0, defaultValue: 0 }, + { + name: "title", + label: "Title", + type: "text", + required: true, + maxLength: 255, + }, + { + name: "content", + label: "Content", + type: "text", + required: true, + maxLength: 5000, + }, + { + name: "category", + label: "Category", + type: "text", + maxLength: 50, + defaultValue: "general", + }, + { + name: "sortOrder", + label: "Sort order", + type: "number", + min: 0, + defaultValue: 0, + }, ]} /> ) : null}
    {data.page.items.map((template) => ( -
  • +
  • {template.title}

    {template.content}

    {context.has(PERMS.TICKETS_EDIT) ? ( @@ -137,9 +177,15 @@ export function PeopleSupportPage({ context, result }: Props) { return (
      {data.page.items.map((ticket) => ( -
    • +
    • {ticket.title} -

      {ticket.open ? "Open" : "Closed"} · {ticket.replyCount} replies

      +

      + {ticket.open ? "Open" : "Closed"} · {ticket.replyCount}{" "} + replies +

    • ))}
    @@ -153,14 +199,23 @@ export function PeopleSupportPage({ context, result }: Props) {
    {queue ? : null}
    - +
      {data.page.items.map((ticket) => ( -
    • +
    • {ticket.subject} -

      {ticket.status} · {ticket.priority}

      +

      + {ticket.status} · {ticket.priority} +

    • ))}
    @@ -182,7 +237,11 @@ export async function renderPeopleSupportPage(input: HousekeepingPageInput) { return ( ); } diff --git a/src/features/housekeeping/domains/people/pages/ticket-detail.tsx b/src/features/housekeeping/domains/people/pages/ticket-detail.tsx index 8f0d9261..1018d87c 100644 --- a/src/features/housekeeping/domains/people/pages/ticket-detail.tsx +++ b/src/features/housekeeping/domains/people/pages/ticket-detail.tsx @@ -27,63 +27,102 @@ export function PeopleTicketDetailPage({ context, result }: Props) { result={result} isEmpty={(data) => data.kind !== "ticket"} > - {(data) => data.kind === "ticket" ? ( -
    -

    {data.ticket.subject}

    {data.ticket.status} · {data.ticket.priority}

    -
      - {data.ticket.messages.map((message) => ( -
    • - {message.username ?? `User #${message.userId}`} -

      {message.message}

      -
    • - ))} -
    - {canEdit ? ( -
    - - ({ value: staff.id, label: staff.username })), - }]} - /> - ({ value, label: value })), - }]} - /> - ({ value, label: value })), - }]} - /> -
    - ) : null} -
    - ) : null} + {(data) => + data.kind === "ticket" ? ( +
    +
    +

    {data.ticket.subject}

    +

    + {data.ticket.status} · {data.ticket.priority} +

    +
    +
      + {data.ticket.messages.map((message) => ( +
    • + + {message.username ?? `User #${message.userId}`} + +

      {message.message}

      +
    • + ))} +
    + {canEdit ? ( +
    + + ({ + value: staff.id, + label: staff.username, + })), + }, + ]} + /> + ({ value, label: value }), + ), + }, + ]} + /> + ({ value, label: value }), + ), + }, + ]} + /> +
    + ) : null} +
    + ) : null + } ); } -export async function renderPeopleTicketDetailPage(input: HousekeepingPageInput) { +export async function renderPeopleTicketDetailPage( + input: HousekeepingPageInput, +) { const id = Number(input.match.params.id); const result = Number.isSafeInteger(id) && id > 0 @@ -91,6 +130,10 @@ export async function renderPeopleTicketDetailPage(input: HousekeepingPageInput) routeId: "people.support.ticket-detail", id, }) - : fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId()); + : fail( + "VALIDATION", + "errors.housekeeping.validation", + createCorrelationId(), + ); return ; } diff --git a/src/features/housekeeping/domains/people/people-providers.test.ts b/src/features/housekeeping/domains/people/people-providers.test.ts index 16fe6f07..5d90cac8 100644 --- a/src/features/housekeeping/domains/people/people-providers.test.ts +++ b/src/features/housekeeping/domains/people/people-providers.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; import { anyCapability } from "../../foundation/contracts"; -import { - createPeopleInboxSources, - PEOPLE_INBOX_SOURCE_IDS, -} from "./inbox"; +import { createPeopleInboxSources, PEOPLE_INBOX_SOURCE_IDS } from "./inbox"; import { peopleManifest } from "./manifest"; import { createPeopleSearchProviders, @@ -30,29 +27,38 @@ function context(granted: readonly string[]): HousekeepingCapabilityContext { describe("People providers", () => { it("declares exact provider IDs and no empty manifest collection", () => { expect(PEOPLE_SEARCH_PROVIDER_IDS).toEqual([ - "people.users", "people.guilds", "people.tickets", + "people.users", + "people.guilds", + "people.tickets", ]); expect(PEOPLE_INBOX_SOURCE_IDS).toEqual([ - "people.tickets", "people.help-tickets", "people.cfh", "people.active-bans", + "people.tickets", + "people.help-tickets", + "people.cfh", + "people.active-bans", ]); - expect([ - peopleManifest.routes, - peopleManifest.searchProviders, - peopleManifest.inboxSources, - peopleManifest.widgets, - ].every((collection) => collection.length > 0)).toBe(true); + expect( + [ + peopleManifest.routes, + peopleManifest.searchProviders, + peopleManifest.inboxSources, + peopleManifest.widgets, + ].every((collection) => collection.length > 0), + ).toBe(true); }); it("bounds search at 25 and item-filters capabilities and unsafe links", async () => { const candidates = Array.from({ length: 40 }, (_, index) => ({ id: `candidate-${index}`, title: `Candidate ${index}`, - href: index === 0 - ? ("https://example.invalid" as const) - : (`/ase/people/users/${index + 1}` as const), - capability: index === 1 - ? anyCapability(PERMS.USERS_EDIT) - : anyCapability(PERMS.MOD_USERS_VIEW), + href: + index === 0 + ? ("https://example.invalid" as const) + : (`/ase/people/users/${index + 1}` as const), + capability: + index === 1 + ? anyCapability(PERMS.USERS_EDIT) + : anyCapability(PERMS.MOD_USERS_VIEW), })); const result = await createPeopleSearchProviders({ users: async () => candidates, @@ -65,7 +71,9 @@ describe("People providers", () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.data).toHaveLength(25); - expect(result.data.every((item) => item.href.startsWith("/ase/"))).toBe(true); + expect(result.data.every((item) => item.href.startsWith("/ase/"))).toBe( + true, + ); expect(result.data.map((item) => item.id)).not.toContain("candidate-1"); }); @@ -145,18 +153,20 @@ describe("People providers", () => { itemId: String(index), deduplicationKey: `ticket:${index}`, domain: "people" as const, - capability: index === 0 - ? anyCapability(PERMS.TICKETS_EDIT) - : anyCapability(PERMS.MOD_TICKETS_VIEW), + capability: + index === 0 + ? anyCapability(PERMS.TICKETS_EDIT) + : anyCapability(PERMS.MOD_TICKETS_VIEW), severity: "info" as const, priority: "normal" as const, ageMs: 0, state: "open", occurredAt: "2026-08-29T00:00:00.000Z", titleKey: "pages.housekeeping.items.ticket", - href: index === 1 - ? (`/ase/people/support/tickets/${index + 1}\u0000` as const) - : (`/ase/people/support/tickets/${index + 1}` as const), + href: + index === 1 + ? (`/ase/people/support/tickets/${index + 1}\u0000` as const) + : (`/ase/people/support/tickets/${index + 1}` as const), freshness: "fresh" as const, actions: [], })); @@ -178,7 +188,10 @@ describe("People providers", () => { const widgets = createPeopleWidgets({ queue: async () => ({ - tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4, + tickets: 1, + helpTickets: 2, + cfh: 3, + activeBans: 4, }), }); expect(widgets[0]).toMatchObject({ @@ -240,12 +253,18 @@ describe("People providers", () => { for (const entry of cases) { vi.clearAllMocks(); const widget = createPeopleWidgets({ queue: loader })[0]; - await expect(widget.load(context(entry.granted), signal)).resolves.toMatchObject({ + await expect( + widget.load(context(entry.granted), signal), + ).resolves.toMatchObject({ ok: true, data: entry.want, }); expect( - [support.mock.calls.length, cfh.mock.calls.length, activeBans.mock.calls.length], + [ + support.mock.calls.length, + cfh.mock.calls.length, + activeBans.mock.calls.length, + ], entry.name, ).toEqual(entry.calls); } @@ -254,6 +273,8 @@ describe("People providers", () => { await expect( createPeopleWidgets({ queue: loader })[0].load(context([]), signal), ).resolves.toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); - expect([support, cfh, activeBans].every((load) => load.mock.calls.length === 0)).toBe(true); + expect( + [support, cfh, activeBans].every((load) => load.mock.calls.length === 0), + ).toBe(true); }); }); diff --git a/src/features/housekeeping/domains/people/people-widgets-production.test.ts b/src/features/housekeeping/domains/people/people-widgets-production.test.ts index 033018ad..611d4fb3 100644 --- a/src/features/housekeeping/domains/people/people-widgets-production.test.ts +++ b/src/features/housekeeping/domains/people/people-widgets-production.test.ts @@ -28,10 +28,7 @@ function context(granted: readonly string[]): HousekeepingCapabilityContext { } async function load(granted: readonly string[]) { - return PEOPLE_WIDGETS[0].load( - context(granted), - new AbortController().signal, - ); + return PEOPLE_WIDGETS[0].load(context(granted), new AbortController().signal); } beforeEach(() => { @@ -78,10 +75,10 @@ describe("People production queue widget", () => { .mockResolvedValueOnce([[{ total: 3 }], []]) .mockResolvedValueOnce([[{ total: 4 }], []]); const mixed = await load([ - PERMS.MOD_TICKETS_VIEW, - PERMS.MOD_CFH_VIEW, - PERMS.MOD_BANS_VIEW, - ]); + PERMS.MOD_TICKETS_VIEW, + PERMS.MOD_CFH_VIEW, + PERMS.MOD_BANS_VIEW, + ]); expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1); expect(mocks.execute).toHaveBeenCalledTimes(2); expect(mixed).toMatchObject({ diff --git a/src/features/housekeeping/domains/people/search.ts b/src/features/housekeeping/domains/people/search.ts index 6e9bf8a6..1ca13356 100644 --- a/src/features/housekeeping/domains/people/search.ts +++ b/src/features/housekeeping/domains/people/search.ts @@ -49,7 +49,9 @@ export interface PeopleSearchAdapters { } function boundedLimit(limit: number): number { - return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25; + return Number.isFinite(limit) + ? Math.min(25, Math.max(1, Math.trunc(limit))) + : 25; } function createProvider( diff --git a/src/features/housekeeping/domains/people/services/moderation-mutations.ts b/src/features/housekeeping/domains/people/services/moderation-mutations.ts index 78f696a1..77cfddfb 100644 --- a/src/features/housekeeping/domains/people/services/moderation-mutations.ts +++ b/src/features/housekeeping/domains/people/services/moderation-mutations.ts @@ -2,10 +2,7 @@ import "server-only"; import { eq } from "drizzle-orm"; import { Ban, type Db, SupportTickets, User } from "@/lib/db"; -import type { - AuditEntry, - HousekeepingAuditWriter, -} from "@/lib/services/audit"; +import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; import type { ModerationAction, ModerationActionTransport, @@ -55,10 +52,7 @@ export interface PeopleModerationMutationDependencies { snapshot: PeopleModerationMutationSnapshot, outcome: AuditOutcome, ) => AuditEntry; - readonly failure: ( - code: HousekeepingErrorCode, - messageKey: string, - ) => Error; + readonly failure: (code: HousekeepingErrorCode, messageKey: string) => Error; readonly record: (input: unknown) => Record; readonly positiveInteger: (value: unknown) => number; readonly nonNegativeInteger: (value: unknown) => number; @@ -328,14 +322,7 @@ export function createPeopleModerationMutationExecutor( }, }; await writeAudit( - auditEntry( - context, - operation, - "Ban", - banId, - snapshot, - "intent", - ), + auditEntry(context, operation, "Ban", banId, snapshot, "intent"), tx, ); }); @@ -380,14 +367,7 @@ export function createPeopleModerationMutationExecutor( await tx.delete(Ban).where(eq(Ban.id, id)); snapshot = { before: existing ?? null, after: null }; await writeAudit( - auditEntry( - context, - operation, - "Ban", - id, - snapshot, - "intent", - ), + auditEntry(context, operation, "Ban", id, snapshot, "intent"), tx, ); }); diff --git a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts index 1903d7ff..144495fe 100644 --- a/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts +++ b/src/features/housekeeping/domains/people/services/mutations-production-workflows.test.ts @@ -1012,25 +1012,62 @@ describe("People production workflow adapter", () => { { operation: "ticket-template.change", input: { action: "update", id: 88, title: "Updated" }, - rows: [[{ id: 88, title: "Greeting", content: "Hello", category: "general", sortOrder: 0 }]], + rows: [ + [ + { + id: 88, + title: "Greeting", + content: "Hello", + category: "general", + sortOrder: 0, + }, + ], + ], transactional: true, }, { operation: "help-ticket.reply", input: { ticketId: "9007199254740993", content: "Handled" }, - rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]], + rows: [ + [ + { + id: 9_007_199_254_740_993n, + userId: 7, + open: true, + title: "Appeal", + }, + ], + ], transactional: true, }, { operation: "help-ticket.status", input: { ticketId: "9007199254740993", status: "close" }, - rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]], + rows: [ + [ + { + id: 9_007_199_254_740_993n, + userId: 7, + open: true, + title: "Appeal", + }, + ], + ], transactional: true, }, { operation: "help-ticket.unban", input: { ticketId: "9007199254740993" }, - rows: [[{ id: 9_007_199_254_740_993n, userId: 7, open: true, title: "Appeal" }]], + rows: [ + [ + { + id: 9_007_199_254_740_993n, + userId: 7, + open: true, + title: "Appeal", + }, + ], + ], transactional: true, }, { @@ -1041,20 +1078,32 @@ describe("People production workflow adapter", () => { }, { operation: "cfh.sanction", - input: { ticketId: 9, action: "alert", userId: 7, reason: "Repeated abuse" }, + input: { + ticketId: 9, + action: "alert", + userId: 7, + reason: "Repeated abuse", + }, rows: [[{ id: 9, state: 1, modId: 8 }]], transactional: true, }, { operation: "ban.create", - input: { userId: 7, hours: 24, type: "account", reason: "Repeated abuse" }, + input: { + userId: 7, + hours: 24, + type: "account", + reason: "Repeated abuse", + }, rows: [[{ username: "Alice" }]], transactional: true, }, { operation: "ban.lift", input: { id: 12 }, - rows: [[{ id: 12, userId: 7, reason: "Repeated abuse", type: "account" }]], + rows: [ + [{ id: 12, userId: 7, reason: "Repeated abuse", type: "account" }], + ], transactional: true, }, { @@ -1082,11 +1131,7 @@ describe("People production workflow adapter", () => { action: `people.${operation}`, correlationId: `task13-${operation}`, }), - ...( - transactional - ? [expect.any(Object)] - : [] - ), + ...(transactional ? [expect.any(Object)] : []), ); if (transactional) expect(mocks.transaction).toHaveBeenCalledTimes(1); }, @@ -1121,7 +1166,11 @@ describe("People production workflow adapter", () => { ["unmute", { action: "unmute", userId: 7 }, "unmuteUser"], ["alert", { action: "alert", userId: 7, message: "Stop" }, "alertUser"], ["room-kick", { action: "room-kick", roomId: 12 }, "kickAll"], - ["broadcast", { action: "broadcast", message: "Notice", type: "staff" }, "staffAlert"], + [ + "broadcast", + { action: "broadcast", message: "Notice", type: "staff" }, + "staffAlert", + ], ] as const)( "preserves legacy confirmed-false success for %s while recording an observed outcome", async (_label, input, transport) => { @@ -1163,7 +1212,9 @@ describe("People production workflow adapter", () => { vi.clearAllMocks(); mocks.resolveServerContext.mockResolvedValue(context()); mocks.audit.mockResolvedValue(undefined); - mocks.rcon.disconnectUser.mockRejectedValueOnce(new Error("RCON unavailable")); + mocks.rcon.disconnectUser.mockRejectedValueOnce( + new Error("RCON unavailable"), + ); await expect( peopleMutationService.execute( { ...invocation, correlationId: "legacy-throw" }, diff --git a/src/features/housekeeping/domains/people/services/support-mutations.ts b/src/features/housekeeping/domains/people/services/support-mutations.ts index c89ed4fd..191f2409 100644 --- a/src/features/housekeeping/domains/people/services/support-mutations.ts +++ b/src/features/housekeeping/domains/people/services/support-mutations.ts @@ -11,10 +11,7 @@ import { WebsiteTicketMessage, WebsiteTicketTemplate, } from "@/lib/db"; -import type { - AuditEntry, - HousekeepingAuditWriter, -} from "@/lib/services/audit"; +import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; import type { HousekeepingCapabilityContext, HousekeepingErrorCode, @@ -62,10 +59,7 @@ export interface PeopleSupportMutationDependencies { snapshot: PeopleSupportMutationSnapshot, outcome: AuditOutcome, ) => AuditEntry; - readonly failure: ( - code: HousekeepingErrorCode, - messageKey: string, - ) => Error; + readonly failure: (code: HousekeepingErrorCode, messageKey: string) => Error; readonly record: (input: unknown) => Record; readonly positiveInteger: (value: unknown) => number; readonly nonNegativeInteger: (value: unknown) => number; @@ -74,9 +68,7 @@ export interface PeopleSupportMutationDependencies { max: number, required?: boolean, ) => string; - readonly canonicalTicketId: ( - value: string | number | bigint, - ) => bigint; + readonly canonicalTicketId: (value: string | number | bigint) => bigint; readonly auditTargetId: (value: bigint) => number | undefined; } @@ -299,19 +291,14 @@ export function createPeopleSupportMutationExecutor( } async function executeHelpTicketMutation( - operation: Extract< - PeopleSupportMutationOperation, - `help-ticket.${string}` - >, + operation: Extract, input: unknown, context: PeopleSupportMutationContext, ): Promise { const data = record(input); let ticketId: bigint; try { - ticketId = canonicalTicketId( - data.ticketId as string | number | bigint, - ); + ticketId = canonicalTicketId(data.ticketId as string | number | bigint); } catch { throw failure("VALIDATION", "errors.housekeeping.validation"); } @@ -354,11 +341,7 @@ export function createPeopleSupportMutationExecutor( } else if (operation === "help-ticket.status") { const status = normalizedText(data.status, 16); const open = - status === "reopen" - ? true - : status === "close" - ? false - : null; + status === "reopen" ? true : status === "close" ? false : null; if (open === null) { throw failure("VALIDATION", "errors.housekeeping.validation"); } diff --git a/src/features/housekeeping/domains/people/widgets.ts b/src/features/housekeeping/domains/people/widgets.ts index 9f3faf8f..a2f390b5 100644 --- a/src/features/housekeeping/domains/people/widgets.ts +++ b/src/features/housekeeping/domains/people/widgets.ts @@ -35,10 +35,7 @@ const cfhQueueCapability = anyCapability( PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW, ); -const banQueueCapability = anyCapability( - PERMS.BANS_VIEW, - PERMS.MOD_BANS_VIEW, -); +const banQueueCapability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW); const queueCapability = anyCapability( ...supportQueueCapability.slugs, ...cfhQueueCapability.slugs, @@ -82,42 +79,44 @@ export function createPeopleQueueAggregateLoader( export function createPeopleWidgets( adapters: PeopleWidgetAdapters, ): readonly HousekeepingWidgetDefinition[] { - return [{ - id: "people.queue", - owner: "people", - capability: queueCapability, - kind: "mandatory", - async load(context, signal) { - const authorization = authorizeHousekeeping(context, queueCapability); - if (!authorization.ok) return authorization; - try { - const queue = await adapters.queue(context, signal); - return ok( - { - tickets: satisfiesCapability(context, supportQueueCapability) - ? queue.tickets - : 0, - helpTickets: satisfiesCapability(context, supportQueueCapability) - ? queue.helpTickets - : 0, - cfh: satisfiesCapability(context, cfhQueueCapability) - ? queue.cfh - : 0, - activeBans: satisfiesCapability(context, banQueueCapability) - ? queue.activeBans - : 0, - }, - authorization.correlationId, - ); - } catch { - return fail( - "DEPENDENCY_UNAVAILABLE", - "errors.housekeeping.dependencyUnavailable", - authorization.correlationId, - ); - } + return [ + { + id: "people.queue", + owner: "people", + capability: queueCapability, + kind: "mandatory", + async load(context, signal) { + const authorization = authorizeHousekeeping(context, queueCapability); + if (!authorization.ok) return authorization; + try { + const queue = await adapters.queue(context, signal); + return ok( + { + tickets: satisfiesCapability(context, supportQueueCapability) + ? queue.tickets + : 0, + helpTickets: satisfiesCapability(context, supportQueueCapability) + ? queue.helpTickets + : 0, + cfh: satisfiesCapability(context, cfhQueueCapability) + ? queue.cfh + : 0, + activeBans: satisfiesCapability(context, banQueueCapability) + ? queue.activeBans + : 0, + }, + authorization.correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + authorization.correlationId, + ); + } + }, }, - }]; + ]; } export const PEOPLE_WIDGETS = createPeopleWidgets({ diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 33a4b75f..c1ddd82c 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -1113,7 +1113,9 @@ describe("housekeeping foundation completion contracts", () => { ]); expect( registry.domains - .filter((domain) => !["people", "content", "system"].includes(domain.id)) + .filter( + (domain) => !["people", "content", "system"].includes(domain.id), + ) .every((domain) => domain.routes.length === 0), ).toBe(true); expect( diff --git a/src/features/housekeeping/foundation/housekeeping-href.ts b/src/features/housekeeping/foundation/housekeeping-href.ts index fefdfcda..1b791a4a 100644 --- a/src/features/housekeeping/foundation/housekeeping-href.ts +++ b/src/features/housekeeping/foundation/housekeeping-href.ts @@ -64,7 +64,6 @@ export function isSafeHousekeepingHref(href: string): boolean { } return ( normalized.origin === HOUSEKEEPING_ORIGIN && - (normalized.pathname === "/ase" || - normalized.pathname.startsWith("/ase/")) + (normalized.pathname === "/ase" || normalized.pathname.startsWith("/ase/")) ); } diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index e7fc13cc..722ac92f 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -60,12 +60,10 @@ const routeMocks = vi.hoisted(() => { "routes.content.help.email-templates": "HK::content-help-email-templates", "routes.content.brand.theme": "HK::content-brand-theme", "routes.content.brand.favicon": "HK::content-brand-favicon", - "routes.content.localization.overview": - "HK::content-localization-overview", + "routes.content.localization.overview": "HK::content-localization-overview", "routes.content.localization.client": "HK::content-localization-client", "routes.content.localization.cms": "HK::content-localization-cms", - "routes.content.localization.emulator": - "HK::content-localization-emulator", + "routes.content.localization.emulator": "HK::content-localization-emulator", "routes.system.access.permissions": "HK::system-access-permissions", "routes.system.access.permission-detail": "HK::system-access-permission-detail", diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 147acb62..837487bb 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -367,9 +367,9 @@ describe("housekeeping registry", () => { ? PEOPLE_ROUTES : expected.id === "content" ? CONTENT_ROUTES - : expected.id === "system" - ? SYSTEM_ROUTES - : [], + : expected.id === "system" + ? SYSTEM_ROUTES + : [], ); expect(actual.searchProviders).toEqual( expected.id === "people"