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