style: satisfy housekeeping biome gate

This commit is contained in:
Simo committed 2026-08-30 11:59:24 +02:00
1 parent d1160eb65a
commit 08358b8aa4
62 files changed
+2024 -773

No files matched your search

+65 -12
View File
@@ -7,17 +7,30 @@ import { ActionError } from "@/lib/safe-action-shared";
import { createAd, deleteAd } from "./admin-ads"; import { createAd, deleteAd } from "./admin-ads";
const { execute } = vi.hoisted(() => ({ 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", () => ({ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute }, 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/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler })); vi.mock("@/lib/safe-action", () => ({
vi.mock("@/lib/safe-action-shared", () => ({ ActionError: class ActionError extends Error {}, actionOk: () => "ok" })); 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/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -27,13 +40,21 @@ const fakeForm = (data) => ({ get: (key) => data[key] ?? null });
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff); 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", () => { describe("Content advertisement legacy wrappers", () => {
it("delegates creation and preserves redirect", async () => { it("delegates creation and preserves redirect", async () => {
await createAd(fakeForm({ image: "https://example.com/ad.png" })); 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"); expect(redirect).toHaveBeenCalledWith("/admin/ads");
}); });
@@ -43,20 +64,52 @@ describe("Content advertisement legacy wrappers", () => {
}); });
it("logs a redacted service failure", async () => { 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" })); 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 () => { it("delegates deletion and preserves action result", async () => {
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>; const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
await expect(handler({ data: { id: 99n }, session: { user: { id: "1" } }, requestId: "delete" })).resolves.toBe("ok"); await expect(
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ correlationId: "delete" }), "ad.change", { action: "delete", id: "99" }); 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 () => { 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<string>; const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
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);
}); });
}); });
+25 -6
View File
@@ -16,7 +16,10 @@ import { ActionError, actionOk } from "@/lib/safe-action-shared";
export async function createAd(formData: FormData): Promise<void> { export async function createAd(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); 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; if (!image) return;
const result = await contentMutationService.execute( const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()), createContentMutationInvocation(staff, createCorrelationId()),
@@ -24,7 +27,10 @@ export async function createAd(formData: FormData): Promise<void> {
{ action: "create", image }, { action: "create", image },
); );
if (!result.ok) { 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"); revalidatePath("/admin/ads");
return; return;
} }
@@ -35,7 +41,10 @@ export async function updateAd(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const raw = String(formData.get("id") ?? "").normalize("NFC"); const raw = String(formData.get("id") ?? "").normalize("NFC");
if (!/^\d+$/u.test(raw)) return; 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; if (!image) return;
const result = await contentMutationService.execute( const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()), createContentMutationInvocation(staff, createCorrelationId()),
@@ -43,7 +52,11 @@ export async function updateAd(formData: FormData): Promise<void> {
{ action: "update", id: raw, image }, { action: "update", id: raw, image },
); );
if (!result.ok) { 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); revalidatePath("/admin/ads/" + raw);
return; return;
} }
@@ -51,14 +64,20 @@ export async function updateAd(formData: FormData): Promise<void> {
} }
const deleteAdInput = z.object({ 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( export const deleteAd = adminAction(
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput }, { permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "ad.change",
{ action: "delete", id: ctx.data.id.toString() }, { action: "delete", id: ctx.data.id.toString() },
); );
+15 -5
View File
@@ -12,10 +12,18 @@ import { PERMS } from "@/lib/permissions";
function articleInput(formData: FormData) { function articleInput(formData: FormData) {
return { return {
title: String(formData.get("title") ?? "").normalize("NFC").trim(), title: String(formData.get("title") ?? "")
shortStory: String(formData.get("shortStory") ?? "").normalize("NFC").trim(), .normalize("NFC")
fullStory: String(formData.get("fullStory") ?? "").normalize("NFC").trim(), .trim(),
image: String(formData.get("image") ?? "").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(), slug: String(formData.get("slug") ?? "").trim(),
}; };
} }
@@ -30,7 +38,9 @@ export async function createArticle(formData: FormData): Promise<void> {
{ action: "create", ...input }, { action: "create", ...input },
); );
if (!result.ok) { 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"); redirect("/admin/articles");
} }
+1 -3
View File
@@ -51,9 +51,7 @@ describe("legacy admin ban wrappers", () => {
}); });
it("returns early when userId is invalid", async () => { it("returns early when userId is invalid", async () => {
await createBan( await createBan(fakeForm({ userId: "0", hours: "1", type: "account" }));
fakeForm({ userId: "0", hours: "1", type: "account" }),
);
expect(execute).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
}); });
+29 -7
View File
@@ -11,10 +11,18 @@ import { PERMS } from "@/lib/permissions";
function templateInput(formData: FormData) { function templateInput(formData: FormData) {
return { return {
name: String(formData.get("name") ?? "").normalize("NFC").trim().slice(0, 255), name: String(formData.get("name") ?? "")
subject: String(formData.get("subject") ?? "").normalize("NFC").trim().slice(0, 255), .normalize("NFC")
.trim()
.slice(0, 255),
subject: String(formData.get("subject") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
body: String(formData.get("body") ?? "").normalize("NFC"), 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, isActive: formData.get("isActive") != null,
}; };
} }
@@ -23,7 +31,11 @@ export async function createEmailTemplate(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const input = templateInput(formData); const input = templateInput(formData);
if (!input.name || !input.subject || !input.body) return; 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"); if (!result.ok) throw new Error("Email template creation failed");
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
@@ -34,16 +46,26 @@ export async function updateEmailTemplate(formData: FormData): Promise<void> {
if (!/^\d+$/u.test(id)) return; if (!/^\d+$/u.test(id)) return;
const input = templateInput(formData); const input = templateInput(formData);
if (!input.subject || !input.body) return; 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"); if (!result.ok) throw new Error("Email template update failed");
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
export async function deleteEmailTemplate(formData: FormData): Promise<void> { export async function deleteEmailTemplate(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); 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; 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"); if (!result.ok) throw new Error("Email template deletion failed");
revalidatePath("/admin/email-templates"); revalidatePath("/admin/email-templates");
} }
+1 -4
View File
@@ -47,10 +47,7 @@ function revalidateHelpCenterTicketPaths(ticketId: bigint) {
async function execute( async function execute(
staff: { readonly id: number }, staff: { readonly id: number },
operation: operation: "help-ticket.reply" | "help-ticket.status" | "help-ticket.unban",
| "help-ticket.reply"
| "help-ticket.status"
| "help-ticket.unban",
input: unknown, input: unknown,
) { ) {
return peopleMutationService.execute( return peopleMutationService.execute(
+38 -9
View File
@@ -1,12 +1,19 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; 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() })); const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
contentMutationService: { execute }, 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/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); 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() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({ get: (key: string) => key in data ? data[key] : null }); const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); 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", () => { describe("Content help legacy wrappers", () => {
it("delegates create and redirects", async () => { it("delegates create and redirects", async () => {
await createHelpQuestion(fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData); await createHelpQuestion(
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "create", name: "FAQ" })); fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData,
);
expect(execute).toHaveBeenCalledWith(
expect.anything(),
"help-question.change",
expect.objectContaining({ action: "create", name: "FAQ" }),
);
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
it("delegates update and redirects", async () => { it("delegates update and redirects", async () => {
await updateHelpQuestion(fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData); await updateHelpQuestion(
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "update", id: "42" })); 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"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
it("delegates delete and redirects", async () => { it("delegates delete and redirects", async () => {
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData); 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"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
}); });
+24 -6
View File
@@ -15,12 +15,16 @@ function helpInput(formData: FormData) {
return { return {
name: sanitizeField(formData.get("name")), name: sanitizeField(formData.get("name")),
content: canonicalize(String(formData.get("content") ?? "")), 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")), imageUrl: sanitizeField(formData.get("imageUrl")),
buttonText: sanitizeField(formData.get("buttonText")), buttonText: sanitizeField(formData.get("buttonText")),
buttonUrl: sanitizeField(formData.get("buttonUrl")), buttonUrl: sanitizeField(formData.get("buttonUrl")),
buttonColor: sanitizeField(formData.get("buttonColor"), 16) || "#eeb425", 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, smallBox: formData.get("smallBox") != null,
}; };
} }
@@ -29,10 +33,16 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const input = helpInput(formData); const input = helpInput(formData);
if (!input.name || !input.content) return; 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) { if (!result.ok) {
revalidatePath("/admin/help-questions"); 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"); revalidatePath("/admin/help-questions");
redirect("/admin/help-questions"); redirect("/admin/help-questions");
@@ -44,7 +54,11 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
if (!/^[1-9]\d*$/u.test(id)) return; if (!/^[1-9]\d*$/u.test(id)) return;
const input = helpInput(formData); const input = helpInput(formData);
if (!input.name || !input.content) return; 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) { if (!result.ok) {
revalidatePath("/admin/help-questions/" + id); revalidatePath("/admin/help-questions/" + id);
return; return;
@@ -56,6 +70,10 @@ export async function deleteHelpQuestion(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").trim(); const id = String(formData.get("id") ?? "").trim();
if (!/^[1-9]\d*$/u.test(id)) return; 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"); redirect("/admin/help-questions");
} }
+32 -6
View File
@@ -5,20 +5,42 @@ import { requirePermission } from "@/lib/admin/guard";
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media"; import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); 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/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); vi.mocked(requirePermission).mockResolvedValue({
execute.mockResolvedValue({ ok: true, data: { before: null, after: { name: "photo.png" }, output: { url: "/api/media/photo.png" } }, correlationId: "legacy" }); 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", () => { describe("Content media legacy wrappers", () => {
it("retains no-file validation", async () => { 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(await uploadMediaAndReturn(new FormData())).toBe("");
expect(execute).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
}); });
@@ -28,11 +50,15 @@ describe("Content media legacy wrappers", () => {
form.set("file", file); form.set("file", file);
expect(await uploadMedia(form)).toEqual({ ok: true }); expect(await uploadMedia(form)).toEqual({ ok: true });
expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png"); 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 () => { it("delegates deletion and preserves revalidation", async () => {
await deleteMedia("photo.png"); 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("/api/media");
expect(revalidatePath).toHaveBeenCalledWith("/admin/media"); expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
}); });
+11 -4
View File
@@ -20,11 +20,14 @@ function mediaFile(formData: FormData): File | null {
function validateMediaFile(file: File | null): string | null { function validateMediaFile(file: File | null): string | null {
if (!file || file.size === 0) return "No file provided"; if (!file || file.size === 0) return "No file provided";
if (file.size > MAX_SIZE) return "File too large (max 5MB)"; 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; 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 staff = await requirePermission(PERMS.PAGES_EDIT);
const file = mediaFile(formData); const file = mediaFile(formData);
const error = validateMediaFile(file); const error = validateMediaFile(file);
@@ -51,7 +54,9 @@ export async function deleteMedia(name: string): Promise<void> {
revalidatePath("/admin/media"); revalidatePath("/admin/media");
} }
export async function uploadMediaAndReturn(formData: FormData): Promise<string> { export async function uploadMediaAndReturn(
formData: FormData,
): Promise<string> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const file = mediaFile(formData); const file = mediaFile(formData);
if (validateMediaFile(file)) return ""; if (validateMediaFile(file)) return "";
@@ -63,5 +68,7 @@ export async function uploadMediaAndReturn(formData: FormData): Promise<string>
if (!result.ok) return ""; if (!result.ok) return "";
revalidatePath("/api/media"); revalidatePath("/api/media");
revalidatePath("/admin/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
: "";
} }
+5 -1
View File
@@ -22,7 +22,11 @@ export const saveAdminNavConfig = adminAction(
}, },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "navigation.update",
ctx.data, ctx.data,
); );
+5 -1
View File
@@ -13,7 +13,11 @@ export async function deletePhoto(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = Number(formData.get("id")); const id = Number(formData.get("id"));
if (!(id > 0)) return; 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("/admin/photos");
revalidatePath("/photos"); revalidatePath("/photos");
} }
+48 -9
View File
@@ -5,7 +5,14 @@ import { requirePermission } from "@/lib/admin/guard";
import { createTag, deleteTag, updateTag } from "./admin-tags"; import { createTag, deleteTag, updateTag } from "./admin-tags";
const { execute } = vi.hoisted(() => ({ execute: vi.fn() })); 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/admin/guard", () => ({ requirePermission: vi.fn() }));
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } })); vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); 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 }); const form = (data) => ({ get: (key) => data[key] ?? null });
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" }); vi.mocked(requirePermission).mockResolvedValue({
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" } }, correlationId: "legacy" }); id: 1,
rank: 7,
username: "admin",
});
execute.mockResolvedValue({
ok: true,
data: { before: null, after: { id: "1" } },
correlationId: "legacy",
});
}); });
describe("Content tag legacy wrappers", () => { describe("Content tag legacy wrappers", () => {
it("delegates create with normalized values", async () => { it("delegates create with normalized values", async () => {
await createTag(form({ name: "News", backgroundColor: "#ff0000" })); 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"); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
}); });
it("uses the legacy default color", async () => { it("uses the legacy default color", async () => {
await createTag(form({ name: "Test" })); 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 () => { it("returns early for an empty name", async () => {
await createTag(form({ name: "" })); await createTag(form({ name: "" }));
expect(execute).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled();
}); });
it("revalidates after a fail-soft dependency result", async () => { 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" })); await createTag(form({ name: "News" }));
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
}); });
it("delegates update", async () => { it("delegates update", async () => {
await updateTag(form({ id: "42", name: "Updated", backgroundColor: "#00ff00" })); await updateTag(
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ action: "update", id: "42" })); 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 () => { it("rejects invalid update id or name", async () => {
await updateTag(form({ id: "", name: "Test" })); await updateTag(form({ id: "", name: "Test" }));
@@ -47,7 +83,10 @@ describe("Content tag legacy wrappers", () => {
}); });
it("delegates delete", async () => { it("delegates delete", async () => {
await deleteTag(form({ id: "42" })); 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 () => { it("rejects invalid delete id", async () => {
await deleteTag(form({ id: "" })); await deleteTag(form({ id: "" }));
+22 -5
View File
@@ -11,8 +11,13 @@ import { PERMS } from "@/lib/permissions";
function tagInput(formData: FormData) { function tagInput(formData: FormData) {
return { return {
name: String(formData.get("name") ?? "").trim().slice(0, 255), name: String(formData.get("name") ?? "")
backgroundColor: String(formData.get("backgroundColor") ?? "").trim().slice(0, 10) || "#888888", .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<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const input = tagInput(formData); const input = tagInput(formData);
if (!input.name) return; 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"); revalidatePath("/admin/tags");
} }
@@ -30,7 +39,11 @@ export async function updateTag(formData: FormData): Promise<void> {
if (!/^[1-9]\d*$/u.test(id)) return; if (!/^[1-9]\d*$/u.test(id)) return;
const input = tagInput(formData); const input = tagInput(formData);
if (!input.name) return; 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"); revalidatePath("/admin/tags");
} }
@@ -38,6 +51,10 @@ export async function deleteTag(formData: FormData): Promise<void> {
const staff = await requirePermission(PERMS.PAGES_EDIT); const staff = await requirePermission(PERMS.PAGES_EDIT);
const id = String(formData.get("id") ?? "").trim(); const id = String(formData.get("id") ?? "").trim();
if (!/^[1-9]\d*$/u.test(id)) return; 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"); revalidatePath("/admin/tags");
} }
+64 -15
View File
@@ -11,16 +11,34 @@ import { requirePermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
function formValues(formData: FormData): Record<string, string> { function formValues(formData: FormData): Record<string, string> {
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<string, unknown>) { async function executeTheme(
operation:
| "theme.update"
| "theme.apply-preset"
| "theme.custom-change"
| "theme.apply-custom",
input: Record<string, unknown>,
) {
const staff = await requirePermission(PERMS.SETTINGS_EDIT); 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<void> { export async function saveTheme(formData: FormData): Promise<void> {
const result = await executeTheme("theme.update", { values: formValues(formData) }); const result = await executeTheme("theme.update", {
values: formValues(formData),
});
if (result.ok) revalidatePath("/", "layout"); if (result.ok) revalidatePath("/", "layout");
redirect("/admin/theme?saved=1"); redirect("/admin/theme?saved=1");
} }
@@ -29,39 +47,70 @@ export async function applyPreset(formData: FormData): Promise<void> {
const name = String(formData.get("preset") ?? "").normalize("NFC"); const name = String(formData.get("preset") ?? "").normalize("NFC");
const result = await executeTheme("theme.apply-preset", { preset: name }); const result = await executeTheme("theme.apply-preset", { preset: name });
if (result.ok) revalidatePath("/", "layout"); 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<void> { export async function saveCustomTheme(formData: FormData): Promise<void> {
const name = String(formData.get("name") ?? "").normalize("NFC").trim(); const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim();
if (!name) redirect("/admin/theme"); 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"); if (result.ok) revalidatePath("/admin/theme");
redirect("/admin/theme?savedTheme=1"); redirect("/admin/theme?savedTheme=1");
} }
export async function applyCustomTheme(formData: FormData): Promise<void> { export async function applyCustomTheme(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "").normalize("NFC").trim(); const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!id) redirect("/admin/theme"); if (!id) redirect("/admin/theme");
const result = await executeTheme("theme.apply-custom", { id }); const result = await executeTheme("theme.apply-custom", { id });
if (result.ok) revalidatePath("/", "layout"); if (result.ok) revalidatePath("/", "layout");
const name = result.ok && typeof result.data.output?.name === "string" ? result.data.output.name : ""; const name =
redirect(result.ok ? "/admin/theme?theme=" + encodeURIComponent(name) : "/admin/theme"); 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<void> { export async function renameCustomTheme(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "").normalize("NFC").trim(); const id = String(formData.get("id") ?? "")
const name = String(formData.get("name") ?? "").normalize("NFC").trim(); .normalize("NFC")
.trim();
const name = String(formData.get("name") ?? "")
.normalize("NFC")
.trim();
if (!id || !name) redirect("/admin/theme"); 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"); if (result.ok) revalidatePath("/admin/theme");
redirect("/admin/theme?renamed=1"); redirect("/admin/theme?renamed=1");
} }
export async function deleteCustomTheme(formData: FormData): Promise<void> { export async function deleteCustomTheme(formData: FormData): Promise<void> {
const id = String(formData.get("id") ?? "").normalize("NFC").trim(); const id = String(formData.get("id") ?? "")
.normalize("NFC")
.trim();
if (!id) redirect("/admin/theme"); 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"); if (result.ok) revalidatePath("/admin/theme");
redirect("/admin/theme?deletedTheme=1"); redirect("/admin/theme?deletedTheme=1");
} }
+24 -7
View File
@@ -12,10 +12,17 @@ import { PERMS } from "@/lib/permissions";
function boxInput(formData: FormData) { function boxInput(formData: FormData) {
const position = Number(formData.get("position")); const position = Number(formData.get("position"));
return { return {
title: String(formData.get("title") ?? "").normalize("NFC").trim().slice(0, 255), title: String(formData.get("title") ?? "")
icon: String(formData.get("icon") ?? "").normalize("NFC").trim().slice(0, 255), .normalize("NFC")
.trim()
.slice(0, 255),
icon: String(formData.get("icon") ?? "")
.normalize("NFC")
.trim()
.slice(0, 255),
content: String(formData.get("content") ?? "").normalize("NFC"), 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", isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
}; };
} }
@@ -25,13 +32,23 @@ function refreshBoxes(): void {
revalidatePath("/", "layout"); revalidatePath("/", "layout");
} }
async function executeBox(formData: FormData, action: "create" | "update" | "delete" | "toggle"): Promise<boolean> { async function executeBox(
formData: FormData,
action: "create" | "update" | "delete" | "toggle",
): Promise<boolean> {
const staff = await requirePermission(PERMS.PAGES_EDIT); 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; if (action !== "create" && !/^\d+$/u.test(id)) return false;
const input = boxInput(formData); const input = boxInput(formData);
if ((action === "create" || action === "update") && !input.title) return false; if ((action === "create" || action === "update") && !input.title)
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "writeable-box.change", { action, ...(id ? { id } : {}), ...input, next: formData.get("next") }); return false;
const result = await contentMutationService.execute(
createContentMutationInvocation(staff, createCorrelationId()),
"writeable-box.change",
{ action, ...(id ? { id } : {}), ...input, next: formData.get("next") },
);
return result.ok; return result.ok;
} }
+16 -3
View File
@@ -18,9 +18,20 @@ const bannerSchema = z.object({
endDate: z.string().max(50).nullable().optional(), endDate: z.string().max(50).nullable().optional(),
}); });
async function runBanner(ctx: { data: Record<string, unknown>; requestId: unknown; session: { user: { id: number } } }, action: "create" | "update" | "delete") { async function runBanner(
ctx: {
data: Record<string, unknown>;
requestId: unknown;
session: { user: { id: number } };
},
action: "create" | "update" | "delete",
) {
return contentMutationService.execute( 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", "banner.change",
{ action, ...ctx.data }, { 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( export const updateBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput }, { permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
+28 -7
View File
@@ -1,17 +1,38 @@
// @ts-nocheck // @ts-nocheck
import { describe, expect, it, vi } from "vitest"; 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" })) })); const { execute } = vi.hoisted(() => ({
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute } })); execute: vi.fn(async () => ({
vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" } })); ok: true,
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler })); 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" })); vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" }));
describe("saveEmulatorSettings", () => { describe("saveEmulatorSettings", () => {
it("delegates the third translation store and preserves result shape", async () => { it("delegates the third translation store and preserves result shape", async () => {
const handler = (await import("./emulator")).saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>; const handler = (await import("./emulator"))
const result = await handler({ data: { settings: { key1: "val1", key2: "val2" } }, session: { user: { id: "1" } }, requestId: "emulator" }); .saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>;
expect(execute).toHaveBeenCalledWith({ correlationId: "emulator", expectedActorId: 1, legacy: true }, "translation.emulator.save", { settings: { key1: "val1", key2: "val2" } }); 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"); expect(result).toBe("ok");
}); });
}); });
+8 -2
View File
@@ -6,13 +6,19 @@ import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { actionOk } from "@/lib/safe-action-shared"; 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( export const saveEmulatorSettings = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema }, { permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "translation.emulator.save",
ctx.data, ctx.data,
); );
+45 -9
View File
@@ -28,7 +28,11 @@ export const createEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event-type.change",
{ action: "create", ...ctx.data }, { action: "create", ...ctx.data },
); );
@@ -45,7 +49,11 @@ export const updateEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event-type.change",
{ action: "update", ...ctx.data }, { action: "update", ...ctx.data },
); );
@@ -62,7 +70,11 @@ export const deleteEventType = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event-type.change",
{ action: "delete", ...ctx.data }, { action: "delete", ...ctx.data },
); );
@@ -77,7 +89,11 @@ export const createEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema }, { permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event.change",
{ action: "create", ...ctx.data }, { action: "create", ...ctx.data },
); );
@@ -94,7 +110,11 @@ export const updateEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput }, { permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event.change",
{ action: "update", ...ctx.data }, { action: "update", ...ctx.data },
); );
@@ -111,7 +131,11 @@ export const deleteEvent = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput }, { permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event.change",
{ action: "delete", ...ctx.data }, { action: "delete", ...ctx.data },
); );
@@ -126,7 +150,11 @@ export const addEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event-prize.change",
{ action: "create", ...ctx.data }, { action: "create", ...ctx.data },
); );
@@ -141,7 +169,11 @@ export const deleteEventPrize = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput }, { permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event-prize.change",
{ action: "delete", ...ctx.data }, { action: "delete", ...ctx.data },
); );
@@ -156,7 +188,11 @@ export const addEventWinner = adminAction(
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema }, { permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "event-winner.add",
ctx.data, ctx.data,
); );
@@ -11,12 +11,15 @@ function wrapper(
options: { options: {
permission: string | readonly string[]; permission: string | readonly string[];
schema?: { schema?: {
safeParse(value: unknown): safeParse(
| { success: true; data: unknown } value: unknown,
| { success: false; error: 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); registrations.push(options);
return async (data: unknown) => { return async (data: unknown) => {
@@ -174,9 +177,10 @@ describe("legacy People support and moderation wrappers", () => {
await call(replyHelpCenterTicket, { ticketId, content: " Handled " }); await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
await call(closeHelpCenterTicket, { ticketId }); await call(closeHelpCenterTicket, { ticketId });
await call(reopenHelpCenterTicket, { ticketId }); await call(reopenHelpCenterTicket, { ticketId });
await expect( await expect(call(liftBanFromHelpTicket, { ticketId })).resolves.toEqual({
call(liftBanFromHelpTicket, { ticketId }), ok: true,
).resolves.toEqual({ ok: true, data: { removed: 2, userId: 7 } }); data: { removed: 2, userId: 7 },
});
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([ expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
{ ticketId: "9007199254740993", content: "Handled" }, { ticketId: "9007199254740993", content: "Handled" },
+30 -6
View File
@@ -26,7 +26,11 @@ export const createPoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema }, { permission: PERMS.POLLS_EDIT, schema: createPollSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "poll.change",
{ action: "create", ...ctx.data }, { action: "create", ...ctx.data },
); );
@@ -43,7 +47,11 @@ export const updatePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput }, { permission: PERMS.POLLS_EDIT, schema: updatePollInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "poll.change",
{ action: "update", ...ctx.data }, { action: "update", ...ctx.data },
); );
@@ -60,7 +68,11 @@ export const deletePoll = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput }, { permission: PERMS.POLLS_EDIT, schema: deletePollInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "poll.change",
{ action: "delete", ...ctx.data }, { action: "delete", ...ctx.data },
); );
@@ -75,7 +87,11 @@ export const addPollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema }, { permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "poll-question.change",
{ action: "create", ...ctx.data }, { action: "create", ...ctx.data },
); );
@@ -92,7 +108,11 @@ export const updatePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "poll-question.change",
{ action: "update", ...ctx.data }, { action: "update", ...ctx.data },
); );
@@ -109,7 +129,11 @@ export const deletePollQuestion = adminAction(
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput }, { permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "poll-question.change",
{ action: "delete", ...ctx.data }, { action: "delete", ...ctx.data },
); );
+50 -11
View File
@@ -24,23 +24,62 @@ const updatePrefixSchema = z.object({
}); });
const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() }); const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() });
const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) }); const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) });
const removeBlacklistWordSchema = z.object({ id: z.coerce.number().int().positive() }); const removeBlacklistWordSchema = z.object({
const updatePrefixSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) }); id: z.coerce.number().int().positive(),
});
const updatePrefixSettingsSchema = z.object({
settings: z.record(z.string(), z.string()),
});
async function run(ctx: { data: Record<string, unknown>; requestId: unknown; session: { user: { id: number } } }, operation: "prefix.change" | "prefix-blacklist.change" | "prefix-settings.update", input: Record<string, unknown>) { async function run(
ctx: {
data: Record<string, unknown>;
requestId: unknown;
session: { user: { id: number } };
},
operation:
| "prefix.change"
| "prefix-blacklist.change"
| "prefix-settings.update",
input: Record<string, unknown>,
) {
const result = await contentMutationService.execute( 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, operation,
input, 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); if (!result.ok) throw new Error(result.error.messageKey);
return actionOk(); return actionOk();
} }
export const createPrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data })); export const createPrefix = adminAction(
export const updatePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data })); { permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
export const deletePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data })); (ctx) => run(ctx, "prefix.change", { action: "create", ...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 updatePrefix = adminAction(
export const updatePrefixSettings = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, (ctx) => run(ctx, "prefix-settings.update", ctx.data)); { 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),
);
+1 -4
View File
@@ -17,10 +17,7 @@ const templateSchema = z.object({
sortOrder: z.coerce.number().int().min(0).default(0), sortOrder: z.coerce.number().int().min(0).default(0),
}); });
async function execute( async function execute(staff: { readonly id: number }, input: unknown) {
staff: { readonly id: number },
input: unknown,
) {
const result = await peopleMutationService.execute( const result = await peopleMutationService.execute(
createPeopleMutationInvocation(staff, createCorrelationId()), createPeopleMutationInvocation(staff, createCorrelationId()),
"ticket-template.change", "ticket-template.change",
+3 -1
View File
@@ -31,7 +31,9 @@ async function execute(
); );
if (!result.ok) { if (!result.ok) {
throw new ActionError( 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",
); );
} }
} }
+40 -5
View File
@@ -8,11 +8,36 @@ import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
const saveTranslationsSchema = z.object({ 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()), data: z.record(z.string(), z.unknown()),
}); });
const saveClientTranslationsSchema = z.object({ 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()), data: z.record(z.string(), z.string()),
}); });
@@ -20,7 +45,11 @@ export const saveTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema }, { permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "translation.cms.save",
ctx.data, ctx.data,
); );
@@ -33,14 +62,20 @@ export const saveClientTranslations = adminAction(
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema }, { permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
async (ctx) => { async (ctx) => {
const result = await contentMutationService.execute( 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", "translation.client.save",
ctx.data, ctx.data,
); );
if (!result.ok) throw new ActionError("Translation save failed"); if (!result.ok) throw new ActionError("Translation save failed");
return actionOk({ return actionOk({
commentsLost: result.data.output?.commentsLost === true, 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
: [],
}); });
}, },
); );
@@ -1,10 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { import { CONTENT_COMMAND_IDS, createContentCommands } from "./content-commands";
CONTENT_COMMAND_IDS,
createContentCommands,
} from "./content-commands";
const expected = [ const expected = [
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT], ["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
@@ -41,11 +38,7 @@ const expected = [
PERMS.SETTINGS_EDIT, PERMS.SETTINGS_EDIT,
], ],
["content.editorial.tag.change", "tag.change", PERMS.PAGES_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", "content.engagement.prefix-blacklist.change",
"prefix-blacklist.change", "prefix-blacklist.change",
@@ -14,33 +14,93 @@ const CONTENT_COMMAND_DEFINITIONS = [
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT], ["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT], ["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
["content.media.banner.change", "banner.change", PERMS.BANNERS_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.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.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.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT], ["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
["content.media.asset.delete", "media.delete", 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.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", 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.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.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-preset",
["content.brand.theme.apply-custom", "theme.apply-custom", PERMS.SETTINGS_EDIT], "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.save", "favicon.save", PERMS.SETTINGS_EDIT],
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT], ["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
["content.brand.logo.save", "logo.save", 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.cms.save",
["content.localization.emulator.save", "translation.emulator.save", PERMS.SETTINGS_EDIT], "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; ] as const;
export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map( export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map(
@@ -2,27 +2,142 @@ import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries"; import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form"; 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) { export function ContentBrandPage({
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2"> context,
{routeId === "content.brand.theme" ? <> result,
<ContentCommandForm commandId="content.brand.theme.update" buttonLabel="Save theme values" input={{}} fields={[{ name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason /> routeId,
<ContentCommandForm commandId="content.brand.theme.apply-preset" buttonLabel="Apply preset" input={{}} fields={[{ name: "preset", label: "Preset", type: "text", required: true, maxLength: 100 }]} requiresReason /> }: ContentPageProps) {
<ContentCommandForm commandId="content.brand.theme.custom-change" buttonLabel="Save custom theme" input={{ action: "update" }} fields={[{ name: "id", label: "Theme ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 100 }, { name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason /> const forms = context.has(PERMS.SETTINGS_EDIT) ? (
<ContentCommandForm commandId="content.brand.theme.apply-custom" buttonLabel="Apply custom theme" input={{}} fields={[{ name: "id", label: "Theme ID", type: "identifier", required: true }]} requiresReason /> <div className="grid gap-3 lg:grid-cols-2">
</> : null} {routeId === "content.brand.theme" ? (
{routeId === "content.brand.favicon" ? <> <>
<ContentCommandForm commandId="content.brand.favicon.save" buttonLabel="Save favicon" input={{}} fields={[{ name: "file", label: "Favicon", type: "file", required: true }]} requiresReason /> <ContentCommandForm
<ContentCommandForm commandId="content.brand.favicon.delete" buttonLabel="Delete favicon" input={{}} requiresReason /> commandId="content.brand.theme.update"
<ContentCommandForm commandId="content.brand.logo.save" buttonLabel="Save logo" input={{}} fields={[{ name: "file", label: "Logo", type: "file", required: true }]} requiresReason /> buttonLabel="Save theme values"
</> : null} input={{}}
</div> : null; fields={[
return <ContentPageFrame title="Brand" description="Manage theme, favicon, and logo assets." result={result} forms={forms} />; {
name: "values",
label: "Theme values JSON",
type: "json",
required: true,
maxLength: 20_000,
},
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.theme.apply-preset"
buttonLabel="Apply preset"
input={{}}
fields={[
{
name: "preset",
label: "Preset",
type: "text",
required: true,
maxLength: 100,
},
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.theme.custom-change"
buttonLabel="Save custom theme"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Theme ID", type: "identifier" },
{
name: "name",
label: "Name",
type: "text",
required: true,
maxLength: 100,
},
{
name: "values",
label: "Theme values JSON",
type: "json",
required: true,
maxLength: 20_000,
},
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.theme.apply-custom"
buttonLabel="Apply custom theme"
input={{}}
fields={[
{
name: "id",
label: "Theme ID",
type: "identifier",
required: true,
},
]}
requiresReason
/>
</>
) : null}
{routeId === "content.brand.favicon" ? (
<>
<ContentCommandForm
commandId="content.brand.favicon.save"
buttonLabel="Save favicon"
input={{}}
fields={[
{ name: "file", label: "Favicon", type: "file", required: true },
]}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.favicon.delete"
buttonLabel="Delete favicon"
input={{}}
requiresReason
/>
<ContentCommandForm
commandId="content.brand.logo.save"
buttonLabel="Save logo"
input={{}}
fields={[
{ name: "file", label: "Logo", type: "file", required: true },
]}
requiresReason
/>
</>
) : null}
</div>
) : null;
return (
<ContentPageFrame
title="Brand"
description="Manage theme, favicon, and logo assets."
result={result}
forms={forms}
/>
);
} }
export async function renderContentBrandPage(input: HousekeepingPageInput) { export async function renderContentBrandPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"]; 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 ?? {}) }); const result = await contentQuery.run(input.context, {
return <ContentBrandPage context={input.context} result={result} routeId={routeId} />; routeId: routeId ?? "content.brand.theme",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentBrandPage
context={input.context}
result={result}
routeId={routeId}
/>
);
} }
@@ -1,7 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { import type { HousekeepingResult } from "../../../foundation/contracts";
HousekeepingResult,
} from "../../../foundation/contracts";
import type { ContentQueryData } from "../queries/content-queries"; import type { ContentQueryData } from "../queries/content-queries";
export interface ContentPageProps { export interface ContentPageProps {
@@ -65,7 +63,9 @@ export function ContentPageFrame({
} }
export function parseContentListInput( export function parseContentListInput(
searchParams: Readonly<Record<string, string | readonly string[] | undefined>>, searchParams: Readonly<
Record<string, string | readonly string[] | undefined>
>,
) { ) {
const first = (value: string | readonly string[] | undefined) => const first = (value: string | readonly string[] | undefined) =>
Array.isArray(value) ? value[0] : value; Array.isArray(value) ? value[0] : value;
@@ -8,7 +8,11 @@ import {
parseContentListInput, parseContentListInput,
} from "./content-page-frame"; } 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 canNews = context.has(PERMS.NEWS_EDIT);
const canPages = context.has(PERMS.PAGES_EDIT); const canPages = context.has(PERMS.PAGES_EDIT);
const canSettings = context.has(PERMS.SETTINGS_EDIT); const canSettings = context.has(PERMS.SETTINGS_EDIT);
@@ -23,11 +27,25 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
<ContentCommandForm <ContentCommandForm
commandId="content.editorial.article.change" commandId="content.editorial.article.change"
buttonLabel="Save article" buttonLabel="Save article"
input={{ action: routeId.endsWith("create") ? "create" : "update" }} input={{
action: routeId.endsWith("create") ? "create" : "update",
}}
fields={[ fields={[
{ name: "id", label: "Article ID", type: "identifier" }, { name: "id", label: "Article ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 }, {
{ name: "content", label: "Body", type: "textarea", required: true, maxLength: 100_000 }, name: "title",
label: "Title",
type: "text",
required: true,
maxLength: 255,
},
{
name: "content",
label: "Body",
type: "textarea",
required: true,
maxLength: 100_000,
},
]} ]}
/> />
) : null} ) : null}
@@ -36,7 +54,15 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
commandId="content.editorial.navigation.update" commandId="content.editorial.navigation.update"
buttonLabel="Save navigation" buttonLabel="Save navigation"
input={{}} 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} ) : null}
{canPages && routeId === "content.editorial.tags" ? ( {canPages && routeId === "content.editorial.tags" ? (
@@ -46,7 +72,13 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
input={{ action: "update" }} input={{ action: "update" }}
fields={[ fields={[
{ name: "id", label: "Tag ID", type: "identifier" }, { 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} ) : null}
@@ -57,8 +89,19 @@ export function ContentEditorialPage({ context, result, routeId }: ContentPagePr
input={{ action: "update" }} input={{ action: "update" }}
fields={[ fields={[
{ name: "id", label: "Box ID", type: "identifier" }, { 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} ) : null}
@@ -75,5 +118,11 @@ export async function renderContentEditorialPage(input: HousekeepingPageInput) {
params: input.match.params, params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}), list: parseContentListInput(input.searchParams ?? {}),
}); });
return <ContentEditorialPage context={input.context} result={result} routeId={routeId} />; return (
<ContentEditorialPage
context={input.context}
result={result}
routeId={routeId}
/>
);
} }
@@ -2,18 +2,96 @@ import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries"; import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form"; 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) { export function ContentHelpPage({
const forms = context.has(PERMS.PAGES_EDIT) ? <div className="grid gap-3 lg:grid-cols-2"> context,
{routeId?.includes("question") ? <ContentCommandForm commandId="content.help.question.change" buttonLabel="Save help question" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Question ID", type: "identifier" }, { name: "name", label: "Question", type: "text", required: true, maxLength: 255 }, { name: "answer", label: "Answer", type: "textarea", required: true, maxLength: 20_000 }]} /> : null} result,
{routeId === "content.help.email-templates" ? <ContentCommandForm commandId="content.help.email-template.change" buttonLabel="Save email template" input={{ action: "update" }} fields={[{ name: "id", label: "Template ID", type: "identifier", required: true }, { name: "subject", label: "Subject", type: "text", required: true, maxLength: 255 }, { name: "body", label: "Body", type: "textarea", required: true, maxLength: 100_000 }]} /> : null} routeId,
</div> : null; }: ContentPageProps) {
return <ContentPageFrame title="Help content" description="Manage help questions and email templates." result={result} forms={forms} />; const forms = context.has(PERMS.PAGES_EDIT) ? (
<div className="grid gap-3 lg:grid-cols-2">
{routeId?.includes("question") ? (
<ContentCommandForm
commandId="content.help.question.change"
buttonLabel="Save help question"
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
fields={[
{ name: "id", label: "Question ID", type: "identifier" },
{
name: "name",
label: "Question",
type: "text",
required: true,
maxLength: 255,
},
{
name: "answer",
label: "Answer",
type: "textarea",
required: true,
maxLength: 20_000,
},
]}
/>
) : null}
{routeId === "content.help.email-templates" ? (
<ContentCommandForm
commandId="content.help.email-template.change"
buttonLabel="Save email template"
input={{ action: "update" }}
fields={[
{
name: "id",
label: "Template ID",
type: "identifier",
required: true,
},
{
name: "subject",
label: "Subject",
type: "text",
required: true,
maxLength: 255,
},
{
name: "body",
label: "Body",
type: "textarea",
required: true,
maxLength: 100_000,
},
]}
/>
) : null}
</div>
) : null;
return (
<ContentPageFrame
title="Help content"
description="Manage help questions and email templates."
result={result}
forms={forms}
/>
);
} }
export async function renderContentHelpPage(input: HousekeepingPageInput) { export async function renderContentHelpPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"]; 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 ?? {}) }); const result = await contentQuery.run(input.context, {
return <ContentHelpPage context={input.context} result={result} routeId={routeId} />; routeId: routeId ?? "content.help.questions",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentHelpPage
context={input.context}
result={result}
routeId={routeId}
/>
);
} }
@@ -2,9 +2,17 @@ import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingPageInput } from "../../../route-handlers"; import type { HousekeepingPageInput } from "../../../route-handlers";
import { contentQuery } from "../queries/content-queries"; import { contentQuery } from "../queries/content-queries";
import { ContentCommandForm } from "./content-command-form"; 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 canPages = context.has(PERMS.PAGES_EDIT);
const canBanners = context.has(PERMS.BANNERS_EDIT); const canBanners = context.has(PERMS.BANNERS_EDIT);
return ( return (
@@ -12,21 +20,108 @@ export function ContentMediaPage({ context, result, routeId }: ContentPageProps)
title="Media" title="Media"
description="Manage photos, uploaded media, banners, and advertisements." description="Manage photos, uploaded media, banners, and advertisements."
result={result} result={result}
forms={<div className="grid gap-3 lg:grid-cols-2"> forms={
{canPages && routeId === "content.media.photos" ? <ContentCommandForm commandId="content.media.photo.delete" buttonLabel="Delete photo" input={{}} fields={[{ name: "id", label: "Photo ID", type: "identifier", required: true }]} /> : null} <div className="grid gap-3 lg:grid-cols-2">
{canPages && routeId === "content.media.library" ? <> {canPages && routeId === "content.media.photos" ? (
<ContentCommandForm commandId="content.media.asset.upload" buttonLabel="Upload media" input={{}} fields={[{ name: "file", label: "Media file", type: "file", required: true }]} /> <ContentCommandForm
<ContentCommandForm commandId="content.media.asset.delete" buttonLabel="Delete media" input={{}} fields={[{ name: "filename", label: "Filename", type: "text", required: true, maxLength: 255 }]} /> commandId="content.media.photo.delete"
</> : null} buttonLabel="Delete photo"
{canBanners && routeId === "content.media.banners" ? <ContentCommandForm commandId="content.media.banner.change" buttonLabel="Save banner" input={{ action: "update" }} fields={[{ name: "id", label: "Banner ID", type: "identifier" }, { name: "title", label: "Title", type: "text", maxLength: 255 }]} /> : null} input={{}}
{canPages && routeId?.includes("ad") ? <ContentCommandForm commandId="content.media.ad.change" buttonLabel="Save advertisement" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Advertisement ID", type: "identifier" }, { name: "image", label: "Image path", type: "text", required: true, maxLength: 500 }, { name: "url", label: "Destination", type: "text", maxLength: 1000 }]} /> : null} fields={[
</div>} {
name: "id",
label: "Photo ID",
type: "identifier",
required: true,
},
]}
/>
) : null}
{canPages && routeId === "content.media.library" ? (
<>
<ContentCommandForm
commandId="content.media.asset.upload"
buttonLabel="Upload media"
input={{}}
fields={[
{
name: "file",
label: "Media file",
type: "file",
required: true,
},
]}
/>
<ContentCommandForm
commandId="content.media.asset.delete"
buttonLabel="Delete media"
input={{}}
fields={[
{
name: "filename",
label: "Filename",
type: "text",
required: true,
maxLength: 255,
},
]}
/>
</>
) : null}
{canBanners && routeId === "content.media.banners" ? (
<ContentCommandForm
commandId="content.media.banner.change"
buttonLabel="Save banner"
input={{ action: "update" }}
fields={[
{ name: "id", label: "Banner ID", type: "identifier" },
{ name: "title", label: "Title", type: "text", maxLength: 255 },
]}
/>
) : null}
{canPages && routeId?.includes("ad") ? (
<ContentCommandForm
commandId="content.media.ad.change"
buttonLabel="Save advertisement"
input={{
action: routeId.endsWith("create") ? "create" : "update",
}}
fields={[
{ name: "id", label: "Advertisement ID", type: "identifier" },
{
name: "image",
label: "Image path",
type: "text",
required: true,
maxLength: 500,
},
{
name: "url",
label: "Destination",
type: "text",
maxLength: 1000,
},
]}
/>
) : null}
</div>
}
/> />
); );
} }
export async function renderContentMediaPage(input: HousekeepingPageInput) { export async function renderContentMediaPage(input: HousekeepingPageInput) {
const routeId = input.match.routeId as ContentPageProps["routeId"]; 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 ?? {}) }); const result = await contentQuery.run(input.context, {
return <ContentMediaPage context={input.context} result={result} routeId={routeId} />; routeId: routeId ?? "content.media.photos",
params: input.match.params,
list: parseContentListInput(input.searchParams ?? {}),
});
return (
<ContentMediaPage
context={input.context}
result={result}
routeId={routeId}
/>
);
} }
@@ -12,7 +12,11 @@ import {
} from "./routes"; } from "./routes";
const expected = [ const expected = [
["content.editorial.articles", "/ase/content/editorial/articles", "editorial"], [
"content.editorial.articles",
"/ase/content/editorial/articles",
"editorial",
],
[ [
"content.editorial.article-create", "content.editorial.article-create",
"/ase/content/editorial/articles/new", "/ase/content/editorial/articles/new",
@@ -57,16 +61,8 @@ const expected = [
"engagement", "engagement",
], ],
["content.help.questions", "/ase/content/help/questions", "help"], ["content.help.questions", "/ase/content/help/questions", "help"],
[ ["content.help.question-create", "/ase/content/help/questions/new", "help"],
"content.help.question-create", ["content.help.question-detail", "/ase/content/help/questions/:id", "help"],
"/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.editorial.tags", "/ase/content/editorial/tags", "editorial"],
[ [
"content.engagement.prefixes", "content.engagement.prefixes",
@@ -78,14 +74,14 @@ const expected = [
"/ase/content/editorial/writeable-boxes", "/ase/content/editorial/writeable-boxes",
"editorial", "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.theme", "/ase/content/brand/theme", "brand"],
["content.brand.favicon", "/ase/content/brand/favicon", "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", "content.localization.client",
"/ase/content/localization/client", "/ase/content/localization/client",
@@ -64,9 +64,11 @@ function contentRoute(
} }
export const CONTENT_ROUTES = [ export const CONTENT_ROUTES = [
contentRoute("content.editorial.articles", "/ase/content/editorial/articles", [ contentRoute(
PERMS.NEWS_VIEW, "content.editorial.articles",
]), "/ase/content/editorial/articles",
[PERMS.NEWS_VIEW],
),
contentRoute( contentRoute(
"content.editorial.article-create", "content.editorial.article-create",
"/ase/content/editorial/articles/new", "/ase/content/editorial/articles/new",
@@ -95,11 +97,9 @@ export const CONTENT_ROUTES = [
contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [ contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [
PERMS.PAGES_VIEW, PERMS.PAGES_VIEW,
]), ]),
contentRoute( contentRoute("content.engagement.events", "/ase/content/engagement/events", [
"content.engagement.events", PERMS.EVENTS_VIEW,
"/ase/content/engagement/events", ]),
[PERMS.EVENTS_VIEW],
),
contentRoute( contentRoute(
"content.engagement.event-create", "content.engagement.event-create",
"/ase/content/engagement/events/create", "/ase/content/engagement/events/create",
@@ -115,11 +115,9 @@ export const CONTENT_ROUTES = [
"/ase/content/engagement/events/:id", "/ase/content/engagement/events/:id",
[PERMS.EVENTS_EDIT], [PERMS.EVENTS_EDIT],
), ),
contentRoute( contentRoute("content.engagement.polls", "/ase/content/engagement/polls", [
"content.engagement.polls", PERMS.POLLS_VIEW,
"/ase/content/engagement/polls", ]),
[PERMS.POLLS_VIEW],
),
contentRoute( contentRoute(
"content.engagement.poll-create", "content.engagement.poll-create",
"/ase/content/engagement/polls/create", "/ase/content/engagement/polls/create",
@@ -167,11 +165,9 @@ export const CONTENT_ROUTES = [
contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [ contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [
PERMS.SETTINGS_VIEW, PERMS.SETTINGS_VIEW,
]), ]),
contentRoute( contentRoute("content.localization.overview", "/ase/content/localization", [
"content.localization.overview", PERMS.SETTINGS_VIEW,
"/ase/content/localization", ]),
[PERMS.SETTINGS_VIEW],
),
contentRoute( contentRoute(
"content.localization.client", "content.localization.client",
"/ase/content/localization/client", "/ase/content/localization/client",
@@ -11,12 +11,8 @@ import type { ContentSearchCandidate } from "./search";
type ContentSearchKind = "articles" | "events" | "media" | "help"; type ContentSearchKind = "articles" | "events" | "media" | "help";
const SEARCH_ROUTES = { const SEARCH_ROUTES = {
articles: [ articles: [["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)]],
["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)], events: [["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)]],
],
events: [
["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)],
],
media: [ media: [
["content.media.photos", anyCapability(PERMS.PAGES_VIEW)], ["content.media.photos", anyCapability(PERMS.PAGES_VIEW)],
["content.media.library", anyCapability(PERMS.PAGES_VIEW)], ["content.media.library", anyCapability(PERMS.PAGES_VIEW)],
@@ -48,7 +48,9 @@ export const CONTENT_MIXED_OPERATIONS = [
type TransactionToken = unknown; type TransactionToken = unknown;
export interface ContentProductionMutationDependencies { export interface ContentProductionMutationDependencies {
transaction<T>(run: (transaction: TransactionToken) => Promise<T>): Promise<T>; transaction<T>(
run: (transaction: TransactionToken) => Promise<T>,
): Promise<T>;
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>; writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
executeOperation( executeOperation(
operation: ContentMutationOperation, operation: ContentMutationOperation,
@@ -111,7 +113,8 @@ async function writeOutcomeOrMarkUnavailable(
} catch { } catch {
const completion = { const completion = {
status: "partial" as const, 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, audit: "unavailable" as const,
}; };
return { ...snapshot, completion }; return { ...snapshot, completion };
@@ -24,9 +24,8 @@ function context(
describe("Content mutation service authority", () => { describe("Content mutation service authority", () => {
it("rehydrates server authority and rejects forged actor identity", async () => { it("rehydrates server authority and rejects forged actor identity", async () => {
const adapter = { execute: vi.fn() }; const adapter = { execute: vi.fn() };
const service = createContentMutationService( const service = createContentMutationService(adapter, async () =>
adapter, context([PERMS.NEWS_EDIT], 42),
async () => context([PERMS.NEWS_EDIT], 42),
); );
const result = await service.execute( 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 () => { it("requires the exact operation ACL even after dispatcher authorization", async () => {
const adapter = { execute: vi.fn() }; const adapter = { execute: vi.fn() };
const service = createContentMutationService( const service = createContentMutationService(adapter, async () =>
adapter, context([PERMS.NEWS_EDIT]),
async () => context([PERMS.NEWS_EDIT]),
); );
const result = await service.execute( const result = await service.execute(
@@ -67,9 +65,8 @@ describe("Content mutation service authority", () => {
before: null, before: null,
after: { actorId: mutationContext.capability.actor.id }, after: { actorId: mutationContext.capability.actor.id },
})); }));
const service = createContentMutationService( const service = createContentMutationService({ execute }, async () =>
{ execute }, context(Object.values(PERMS)),
async () => context(Object.values(PERMS)),
); );
const invocation = createContentMutationInvocation( const invocation = createContentMutationInvocation(
{ id: 42 }, { id: 42 },
@@ -125,9 +125,7 @@ const OPERATION_PERMISSION = Object.freeze({
"translation.emulator.save": PERMS.SETTINGS_EDIT, "translation.emulator.save": PERMS.SETTINGS_EDIT,
} satisfies Record<ContentMutationOperation, string>); } satisfies Record<ContentMutationOperation, string>);
export function contentMutationCapability( export function contentMutationCapability(operation: ContentMutationOperation) {
operation: ContentMutationOperation,
) {
return anyCapability(OPERATION_PERMISSION[operation]); return anyCapability(OPERATION_PERMISSION[operation]);
} }
@@ -47,7 +47,9 @@ describe("People moderation commands", () => {
const command = commands.find((entry) => entry.id === id); const command = commands.find((entry) => entry.id === id);
if (!command) throw new Error("command missing"); if (!command) throw new Error("command missing");
expect(command.requiresReason).toBe(true); expect(command.requiresReason).toBe(true);
expect(confirmHousekeepingCommand(command, " ", "moderation")).toMatchObject({ expect(
confirmHousekeepingCommand(command, " ", "moderation"),
).toMatchObject({
ok: false, ok: false,
error: { code: "VALIDATION" }, error: { code: "VALIDATION" },
}); });
@@ -63,9 +65,7 @@ describe("People moderation commands", () => {
const created = createModerationCommands({ const created = createModerationCommands({
execute, execute,
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[]; }) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
const command = created.find( const command = created.find((entry) => entry.id === "people.cfh.sanction");
(entry) => entry.id === "people.cfh.sanction",
);
if (!command) throw new Error("command missing"); if (!command) throw new Error("command missing");
expect( expect(
command.input.safeParse({ command.input.safeParse({
@@ -106,16 +106,26 @@ describe("People moderation commands", () => {
it.each([ it.each([
["people.cfh.resolve", { ticketId: 9, state: 2 }, "cfh.resolve"], ["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.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) => { ] as const)("delegates %s to %s", async (id, input, operation) => {
const execute = vi.fn(async (invocation) => ({ const execute = vi.fn(async (invocation) => ({
ok: true as const, ok: true as const,
data: { before: null, after: null }, data: { before: null, after: null },
correlationId: invocation.correlationId, correlationId: invocation.correlationId,
})); }));
const created = createModerationCommands({ execute }) as unknown as readonly HousekeepingCommand<unknown, unknown>[]; const created = createModerationCommands({
execute,
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[];
const command = created.find((entry) => entry.id === id); const command = created.find((entry) => entry.id === id);
if (!command) throw new Error("command missing"); if (!command) throw new Error("command missing");
const parsed = command.input.parse(input); const parsed = command.input.parse(input);
@@ -128,5 +128,6 @@ export function createModerationCommands(
] as const; ] as const;
} }
export const MODERATION_COMMANDS = export const MODERATION_COMMANDS = createModerationCommands(
createModerationCommands(peopleMutationService); peopleMutationService,
);
@@ -58,47 +58,78 @@ describe("People support commands", () => {
}); });
it.each([ 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.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.ticket.status",
["people.help-ticket.reply", { ticketId: "9007199254740993", content: "Handled" }, "help-ticket.reply"], { ticketId: 7, status: "closed" },
["people.help-ticket.status", { ticketId: "9007199254740993", status: "close" }, "help-ticket.status"], "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) => { "people.ticket-template.change",
const execute = vi.fn(async (invocation) => ({ { action: "create", title: "Greeting", content: "Hello" },
ok: true as const, "ticket-template.change",
data: { before: null, after: null }, ],
correlationId: invocation.correlationId, [
})); "people.help-ticket.reply",
const created = createSupportCommands({ { ticketId: "9007199254740993", content: "Handled" },
execute, "help-ticket.reply",
}) as unknown as readonly HousekeepingCommand<unknown, unknown>[]; ],
const command = created.find((entry) => entry.id === id); [
if (!command) throw new Error("command missing"); "people.help-ticket.status",
const parsed = command.input.parse(input); { ticketId: "9007199254740993", status: "close" },
await command.execute( "help-ticket.status",
{ ],
capability: { [
actor: { id: 42, username: "mod", rank: 4 }, "people.help-ticket.status",
isSuperAdmin: false, { ticketId: "9007199254740993", status: "reopen" },
has: () => false, "help-ticket.status",
hasAny: () => true, ],
hasAll: () => false, [
"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<unknown, unknown>[];
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", parsed,
ipAddress: "198.51.100.8", );
}, expect(execute).toHaveBeenCalledWith(
parsed, expect.objectContaining({
); correlationId: "support-red",
expect(execute).toHaveBeenCalledWith( expectedActorId: 42,
expect.objectContaining({ }),
correlationId: "support-red", operation,
expectedActorId: 42, parsed,
}), );
operation, },
parsed, );
);
});
}); });
@@ -72,7 +72,10 @@ export function createSupportCommands(
id: "people.ticket.assign", id: "people.ticket.assign",
operation: "ticket.assign", operation: "ticket.assign",
capability: ticketEdit, capability: ticketEdit,
input: z.object({ ticketId: positiveId, assigneeId: positiveId.nullable() }), input: z.object({
ticketId: positiveId,
assigneeId: positiveId.nullable(),
}),
}), }),
command(service, { command(service, {
id: "people.ticket.status", id: "people.ticket.status",
@@ -127,27 +127,39 @@ const productionAdapters: PeopleInboxAdapters = {
routeId: "people.support.tickets", routeId: "people.support.tickets",
list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" }, list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" },
}); });
if (!result.ok || result.data.kind !== "tickets") throw new Error("tickets"); if (!result.ok || result.data.kind !== "tickets")
const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW); throw new Error("tickets");
const capability = anyCapability(
PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW,
);
return result.data.page.items.flatMap((ticket) => { return result.data.page.items.flatMap((ticket) => {
const date = time(ticket.updatedAt); const date = time(ticket.updatedAt);
return date === null return date === null
? [] ? []
: [{ : [
sourceId: "people.tickets", {
itemId: String(ticket.id), sourceId: "people.tickets",
deduplicationKey: `${ticket.source}-ticket:${ticket.id}`, itemId: String(ticket.id),
domain: "people" as const, deduplicationKey: `${ticket.source}-ticket:${ticket.id}`,
capability, domain: "people" as const,
severity: ticket.priority === "urgent" ? "critical" as const : "info" as const, capability,
priority: ticket.priority === "urgent" ? "critical" as const : "normal" as const, severity:
...date, ticket.priority === "urgent"
state: ticket.status, ? ("critical" as const)
titleKey: "pages.housekeeping.items.ticket", : ("info" as const),
context: { subject: ticket.subject }, priority:
href: ticket.href, ticket.priority === "urgent"
actions: [], ? ("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) { async helpTickets(context) {
@@ -155,25 +167,33 @@ const productionAdapters: PeopleInboxAdapters = {
routeId: "people.support.help-tickets", routeId: "people.support.help-tickets",
list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" }, list: { pageSize: 25, offset: 0, sort: "updatedAt", order: "desc" },
}); });
if (!result.ok || result.data.kind !== "help-tickets") throw new Error("help"); if (!result.ok || result.data.kind !== "help-tickets")
const capability = anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW); throw new Error("help");
const capability = anyCapability(
PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW,
);
return result.data.page.items.flatMap((ticket) => { return result.data.page.items.flatMap((ticket) => {
const date = time(ticket.updatedAt); const date = time(ticket.updatedAt);
return date === null ? [] : [{ return date === null
sourceId: "people.help-tickets", ? []
itemId: ticket.id, : [
deduplicationKey: `help-ticket:${ticket.id}`, {
domain: "people" as const, sourceId: "people.help-tickets",
capability, itemId: ticket.id,
severity: "info" as const, deduplicationKey: `help-ticket:${ticket.id}`,
priority: "normal" as const, domain: "people" as const,
...date, capability,
state: ticket.open ? "open" : "closed", severity: "info" as const,
titleKey: "pages.housekeeping.items.helpTicket", priority: "normal" as const,
context: { title: ticket.title }, ...date,
href: ticket.href, state: ticket.open ? "open" : "closed",
actions: [], titleKey: "pages.housekeeping.items.helpTicket",
}]; context: { title: ticket.title },
href: ticket.href,
actions: [],
},
];
}); });
}, },
async cfh(context) { async cfh(context) {
@@ -185,21 +205,25 @@ const productionAdapters: PeopleInboxAdapters = {
const capability = anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW); const capability = anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW);
return result.data.page.items.flatMap((ticket) => { return result.data.page.items.flatMap((ticket) => {
const date = time(ticket.createdAt); const date = time(ticket.createdAt);
return date === null ? [] : [{ return date === null
sourceId: "people.cfh", ? []
itemId: String(ticket.id), : [
deduplicationKey: `cfh:${ticket.id}`, {
domain: "people" as const, sourceId: "people.cfh",
capability, itemId: String(ticket.id),
severity: "warning" as const, deduplicationKey: `cfh:${ticket.id}`,
priority: "high" as const, domain: "people" as const,
...date, capability,
state: String(ticket.state), severity: "warning" as const,
titleKey: "pages.housekeeping.items.cfh", priority: "high" as const,
context: { issue: ticket.issue }, ...date,
href: ticket.href, state: String(ticket.state),
actions: [], titleKey: "pages.housekeeping.items.cfh",
}]; context: { issue: ticket.issue },
href: ticket.href,
actions: [],
},
];
}); });
}, },
async activeBans(context) { async activeBans(context) {
@@ -211,21 +235,25 @@ const productionAdapters: PeopleInboxAdapters = {
const capability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW); const capability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW);
return result.data.page.items.flatMap((ban) => { return result.data.page.items.flatMap((ban) => {
const date = time(ban.createdAt); const date = time(ban.createdAt);
return date === null ? [] : [{ return date === null
sourceId: "people.active-bans", ? []
itemId: String(ban.id), : [
deduplicationKey: `ban:${ban.id}`, {
domain: "people" as const, sourceId: "people.active-bans",
capability, itemId: String(ban.id),
severity: "warning" as const, deduplicationKey: `ban:${ban.id}`,
priority: "normal" as const, domain: "people" as const,
...date, capability,
state: "active", severity: "warning" as const,
titleKey: "pages.housekeeping.items.activeBan", priority: "normal" as const,
context: { userId: ban.userId, reason: ban.reason }, ...date,
href: `/ase/people/users/${ban.userId}` as const, state: "active",
actions: [], titleKey: "pages.housekeeping.items.activeBan",
}]; context: { userId: ban.userId, reason: ban.reason },
href: `/ase/people/users/${ban.userId}` as const,
actions: [],
},
];
}); });
}, },
}; };
@@ -27,46 +27,82 @@ export function PeopleCfhDetailPage({ context, result }: Props) {
result={result} result={result}
isEmpty={(data) => data.kind !== "cfh-detail"} isEmpty={(data) => data.kind !== "cfh-detail"}
> >
{(data) => data.kind === "cfh-detail" ? ( {(data) =>
<article className="space-y-4"> data.kind === "cfh-detail" ? (
<h2>CFH #{data.ticket.id}</h2> <article className="space-y-4">
<p>{data.ticket.issue}</p> <h2>CFH #{data.ticket.id}</h2>
<p>Reporter: {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}</p> <p>{data.ticket.issue}</p>
<p>Reported: {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}</p> <p>
{canEdit ? ( Reporter:{" "}
<> {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}
<PeopleCommandForm </p>
commandId="people.cfh.resolve" <p>
buttonLabel="Resolve" Reported:{" "}
input={{ ticketId: data.ticket.id, state: 2 }} {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}
/> </p>
<PeopleCommandForm {canEdit ? (
commandId="people.cfh.sanction" <>
buttonLabel="Apply sanction" <PeopleCommandForm
input={{ ticketId: data.ticket.id, userId: data.ticket.reportedId }} commandId="people.cfh.resolve"
fields={[ buttonLabel="Resolve"
{name: "action", label: "Action", type: "select", options: ["kick", "mute", "alert"].map((value) => ({ value, label: value }))}, input={{ ticketId: data.ticket.id, state: 2 }}
{name: "duration", label: "Duration", type: "number", min: 0, max: 525600, defaultValue: 0}, />
{name: "message", label: "Message", type: "text", maxLength: 500}, <PeopleCommandForm
]} commandId="people.cfh.sanction"
requiresReason buttonLabel="Apply sanction"
includeReasonInInput input={{
/> ticketId: data.ticket.id,
</> userId: data.ticket.reportedId,
) : null} }}
</article> fields={[
) : null} {
name: "action",
label: "Action",
type: "select",
options: ["kick", "mute", "alert"].map((value) => ({
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}
</article>
) : null
}
</PeoplePageFrame> </PeoplePageFrame>
); );
} }
export async function renderPeopleCfhDetailPage(input: HousekeepingPageInput) { export async function renderPeopleCfhDetailPage(input: HousekeepingPageInput) {
const id = Number(input.match.params.id); const id = Number(input.match.params.id);
const result = Number.isSafeInteger(id) && id > 0 const result =
? await peopleModerationQuery.run(input.context, { Number.isSafeInteger(id) && id > 0
routeId: "people.moderation.cfh-detail", ? await peopleModerationQuery.run(input.context, {
id, routeId: "people.moderation.cfh-detail",
}) id,
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId()); })
: fail(
"VALIDATION",
"errors.housekeeping.validation",
createCorrelationId(),
);
return <PeopleCfhDetailPage context={input.context} result={result} />; return <PeopleCfhDetailPage context={input.context} result={result} />;
} }
@@ -28,44 +28,67 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) {
result={result} result={result}
isEmpty={(data) => data.kind !== "help-ticket"} isEmpty={(data) => data.kind !== "help-ticket"}
> >
{(data) => data.kind === "help-ticket" ? ( {(data) =>
<article className="space-y-4"> data.kind === "help-ticket" ? (
<header><h2>{data.ticket.title}</h2><p>{data.ticket.content}</p></header> <article className="space-y-4">
<ul> <header>
{data.ticket.replies.map((reply) => ( <h2>{data.ticket.title}</h2>
<li key={reply.id}><strong>{reply.username ?? `User #${reply.userId}`}</strong>: {reply.content}</li> <p>{data.ticket.content}</p>
))} </header>
</ul> <ul>
{canEdit ? ( {data.ticket.replies.map((reply) => (
<> <li key={reply.id}>
<strong>{reply.username ?? `User #${reply.userId}`}</strong>:{" "}
{reply.content}
</li>
))}
</ul>
{canEdit ? (
<>
<PeopleCommandForm
commandId="people.help-ticket.reply"
buttonLabel="Reply"
input={{ ticketId: data.ticket.id }}
fields={[
{
name: "content",
label: "Reply",
type: "text",
required: true,
maxLength: 5000,
},
]}
/>
<PeopleCommandForm
commandId="people.help-ticket.status"
buttonLabel={
data.ticket.open ? "Close ticket" : "Reopen ticket"
}
input={{
ticketId: data.ticket.id,
status: data.ticket.open ? "close" : "reopen",
}}
/>
</>
) : null}
{data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? (
<PeopleCommandForm <PeopleCommandForm
commandId="people.help-ticket.reply" commandId="people.help-ticket.unban"
buttonLabel="Reply" buttonLabel="Lift requester bans and close"
input={{ ticketId: data.ticket.id }} input={{ ticketId: data.ticket.id }}
fields={[{ name: "content", label: "Reply", type: "text", required: true, maxLength: 5000 }]} requiresReason
/> />
<PeopleCommandForm ) : null}
commandId="people.help-ticket.status" </article>
buttonLabel={data.ticket.open ? "Close ticket" : "Reopen ticket"} ) : null
input={{ ticketId: data.ticket.id, status: data.ticket.open ? "close" : "reopen" }} }
/>
</>
) : null}
{data.ticket.activeBan && context.has(PERMS.USERS_BAN) ? (
<PeopleCommandForm
commandId="people.help-ticket.unban"
buttonLabel="Lift requester bans and close"
input={{ ticketId: data.ticket.id }}
requiresReason
/>
) : null}
</article>
) : null}
</PeoplePageFrame> </PeoplePageFrame>
); );
} }
export async function renderPeopleHelpTicketDetailPage(input: HousekeepingPageInput) { export async function renderPeopleHelpTicketDetailPage(
input: HousekeepingPageInput,
) {
const raw = input.match.params.id ?? ""; const raw = input.match.params.id ?? "";
let id: string | null = null; let id: string | null = null;
try { try {
@@ -78,6 +101,10 @@ export async function renderPeopleHelpTicketDetailPage(input: HousekeepingPageIn
routeId: "people.support.help-ticket-detail", routeId: "people.support.help-ticket-detail",
id, id,
}) })
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId()); : fail(
"VALIDATION",
"errors.housekeeping.validation",
createCorrelationId(),
);
return <PeopleHelpTicketDetailPage context={input.context} result={result} />; return <PeopleHelpTicketDetailPage context={input.context} result={result} />;
} }
@@ -29,12 +29,38 @@ function QuickAction() {
buttonLabel="Run moderation action" buttonLabel="Run moderation action"
input={{}} input={{}}
fields={[ 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: "action",
{name: "roomId", label: "Room ID", type: "number", min: 1}, label: "Action",
{name: "duration", label: "Duration", type: "number", min: 0, max: 525600}, type: "select",
{name: "message", label: "Message", type: "text", maxLength: 500}, options: [
{name: "type", label: "Audience", type: "select", options: [{value: "hotel", label: "hotel"}, {value: "staff", label: "staff"}]}, "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} isEmpty={isEmpty}
> >
{(data) => { {(data) => {
if (data.kind === "overview") return ( if (data.kind === "overview")
<div className="space-y-4"> return (
<dl className="grid gap-3 sm:grid-cols-3"> <div className="space-y-4">
{Object.entries(data.snapshot).map(([label, value]) => ( <dl className="grid gap-3 sm:grid-cols-3">
<div key={label} className="rounded border border-[var(--admin-border)] p-3"><dt>{label}</dt><dd>{value}</dd></div> {Object.entries(data.snapshot).map(([label, value]) => (
<div
key={label}
className="rounded border border-[var(--admin-border)] p-3"
>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
{canAct ? <QuickAction /> : null}
</div>
);
if (data.kind === "cfh")
return (
<ul>
{data.page.items.map((ticket) => (
<li key={ticket.id}>
<a href={ticket.href}>CFH #{ticket.id}</a> · {ticket.issue}
</li>
))} ))}
</dl> </ul>
{canAct ? <QuickAction /> : null} );
</div> if (data.kind === "bans")
); return (
if (data.kind === "cfh") return ( <div className="space-y-3">
<ul>{data.page.items.map((ticket) => ( {context.has(PERMS.USERS_BAN) ? (
<li key={ticket.id}><a href={ticket.href}>CFH #{ticket.id}</a> · {ticket.issue}</li> <PeopleCommandForm
))}</ul> commandId="people.ban.create"
); buttonLabel="Create ban"
if (data.kind === "bans") return ( input={{}}
<div className="space-y-3"> fields={[
{context.has(PERMS.USERS_BAN) ? ( {
<PeopleCommandForm name: "userId",
commandId="people.ban.create" label: "User ID",
buttonLabel="Create ban" type: "number",
input={{}} min: 1,
fields={[ required: true,
{name: "userId", label: "User ID", type: "number", min: 1, required: true}, },
{name: "hours", label: "Hours", type: "number", min: 0, max: 876000, defaultValue: 0}, {
{name: "type", label: "Type", type: "select", options: ["account", "ip", "machine", "super"].map((value) => ({value, label: value}))}, name: "hours",
]} label: "Hours",
requiresReason type: "number",
includeReasonInInput min: 0,
/> max: 876000,
) : null} defaultValue: 0,
<ul>{data.page.items.map((ban) => ( },
<li key={ban.id}> {
User #{ban.userId}: {ban.reason} name: "type",
{context.has(PERMS.USERS_BAN) ? ( label: "Type",
<PeopleCommandForm commandId="people.ban.lift" buttonLabel="Lift ban" input={{id: ban.id}} requiresReason /> type: "select",
) : null} options: ["account", "ip", "machine", "super"].map(
</li> (value) => ({ value, label: value }),
))}</ul> ),
</div> },
); ]}
if (data.kind === "ip-rules") return <pre>{JSON.stringify({blacklist: data.blacklist, whitelist: data.whitelist}, null, 2)}</pre>; requiresReason
if (data.kind === "vpn") return <ul>{data.settings.map((setting) => <li key={setting.key}>{setting.key}: {setting.value}</li>)}</ul>; includeReasonInInput
if (data.kind === "word-filter") return <ul>{data.page.items.map((entry) => <li key={entry.id}>{entry.word}</li>)}</ul>; />
) : null}
<ul>
{data.page.items.map((ban) => (
<li key={ban.id}>
User #{ban.userId}: {ban.reason}
{context.has(PERMS.USERS_BAN) ? (
<PeopleCommandForm
commandId="people.ban.lift"
buttonLabel="Lift ban"
input={{ id: ban.id }}
requiresReason
/>
) : null}
</li>
))}
</ul>
</div>
);
if (data.kind === "ip-rules")
return (
<pre>
{JSON.stringify(
{ blacklist: data.blacklist, whitelist: data.whitelist },
null,
2,
)}
</pre>
);
if (data.kind === "vpn")
return (
<ul>
{data.settings.map((setting) => (
<li key={setting.key}>
{setting.key}: {setting.value}
</li>
))}
</ul>
);
if (data.kind === "word-filter")
return (
<ul>
{data.page.items.map((entry) => (
<li key={entry.id}>{entry.word}</li>
))}
</ul>
);
return null; return null;
}} }}
</PeoplePageFrame> </PeoplePageFrame>
@@ -118,6 +209,10 @@ export async function renderPeopleModerationPage(input: HousekeepingPageInput) {
routeId: queryRoute, routeId: queryRoute,
list: parsePeopleListInput(input.searchParams ?? {}), list: parsePeopleListInput(input.searchParams ?? {}),
}) })
: fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId()); : fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
createCorrelationId(),
);
return <PeopleModerationPage context={input.context} result={result} />; return <PeopleModerationPage context={input.context} result={result} />;
} }
@@ -288,9 +288,9 @@ describe("People primary route registration", () => {
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId), HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expect.arrayContaining(expected)); ).toEqual(expect.arrayContaining(expected));
expect( expect(
( (HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[]).flatMap(
HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[] (manifest) => manifest.routes.map((route) => route.id),
).flatMap((manifest) => manifest.routes.map((route) => route.id)), ),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId)); ).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
}); });
@@ -7,10 +7,7 @@ import { PeopleCfhDetailPage } from "./cfh-detail";
import { PeopleHelpTicketDetailPage } from "./help-ticket-detail"; import { PeopleHelpTicketDetailPage } from "./help-ticket-detail";
import { PeopleModerationPage } from "./moderation"; import { PeopleModerationPage } from "./moderation";
import { submitPeopleCommandForm } from "./people-command-form"; import { submitPeopleCommandForm } from "./people-command-form";
import { import { PeopleSupportPage, ticketTemplateUpdateSubmission } from "./support";
PeopleSupportPage,
ticketTemplateUpdateSubmission,
} from "./support";
import { PeopleTicketDetailPage } from "./ticket-detail"; import { PeopleTicketDetailPage } from "./ticket-detail";
const executeHousekeepingCommand = vi.hoisted(() => vi.fn()); const executeHousekeepingCommand = vi.hoisted(() => vi.fn());
@@ -38,17 +35,19 @@ describe("People support/moderation pages", () => {
{ {
kind: "tickets" as const, kind: "tickets" as const,
page: { page: {
items: [{ items: [
source: "cms" as const, {
id: 7, source: "cms" as const,
subject: "Need help", id: 7,
status: "open", subject: "Need help",
priority: "normal", status: "open",
creatorId: 9, priority: "normal",
creatorUsername: "visitor", creatorId: 9,
updatedAt: null, creatorUsername: "visitor",
href: "/ase/people/support/tickets/7" as const, updatedAt: null,
}], href: "/ase/people/support/tickets/7" as const,
},
],
total: 1, total: 1,
pageSize: 20, pageSize: 20,
offset: 0, 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.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", () => { it("renders CFH and quick moderation forms with mod.* only", () => {
@@ -101,11 +102,18 @@ describe("People support/moderation pages", () => {
{ {
kind: "cfh-detail" as const, kind: "cfh-detail" as const,
ticket: { ticket: {
id: 4, state: 0, senderId: 2, senderUsername: "sender", id: 4,
reportedId: 3, reportedUsername: "reported", moderatorId: 0, state: 0,
issue: "spam", createdAt: null, senderId: 2,
senderUsername: "sender",
reportedId: 3,
reportedUsername: "reported",
moderatorId: 0,
issue: "spam",
createdAt: null,
href: "/ase/people/moderation/cfh/4" as const, href: "/ase/people/moderation/cfh/4" as const,
roomId: 0, activeBan: null, roomId: 0,
activeBan: null,
}, },
}, },
"cfh", "cfh",
@@ -122,8 +130,12 @@ describe("People support/moderation pages", () => {
{ {
kind: "overview" as const, kind: "overview" as const,
snapshot: { snapshot: {
tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1, tickets: 1,
staffOnline: 1, recentActions: 1, helpTickets: 1,
cfh: 1,
activeBans: 1,
staffOnline: 1,
recentActions: 1,
}, },
}, },
"moderation", "moderation",
@@ -155,13 +167,15 @@ describe("People support/moderation pages", () => {
categoryId: "2", categoryId: "2",
categoryName: "Appeal", categoryName: "Appeal",
content: "Please review", content: "Please review",
replies: [{ replies: [
id: "9007199254740994", {
userId: 7, id: "9007199254740994",
username: "visitor", userId: 7,
content: "More context", username: "visitor",
createdAt: "2026-08-29T00:00:00.000Z", content: "More context",
}], createdAt: "2026-08-29T00:00:00.000Z",
},
],
queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 }, queue: { tickets: 1, helpTickets: 1, cfh: 1, activeBans: 1 },
staff: [], staff: [],
activeBan: { activeBan: {
@@ -182,9 +196,15 @@ describe("People support/moderation pages", () => {
)} )}
/>, />,
); );
expect(html).toContain('data-housekeeping-command="people.help-ticket.reply"'); expect(html).toContain(
expect(html).toContain('data-housekeeping-command="people.help-ticket.status"'); 'data-housekeeping-command="people.help-ticket.reply"',
expect(html).toContain('data-housekeeping-command="people.help-ticket.unban"'); );
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("/admin");
expect(html).not.toContain("/mod/"); expect(html).not.toContain("/mod/");
}); });
@@ -212,9 +232,13 @@ describe("People support/moderation pages", () => {
}, },
"templates", "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("Update template");
expect(html).toContain('value="Greeting"'); expect(html).toContain('value="Greeting"');
expect(html).toContain('value="Hello"'); expect(html).toContain('value="Hello"');
@@ -263,7 +287,7 @@ describe("People support/moderation pages", () => {
}, },
"read-only-templates", "read-only-templates",
)} )}
/> />,
); );
expect(readOnly).not.toContain("Update template"); expect(readOnly).not.toContain("Update template");
expect(readOnly).not.toContain("Delete template"); expect(readOnly).not.toContain("Delete template");
@@ -25,12 +25,25 @@ function empty(data: PeopleSupportQueryData): boolean {
return "page" in data ? data.page.items.length === 0 : false; 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 ( return (
<dl className="grid gap-3 sm:grid-cols-4"> <dl className="grid gap-3 sm:grid-cols-4">
{Object.entries(queue).map(([label, value]) => ( {Object.entries(queue).map(([label, value]) => (
<div key={label} className="rounded border border-[var(--admin-border)] p-3"> <div
<dt>{label}</dt><dd>{value}</dd> key={label}
className="rounded border border-[var(--admin-border)] p-3"
>
<dt>{label}</dt>
<dd>{value}</dd>
</div> </div>
))} ))}
</dl> </dl>
@@ -102,16 +115,43 @@ export function PeopleSupportPage({ context, result }: Props) {
buttonLabel="Create template" buttonLabel="Create template"
input={{ action: "create" }} input={{ action: "create" }}
fields={[ fields={[
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 }, {
{ name: "content", label: "Content", type: "text", required: true, maxLength: 5000 }, name: "title",
{ name: "category", label: "Category", type: "text", maxLength: 50, defaultValue: "general" }, label: "Title",
{ name: "sortOrder", label: "Sort order", type: "number", min: 0, defaultValue: 0 }, 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} ) : null}
<ul className="space-y-2"> <ul className="space-y-2">
{data.page.items.map((template) => ( {data.page.items.map((template) => (
<li key={template.id} className="rounded border border-[var(--admin-border)] p-3"> <li
key={template.id}
className="rounded border border-[var(--admin-border)] p-3"
>
<h2>{template.title}</h2> <h2>{template.title}</h2>
<p>{template.content}</p> <p>{template.content}</p>
{context.has(PERMS.TICKETS_EDIT) ? ( {context.has(PERMS.TICKETS_EDIT) ? (
@@ -137,9 +177,15 @@ export function PeopleSupportPage({ context, result }: Props) {
return ( return (
<ul className="space-y-2"> <ul className="space-y-2">
{data.page.items.map((ticket) => ( {data.page.items.map((ticket) => (
<li key={ticket.id} className="rounded border border-[var(--admin-border)] p-3"> <li
key={ticket.id}
className="rounded border border-[var(--admin-border)] p-3"
>
<a href={ticket.href}>{ticket.title}</a> <a href={ticket.href}>{ticket.title}</a>
<p>{ticket.open ? "Open" : "Closed"} · {ticket.replyCount} replies</p> <p>
{ticket.open ? "Open" : "Closed"} · {ticket.replyCount}{" "}
replies
</p>
</li> </li>
))} ))}
</ul> </ul>
@@ -153,14 +199,23 @@ export function PeopleSupportPage({ context, result }: Props) {
<div className="space-y-4"> <div className="space-y-4">
{queue ? <Queue queue={queue} /> : null} {queue ? <Queue queue={queue} /> : null}
<form method="get" className="flex gap-2"> <form method="get" className="flex gap-2">
<input name="search" maxLength={100} aria-label="Search tickets" /> <input
name="search"
maxLength={100}
aria-label="Search tickets"
/>
<button type="submit">Search</button> <button type="submit">Search</button>
</form> </form>
<ul className="space-y-2"> <ul className="space-y-2">
{data.page.items.map((ticket) => ( {data.page.items.map((ticket) => (
<li key={`${ticket.source}:${ticket.id}`} className="rounded border border-[var(--admin-border)] p-3"> <li
key={`${ticket.source}:${ticket.id}`}
className="rounded border border-[var(--admin-border)] p-3"
>
<a href={ticket.href}>{ticket.subject}</a> <a href={ticket.href}>{ticket.subject}</a>
<p>{ticket.status} · {ticket.priority}</p> <p>
{ticket.status} · {ticket.priority}
</p>
</li> </li>
))} ))}
</ul> </ul>
@@ -182,7 +237,11 @@ export async function renderPeopleSupportPage(input: HousekeepingPageInput) {
return ( return (
<PeopleSupportPage <PeopleSupportPage
context={input.context} context={input.context}
result={fail("NOT_FOUND", "errors.housekeeping.notFound", createCorrelationId())} result={fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
createCorrelationId(),
)}
/> />
); );
} }
@@ -27,63 +27,102 @@ export function PeopleTicketDetailPage({ context, result }: Props) {
result={result} result={result}
isEmpty={(data) => data.kind !== "ticket"} isEmpty={(data) => data.kind !== "ticket"}
> >
{(data) => data.kind === "ticket" ? ( {(data) =>
<article className="space-y-4"> data.kind === "ticket" ? (
<header><h2>{data.ticket.subject}</h2><p>{data.ticket.status} · {data.ticket.priority}</p></header> <article className="space-y-4">
<ul className="space-y-2"> <header>
{data.ticket.messages.map((message) => ( <h2>{data.ticket.subject}</h2>
<li key={message.id} className="rounded border border-[var(--admin-border)] p-3"> <p>
<strong>{message.username ?? `User #${message.userId}`}</strong> {data.ticket.status} · {data.ticket.priority}
<p>{message.message}</p> </p>
</li> </header>
))} <ul className="space-y-2">
</ul> {data.ticket.messages.map((message) => (
{canEdit ? ( <li
<div className="grid gap-3 lg:grid-cols-2"> key={message.id}
<PeopleCommandForm className="rounded border border-[var(--admin-border)] p-3"
commandId="people.ticket.reply" >
buttonLabel="Reply" <strong>
input={{ ticketId: data.ticket.id }} {message.username ?? `User #${message.userId}`}
fields={[{ name: "message", label: "Message", type: "text", required: true, maxLength: 5000 }]} </strong>
/> <p>{message.message}</p>
<PeopleCommandForm </li>
commandId="people.ticket.assign" ))}
buttonLabel="Assign" </ul>
input={{ ticketId: data.ticket.id }} {canEdit ? (
fields={[{ <div className="grid gap-3 lg:grid-cols-2">
name: "assigneeId", <PeopleCommandForm
label: "Assignee", commandId="people.ticket.reply"
type: "select", buttonLabel="Reply"
options: data.ticket.staff.map((staff) => ({ value: staff.id, label: staff.username })), input={{ ticketId: data.ticket.id }}
}]} fields={[
/> {
<PeopleCommandForm name: "message",
commandId="people.ticket.status" label: "Message",
buttonLabel="Change status" type: "text",
input={{ ticketId: data.ticket.id }} required: true,
fields={[{ maxLength: 5000,
name: "status", label: "Status", type: "select", },
options: ["open", "in_progress", "waiting", "closed"].map((value) => ({ value, label: value })), ]}
}]} />
/> <PeopleCommandForm
<PeopleCommandForm commandId="people.ticket.assign"
commandId="people.ticket.priority" buttonLabel="Assign"
buttonLabel="Change priority" input={{ ticketId: data.ticket.id }}
input={{ ticketId: data.ticket.id }} fields={[
fields={[{ {
name: "priority", label: "Priority", type: "select", name: "assigneeId",
options: ["low", "normal", "high", "urgent"].map((value) => ({ value, label: value })), label: "Assignee",
}]} type: "select",
/> options: data.ticket.staff.map((staff) => ({
</div> value: staff.id,
) : null} label: staff.username,
</article> })),
) : null} },
]}
/>
<PeopleCommandForm
commandId="people.ticket.status"
buttonLabel="Change status"
input={{ ticketId: data.ticket.id }}
fields={[
{
name: "status",
label: "Status",
type: "select",
options: ["open", "in_progress", "waiting", "closed"].map(
(value) => ({ value, label: value }),
),
},
]}
/>
<PeopleCommandForm
commandId="people.ticket.priority"
buttonLabel="Change priority"
input={{ ticketId: data.ticket.id }}
fields={[
{
name: "priority",
label: "Priority",
type: "select",
options: ["low", "normal", "high", "urgent"].map(
(value) => ({ value, label: value }),
),
},
]}
/>
</div>
) : null}
</article>
) : null
}
</PeoplePageFrame> </PeoplePageFrame>
); );
} }
export async function renderPeopleTicketDetailPage(input: HousekeepingPageInput) { export async function renderPeopleTicketDetailPage(
input: HousekeepingPageInput,
) {
const id = Number(input.match.params.id); const id = Number(input.match.params.id);
const result = const result =
Number.isSafeInteger(id) && id > 0 Number.isSafeInteger(id) && id > 0
@@ -91,6 +130,10 @@ export async function renderPeopleTicketDetailPage(input: HousekeepingPageInput)
routeId: "people.support.ticket-detail", routeId: "people.support.ticket-detail",
id, id,
}) })
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId()); : fail(
"VALIDATION",
"errors.housekeeping.validation",
createCorrelationId(),
);
return <PeopleTicketDetailPage context={input.context} result={result} />; return <PeopleTicketDetailPage context={input.context} result={result} />;
} }
@@ -2,10 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
import { anyCapability } from "../../foundation/contracts"; import { anyCapability } from "../../foundation/contracts";
import { import { createPeopleInboxSources, PEOPLE_INBOX_SOURCE_IDS } from "./inbox";
createPeopleInboxSources,
PEOPLE_INBOX_SOURCE_IDS,
} from "./inbox";
import { peopleManifest } from "./manifest"; import { peopleManifest } from "./manifest";
import { import {
createPeopleSearchProviders, createPeopleSearchProviders,
@@ -30,29 +27,38 @@ function context(granted: readonly string[]): HousekeepingCapabilityContext {
describe("People providers", () => { describe("People providers", () => {
it("declares exact provider IDs and no empty manifest collection", () => { it("declares exact provider IDs and no empty manifest collection", () => {
expect(PEOPLE_SEARCH_PROVIDER_IDS).toEqual([ expect(PEOPLE_SEARCH_PROVIDER_IDS).toEqual([
"people.users", "people.guilds", "people.tickets", "people.users",
"people.guilds",
"people.tickets",
]); ]);
expect(PEOPLE_INBOX_SOURCE_IDS).toEqual([ 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([ expect(
peopleManifest.routes, [
peopleManifest.searchProviders, peopleManifest.routes,
peopleManifest.inboxSources, peopleManifest.searchProviders,
peopleManifest.widgets, peopleManifest.inboxSources,
].every((collection) => collection.length > 0)).toBe(true); peopleManifest.widgets,
].every((collection) => collection.length > 0),
).toBe(true);
}); });
it("bounds search at 25 and item-filters capabilities and unsafe links", async () => { it("bounds search at 25 and item-filters capabilities and unsafe links", async () => {
const candidates = Array.from({ length: 40 }, (_, index) => ({ const candidates = Array.from({ length: 40 }, (_, index) => ({
id: `candidate-${index}`, id: `candidate-${index}`,
title: `Candidate ${index}`, title: `Candidate ${index}`,
href: index === 0 href:
? ("https://example.invalid" as const) index === 0
: (`/ase/people/users/${index + 1}` as const), ? ("https://example.invalid" as const)
capability: index === 1 : (`/ase/people/users/${index + 1}` as const),
? anyCapability(PERMS.USERS_EDIT) capability:
: anyCapability(PERMS.MOD_USERS_VIEW), index === 1
? anyCapability(PERMS.USERS_EDIT)
: anyCapability(PERMS.MOD_USERS_VIEW),
})); }));
const result = await createPeopleSearchProviders({ const result = await createPeopleSearchProviders({
users: async () => candidates, users: async () => candidates,
@@ -65,7 +71,9 @@ describe("People providers", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
expect(result.data).toHaveLength(25); 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"); expect(result.data.map((item) => item.id)).not.toContain("candidate-1");
}); });
@@ -145,18 +153,20 @@ describe("People providers", () => {
itemId: String(index), itemId: String(index),
deduplicationKey: `ticket:${index}`, deduplicationKey: `ticket:${index}`,
domain: "people" as const, domain: "people" as const,
capability: index === 0 capability:
? anyCapability(PERMS.TICKETS_EDIT) index === 0
: anyCapability(PERMS.MOD_TICKETS_VIEW), ? anyCapability(PERMS.TICKETS_EDIT)
: anyCapability(PERMS.MOD_TICKETS_VIEW),
severity: "info" as const, severity: "info" as const,
priority: "normal" as const, priority: "normal" as const,
ageMs: 0, ageMs: 0,
state: "open", state: "open",
occurredAt: "2026-08-29T00:00:00.000Z", occurredAt: "2026-08-29T00:00:00.000Z",
titleKey: "pages.housekeeping.items.ticket", titleKey: "pages.housekeeping.items.ticket",
href: index === 1 href:
? (`/ase/people/support/tickets/${index + 1}\u0000` as const) index === 1
: (`/ase/people/support/tickets/${index + 1}` as const), ? (`/ase/people/support/tickets/${index + 1}\u0000` as const)
: (`/ase/people/support/tickets/${index + 1}` as const),
freshness: "fresh" as const, freshness: "fresh" as const,
actions: [], actions: [],
})); }));
@@ -178,7 +188,10 @@ describe("People providers", () => {
const widgets = createPeopleWidgets({ const widgets = createPeopleWidgets({
queue: async () => ({ queue: async () => ({
tickets: 1, helpTickets: 2, cfh: 3, activeBans: 4, tickets: 1,
helpTickets: 2,
cfh: 3,
activeBans: 4,
}), }),
}); });
expect(widgets[0]).toMatchObject({ expect(widgets[0]).toMatchObject({
@@ -240,12 +253,18 @@ describe("People providers", () => {
for (const entry of cases) { for (const entry of cases) {
vi.clearAllMocks(); vi.clearAllMocks();
const widget = createPeopleWidgets({ queue: loader })[0]; 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, ok: true,
data: entry.want, data: entry.want,
}); });
expect( 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, entry.name,
).toEqual(entry.calls); ).toEqual(entry.calls);
} }
@@ -254,6 +273,8 @@ describe("People providers", () => {
await expect( await expect(
createPeopleWidgets({ queue: loader })[0].load(context([]), signal), createPeopleWidgets({ queue: loader })[0].load(context([]), signal),
).resolves.toMatchObject({ ok: false, error: { code: "FORBIDDEN" } }); ).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);
}); });
}); });
@@ -28,10 +28,7 @@ function context(granted: readonly string[]): HousekeepingCapabilityContext {
} }
async function load(granted: readonly string[]) { async function load(granted: readonly string[]) {
return PEOPLE_WIDGETS[0].load( return PEOPLE_WIDGETS[0].load(context(granted), new AbortController().signal);
context(granted),
new AbortController().signal,
);
} }
beforeEach(() => { beforeEach(() => {
@@ -78,10 +75,10 @@ describe("People production queue widget", () => {
.mockResolvedValueOnce([[{ total: 3 }], []]) .mockResolvedValueOnce([[{ total: 3 }], []])
.mockResolvedValueOnce([[{ total: 4 }], []]); .mockResolvedValueOnce([[{ total: 4 }], []]);
const mixed = await load([ const mixed = await load([
PERMS.MOD_TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_CFH_VIEW, PERMS.MOD_CFH_VIEW,
PERMS.MOD_BANS_VIEW, PERMS.MOD_BANS_VIEW,
]); ]);
expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1); expect(mocks.fetchTicketQueueOpenCounts).toHaveBeenCalledTimes(1);
expect(mocks.execute).toHaveBeenCalledTimes(2); expect(mocks.execute).toHaveBeenCalledTimes(2);
expect(mixed).toMatchObject({ expect(mixed).toMatchObject({
@@ -49,7 +49,9 @@ export interface PeopleSearchAdapters {
} }
function boundedLimit(limit: number): number { 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( function createProvider(
@@ -2,10 +2,7 @@ import "server-only";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Ban, type Db, SupportTickets, User } from "@/lib/db"; import { Ban, type Db, SupportTickets, User } from "@/lib/db";
import type { import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
AuditEntry,
HousekeepingAuditWriter,
} from "@/lib/services/audit";
import type { import type {
ModerationAction, ModerationAction,
ModerationActionTransport, ModerationActionTransport,
@@ -55,10 +52,7 @@ export interface PeopleModerationMutationDependencies {
snapshot: PeopleModerationMutationSnapshot, snapshot: PeopleModerationMutationSnapshot,
outcome: AuditOutcome, outcome: AuditOutcome,
) => AuditEntry; ) => AuditEntry;
readonly failure: ( readonly failure: (code: HousekeepingErrorCode, messageKey: string) => Error;
code: HousekeepingErrorCode,
messageKey: string,
) => Error;
readonly record: (input: unknown) => Record<string, unknown>; readonly record: (input: unknown) => Record<string, unknown>;
readonly positiveInteger: (value: unknown) => number; readonly positiveInteger: (value: unknown) => number;
readonly nonNegativeInteger: (value: unknown) => number; readonly nonNegativeInteger: (value: unknown) => number;
@@ -328,14 +322,7 @@ export function createPeopleModerationMutationExecutor(
}, },
}; };
await writeAudit( await writeAudit(
auditEntry( auditEntry(context, operation, "Ban", banId, snapshot, "intent"),
context,
operation,
"Ban",
banId,
snapshot,
"intent",
),
tx, tx,
); );
}); });
@@ -380,14 +367,7 @@ export function createPeopleModerationMutationExecutor(
await tx.delete(Ban).where(eq(Ban.id, id)); await tx.delete(Ban).where(eq(Ban.id, id));
snapshot = { before: existing ?? null, after: null }; snapshot = { before: existing ?? null, after: null };
await writeAudit( await writeAudit(
auditEntry( auditEntry(context, operation, "Ban", id, snapshot, "intent"),
context,
operation,
"Ban",
id,
snapshot,
"intent",
),
tx, tx,
); );
}); });
@@ -1012,25 +1012,62 @@ describe("People production workflow adapter", () => {
{ {
operation: "ticket-template.change", operation: "ticket-template.change",
input: { action: "update", id: 88, title: "Updated" }, 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, transactional: true,
}, },
{ {
operation: "help-ticket.reply", operation: "help-ticket.reply",
input: { ticketId: "9007199254740993", content: "Handled" }, 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, transactional: true,
}, },
{ {
operation: "help-ticket.status", operation: "help-ticket.status",
input: { ticketId: "9007199254740993", status: "close" }, 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, transactional: true,
}, },
{ {
operation: "help-ticket.unban", operation: "help-ticket.unban",
input: { ticketId: "9007199254740993" }, 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, transactional: true,
}, },
{ {
@@ -1041,20 +1078,32 @@ describe("People production workflow adapter", () => {
}, },
{ {
operation: "cfh.sanction", 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 }]], rows: [[{ id: 9, state: 1, modId: 8 }]],
transactional: true, transactional: true,
}, },
{ {
operation: "ban.create", 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" }]], rows: [[{ username: "Alice" }]],
transactional: true, transactional: true,
}, },
{ {
operation: "ban.lift", operation: "ban.lift",
input: { id: 12 }, 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, transactional: true,
}, },
{ {
@@ -1082,11 +1131,7 @@ describe("People production workflow adapter", () => {
action: `people.${operation}`, action: `people.${operation}`,
correlationId: `task13-${operation}`, correlationId: `task13-${operation}`,
}), }),
...( ...(transactional ? [expect.any(Object)] : []),
transactional
? [expect.any(Object)]
: []
),
); );
if (transactional) expect(mocks.transaction).toHaveBeenCalledTimes(1); if (transactional) expect(mocks.transaction).toHaveBeenCalledTimes(1);
}, },
@@ -1121,7 +1166,11 @@ describe("People production workflow adapter", () => {
["unmute", { action: "unmute", userId: 7 }, "unmuteUser"], ["unmute", { action: "unmute", userId: 7 }, "unmuteUser"],
["alert", { action: "alert", userId: 7, message: "Stop" }, "alertUser"], ["alert", { action: "alert", userId: 7, message: "Stop" }, "alertUser"],
["room-kick", { action: "room-kick", roomId: 12 }, "kickAll"], ["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)( ] as const)(
"preserves legacy confirmed-false success for %s while recording an observed outcome", "preserves legacy confirmed-false success for %s while recording an observed outcome",
async (_label, input, transport) => { async (_label, input, transport) => {
@@ -1163,7 +1212,9 @@ describe("People production workflow adapter", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.resolveServerContext.mockResolvedValue(context()); mocks.resolveServerContext.mockResolvedValue(context());
mocks.audit.mockResolvedValue(undefined); mocks.audit.mockResolvedValue(undefined);
mocks.rcon.disconnectUser.mockRejectedValueOnce(new Error("RCON unavailable")); mocks.rcon.disconnectUser.mockRejectedValueOnce(
new Error("RCON unavailable"),
);
await expect( await expect(
peopleMutationService.execute( peopleMutationService.execute(
{ ...invocation, correlationId: "legacy-throw" }, { ...invocation, correlationId: "legacy-throw" },
@@ -11,10 +11,7 @@ import {
WebsiteTicketMessage, WebsiteTicketMessage,
WebsiteTicketTemplate, WebsiteTicketTemplate,
} from "@/lib/db"; } from "@/lib/db";
import type { import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit";
AuditEntry,
HousekeepingAuditWriter,
} from "@/lib/services/audit";
import type { import type {
HousekeepingCapabilityContext, HousekeepingCapabilityContext,
HousekeepingErrorCode, HousekeepingErrorCode,
@@ -62,10 +59,7 @@ export interface PeopleSupportMutationDependencies {
snapshot: PeopleSupportMutationSnapshot, snapshot: PeopleSupportMutationSnapshot,
outcome: AuditOutcome, outcome: AuditOutcome,
) => AuditEntry; ) => AuditEntry;
readonly failure: ( readonly failure: (code: HousekeepingErrorCode, messageKey: string) => Error;
code: HousekeepingErrorCode,
messageKey: string,
) => Error;
readonly record: (input: unknown) => Record<string, unknown>; readonly record: (input: unknown) => Record<string, unknown>;
readonly positiveInteger: (value: unknown) => number; readonly positiveInteger: (value: unknown) => number;
readonly nonNegativeInteger: (value: unknown) => number; readonly nonNegativeInteger: (value: unknown) => number;
@@ -74,9 +68,7 @@ export interface PeopleSupportMutationDependencies {
max: number, max: number,
required?: boolean, required?: boolean,
) => string; ) => string;
readonly canonicalTicketId: ( readonly canonicalTicketId: (value: string | number | bigint) => bigint;
value: string | number | bigint,
) => bigint;
readonly auditTargetId: (value: bigint) => number | undefined; readonly auditTargetId: (value: bigint) => number | undefined;
} }
@@ -299,19 +291,14 @@ export function createPeopleSupportMutationExecutor(
} }
async function executeHelpTicketMutation( async function executeHelpTicketMutation(
operation: Extract< operation: Extract<PeopleSupportMutationOperation, `help-ticket.${string}`>,
PeopleSupportMutationOperation,
`help-ticket.${string}`
>,
input: unknown, input: unknown,
context: PeopleSupportMutationContext, context: PeopleSupportMutationContext,
): Promise<PeopleSupportMutationSnapshot> { ): Promise<PeopleSupportMutationSnapshot> {
const data = record(input); const data = record(input);
let ticketId: bigint; let ticketId: bigint;
try { try {
ticketId = canonicalTicketId( ticketId = canonicalTicketId(data.ticketId as string | number | bigint);
data.ticketId as string | number | bigint,
);
} catch { } catch {
throw failure("VALIDATION", "errors.housekeeping.validation"); throw failure("VALIDATION", "errors.housekeeping.validation");
} }
@@ -354,11 +341,7 @@ export function createPeopleSupportMutationExecutor(
} else if (operation === "help-ticket.status") { } else if (operation === "help-ticket.status") {
const status = normalizedText(data.status, 16); const status = normalizedText(data.status, 16);
const open = const open =
status === "reopen" status === "reopen" ? true : status === "close" ? false : null;
? true
: status === "close"
? false
: null;
if (open === null) { if (open === null) {
throw failure("VALIDATION", "errors.housekeeping.validation"); throw failure("VALIDATION", "errors.housekeeping.validation");
} }
@@ -35,10 +35,7 @@ const cfhQueueCapability = anyCapability(
PERMS.MODERATION_VIEW, PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW, PERMS.MOD_CFH_VIEW,
); );
const banQueueCapability = anyCapability( const banQueueCapability = anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW);
PERMS.BANS_VIEW,
PERMS.MOD_BANS_VIEW,
);
const queueCapability = anyCapability( const queueCapability = anyCapability(
...supportQueueCapability.slugs, ...supportQueueCapability.slugs,
...cfhQueueCapability.slugs, ...cfhQueueCapability.slugs,
@@ -82,42 +79,44 @@ export function createPeopleQueueAggregateLoader(
export function createPeopleWidgets( export function createPeopleWidgets(
adapters: PeopleWidgetAdapters, adapters: PeopleWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] { ): readonly HousekeepingWidgetDefinition[] {
return [{ return [
id: "people.queue", {
owner: "people", id: "people.queue",
capability: queueCapability, owner: "people",
kind: "mandatory", capability: queueCapability,
async load(context, signal) { kind: "mandatory",
const authorization = authorizeHousekeeping(context, queueCapability); async load(context, signal) {
if (!authorization.ok) return authorization; const authorization = authorizeHousekeeping(context, queueCapability);
try { if (!authorization.ok) return authorization;
const queue = await adapters.queue(context, signal); try {
return ok( const queue = await adapters.queue(context, signal);
{ return ok(
tickets: satisfiesCapability(context, supportQueueCapability) {
? queue.tickets tickets: satisfiesCapability(context, supportQueueCapability)
: 0, ? queue.tickets
helpTickets: satisfiesCapability(context, supportQueueCapability) : 0,
? queue.helpTickets helpTickets: satisfiesCapability(context, supportQueueCapability)
: 0, ? queue.helpTickets
cfh: satisfiesCapability(context, cfhQueueCapability) : 0,
? queue.cfh cfh: satisfiesCapability(context, cfhQueueCapability)
: 0, ? queue.cfh
activeBans: satisfiesCapability(context, banQueueCapability) : 0,
? queue.activeBans activeBans: satisfiesCapability(context, banQueueCapability)
: 0, ? queue.activeBans
}, : 0,
authorization.correlationId, },
); authorization.correlationId,
} catch { );
return fail( } catch {
"DEPENDENCY_UNAVAILABLE", return fail(
"errors.housekeeping.dependencyUnavailable", "DEPENDENCY_UNAVAILABLE",
authorization.correlationId, "errors.housekeeping.dependencyUnavailable",
); authorization.correlationId,
} );
}
},
}, },
}]; ];
} }
export const PEOPLE_WIDGETS = createPeopleWidgets({ export const PEOPLE_WIDGETS = createPeopleWidgets({
@@ -1113,7 +1113,9 @@ describe("housekeeping foundation completion contracts", () => {
]); ]);
expect( expect(
registry.domains registry.domains
.filter((domain) => !["people", "content", "system"].includes(domain.id)) .filter(
(domain) => !["people", "content", "system"].includes(domain.id),
)
.every((domain) => domain.routes.length === 0), .every((domain) => domain.routes.length === 0),
).toBe(true); ).toBe(true);
expect( expect(
@@ -64,7 +64,6 @@ export function isSafeHousekeepingHref(href: string): boolean {
} }
return ( return (
normalized.origin === HOUSEKEEPING_ORIGIN && normalized.origin === HOUSEKEEPING_ORIGIN &&
(normalized.pathname === "/ase" || (normalized.pathname === "/ase" || normalized.pathname.startsWith("/ase/"))
normalized.pathname.startsWith("/ase/"))
); );
} }
@@ -60,12 +60,10 @@ const routeMocks = vi.hoisted(() => {
"routes.content.help.email-templates": "HK::content-help-email-templates", "routes.content.help.email-templates": "HK::content-help-email-templates",
"routes.content.brand.theme": "HK::content-brand-theme", "routes.content.brand.theme": "HK::content-brand-theme",
"routes.content.brand.favicon": "HK::content-brand-favicon", "routes.content.brand.favicon": "HK::content-brand-favicon",
"routes.content.localization.overview": "routes.content.localization.overview": "HK::content-localization-overview",
"HK::content-localization-overview",
"routes.content.localization.client": "HK::content-localization-client", "routes.content.localization.client": "HK::content-localization-client",
"routes.content.localization.cms": "HK::content-localization-cms", "routes.content.localization.cms": "HK::content-localization-cms",
"routes.content.localization.emulator": "routes.content.localization.emulator": "HK::content-localization-emulator",
"HK::content-localization-emulator",
"routes.system.access.permissions": "HK::system-access-permissions", "routes.system.access.permissions": "HK::system-access-permissions",
"routes.system.access.permission-detail": "routes.system.access.permission-detail":
"HK::system-access-permission-detail", "HK::system-access-permission-detail",
@@ -367,9 +367,9 @@ describe("housekeeping registry", () => {
? PEOPLE_ROUTES ? PEOPLE_ROUTES
: expected.id === "content" : expected.id === "content"
? CONTENT_ROUTES ? CONTENT_ROUTES
: expected.id === "system" : expected.id === "system"
? SYSTEM_ROUTES ? SYSTEM_ROUTES
: [], : [],
); );
expect(actual.searchProviders).toEqual( expect(actual.searchProviders).toEqual(
expected.id === "people" expected.id === "people"