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
61 files changed
+1763 -512

No files matched your search

File diff suppressed because it is too large. Load diff
+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");
} }
File diff suppressed because it is too large. Load diff
+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
File diff suppressed because it is too large. Load diff
@@ -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",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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,15 +58,45 @@ 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",
{ action: "create", title: "Greeting", content: "Hello" },
"ticket-template.change",
],
[
"people.help-ticket.reply",
{ ticketId: "9007199254740993", content: "Handled" },
"help-ticket.reply",
],
[
"people.help-ticket.status",
{ ticketId: "9007199254740993", status: "close" },
"help-ticket.status",
],
[
"people.help-ticket.status",
{ ticketId: "9007199254740993", status: "reopen" },
"help-ticket.status",
],
[
"people.help-ticket.unban",
{ ticketId: "9007199254740993" },
"help-ticket.unban",
],
] as const)(
"delegates %s to the redirect-free %s operation",
async (id, input, operation) => {
const execute = vi.fn(async (invocation) => ({ const execute = vi.fn(async (invocation) => ({
ok: true as const, ok: true as const,
data: { before: null, after: null }, data: { before: null, after: null },
@@ -100,5 +130,6 @@ describe("People support commands", () => {
operation, operation,
parsed, 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",
File diff suppressed because it is too large. Load diff
@@ -27,12 +27,19 @@ 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) =>
data.kind === "cfh-detail" ? (
<article className="space-y-4"> <article className="space-y-4">
<h2>CFH #{data.ticket.id}</h2> <h2>CFH #{data.ticket.id}</h2>
<p>{data.ticket.issue}</p> <p>{data.ticket.issue}</p>
<p>Reporter: {data.ticket.senderUsername ?? `#${data.ticket.senderId}`}</p> <p>
<p>Reported: {data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}</p> Reporter:{" "}
{data.ticket.senderUsername ?? `#${data.ticket.senderId}`}
</p>
<p>
Reported:{" "}
{data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}
</p>
{canEdit ? ( {canEdit ? (
<> <>
<PeopleCommandForm <PeopleCommandForm
@@ -43,11 +50,34 @@ export function PeopleCfhDetailPage({ context, result }: Props) {
<PeopleCommandForm <PeopleCommandForm
commandId="people.cfh.sanction" commandId="people.cfh.sanction"
buttonLabel="Apply sanction" buttonLabel="Apply sanction"
input={{ ticketId: data.ticket.id, userId: data.ticket.reportedId }} input={{
ticketId: data.ticket.id,
userId: data.ticket.reportedId,
}}
fields={[ fields={[
{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: "action",
{name: "message", label: "Message", type: "text", maxLength: 500}, 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 requiresReason
includeReasonInInput includeReasonInInput
@@ -55,18 +85,24 @@ export function PeopleCfhDetailPage({ context, result }: Props) {
</> </>
) : null} ) : null}
</article> </article>
) : null} ) : 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 =
Number.isSafeInteger(id) && id > 0
? await peopleModerationQuery.run(input.context, { ? await peopleModerationQuery.run(input.context, {
routeId: "people.moderation.cfh-detail", routeId: "people.moderation.cfh-detail",
id, 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,12 +28,19 @@ 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) =>
data.kind === "help-ticket" ? (
<article className="space-y-4"> <article className="space-y-4">
<header><h2>{data.ticket.title}</h2><p>{data.ticket.content}</p></header> <header>
<h2>{data.ticket.title}</h2>
<p>{data.ticket.content}</p>
</header>
<ul> <ul>
{data.ticket.replies.map((reply) => ( {data.ticket.replies.map((reply) => (
<li key={reply.id}><strong>{reply.username ?? `User #${reply.userId}`}</strong>: {reply.content}</li> <li key={reply.id}>
<strong>{reply.username ?? `User #${reply.userId}`}</strong>:{" "}
{reply.content}
</li>
))} ))}
</ul> </ul>
{canEdit ? ( {canEdit ? (
@@ -42,12 +49,25 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) {
commandId="people.help-ticket.reply" commandId="people.help-ticket.reply"
buttonLabel="Reply" buttonLabel="Reply"
input={{ ticketId: data.ticket.id }} input={{ ticketId: data.ticket.id }}
fields={[{ name: "content", label: "Reply", type: "text", required: true, maxLength: 5000 }]} fields={[
{
name: "content",
label: "Reply",
type: "text",
required: true,
maxLength: 5000,
},
]}
/> />
<PeopleCommandForm <PeopleCommandForm
commandId="people.help-ticket.status" commandId="people.help-ticket.status"
buttonLabel={data.ticket.open ? "Close ticket" : "Reopen ticket"} buttonLabel={
input={{ ticketId: data.ticket.id, status: data.ticket.open ? "close" : "reopen" }} data.ticket.open ? "Close ticket" : "Reopen ticket"
}
input={{
ticketId: data.ticket.id,
status: data.ticket.open ? "close" : "reopen",
}}
/> />
</> </>
) : null} ) : null}
@@ -60,12 +80,15 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) {
/> />
) : null} ) : null}
</article> </article>
) : null} ) : 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} />;
} }
File diff suppressed because it is too large. Load diff
@@ -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));
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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(() => {
@@ -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,
); );
}); });
@@ -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,7 +79,8 @@ export function createPeopleQueueAggregateLoader(
export function createPeopleWidgets( export function createPeopleWidgets(
adapters: PeopleWidgetAdapters, adapters: PeopleWidgetAdapters,
): readonly HousekeepingWidgetDefinition[] { ): readonly HousekeepingWidgetDefinition[] {
return [{ return [
{
id: "people.queue", id: "people.queue",
owner: "people", owner: "people",
capability: queueCapability, capability: queueCapability,
@@ -117,7 +115,8 @@ export function createPeopleWidgets(
); );
} }
}, },
}]; },
];
} }
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",