style: satisfy housekeeping biome gate
This commit is contained in:
1 parent
d1160eb65a
commit
08358b8aa4
61 files changed
+1763
-512
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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() },
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
: "";
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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: "" }));
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +58,45 @@ 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) => {
|
||||
[
|
||||
"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 },
|
||||
@@ -100,5 +130,6 @@ describe("People support commands", () => {
|
||||
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,
|
||||
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,11 +167,18 @@ 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 ? [] : [{
|
||||
return date === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
sourceId: "people.help-tickets",
|
||||
itemId: ticket.id,
|
||||
deduplicationKey: `help-ticket:${ticket.id}`,
|
||||
@@ -173,7 +192,8 @@ const productionAdapters: PeopleInboxAdapters = {
|
||||
context: { title: ticket.title },
|
||||
href: ticket.href,
|
||||
actions: [],
|
||||
}];
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
async cfh(context) {
|
||||
@@ -185,7 +205,10 @@ 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 ? [] : [{
|
||||
return date === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
sourceId: "people.cfh",
|
||||
itemId: String(ticket.id),
|
||||
deduplicationKey: `cfh:${ticket.id}`,
|
||||
@@ -199,7 +222,8 @@ const productionAdapters: PeopleInboxAdapters = {
|
||||
context: { issue: ticket.issue },
|
||||
href: ticket.href,
|
||||
actions: [],
|
||||
}];
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
async activeBans(context) {
|
||||
@@ -211,7 +235,10 @@ 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 ? [] : [{
|
||||
return date === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
sourceId: "people.active-bans",
|
||||
itemId: String(ban.id),
|
||||
deduplicationKey: `ban:${ban.id}`,
|
||||
@@ -225,7 +252,8 @@ const productionAdapters: PeopleInboxAdapters = {
|
||||
context: { userId: ban.userId, reason: ban.reason },
|
||||
href: `/ase/people/users/${ban.userId}` as const,
|
||||
actions: [],
|
||||
}];
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,12 +27,19 @@ export function PeopleCfhDetailPage({ context, result }: Props) {
|
||||
result={result}
|
||||
isEmpty={(data) => data.kind !== "cfh-detail"}
|
||||
>
|
||||
{(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>
|
||||
<p>
|
||||
Reporter:{" "}
|
||||
{data.ticket.senderUsername ?? `#${data.ticket.senderId}`}
|
||||
</p>
|
||||
<p>
|
||||
Reported:{" "}
|
||||
{data.ticket.reportedUsername ?? `#${data.ticket.reportedId}`}
|
||||
</p>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<PeopleCommandForm
|
||||
@@ -43,11 +50,34 @@ export function PeopleCfhDetailPage({ context, result }: Props) {
|
||||
<PeopleCommandForm
|
||||
commandId="people.cfh.sanction"
|
||||
buttonLabel="Apply sanction"
|
||||
input={{ ticketId: data.ticket.id, userId: data.ticket.reportedId }}
|
||||
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},
|
||||
{
|
||||
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
|
||||
@@ -55,18 +85,24 @@ export function PeopleCfhDetailPage({ context, result }: Props) {
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
) : null
|
||||
}
|
||||
</PeoplePageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderPeopleCfhDetailPage(input: HousekeepingPageInput) {
|
||||
const id = Number(input.match.params.id);
|
||||
const result = Number.isSafeInteger(id) && id > 0
|
||||
const result =
|
||||
Number.isSafeInteger(id) && id > 0
|
||||
? await peopleModerationQuery.run(input.context, {
|
||||
routeId: "people.moderation.cfh-detail",
|
||||
id,
|
||||
})
|
||||
: fail("VALIDATION", "errors.housekeeping.validation", createCorrelationId());
|
||||
: fail(
|
||||
"VALIDATION",
|
||||
"errors.housekeeping.validation",
|
||||
createCorrelationId(),
|
||||
);
|
||||
return <PeopleCfhDetailPage context={input.context} result={result} />;
|
||||
}
|
||||
@@ -28,12 +28,19 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) {
|
||||
result={result}
|
||||
isEmpty={(data) => data.kind !== "help-ticket"}
|
||||
>
|
||||
{(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>
|
||||
<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>
|
||||
<li key={reply.id}>
|
||||
<strong>{reply.username ?? `User #${reply.userId}`}</strong>:{" "}
|
||||
{reply.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canEdit ? (
|
||||
@@ -42,12 +49,25 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) {
|
||||
commandId="people.help-ticket.reply"
|
||||
buttonLabel="Reply"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{ name: "content", label: "Reply", type: "text", required: true, maxLength: 5000 }]}
|
||||
fields={[
|
||||
{
|
||||
name: "content",
|
||||
label: "Reply",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 5000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.help-ticket.status"
|
||||
buttonLabel={data.ticket.open ? "Close ticket" : "Reopen ticket"}
|
||||
input={{ ticketId: data.ticket.id, status: data.ticket.open ? "close" : "reopen" }}
|
||||
buttonLabel={
|
||||
data.ticket.open ? "Close ticket" : "Reopen ticket"
|
||||
}
|
||||
input={{
|
||||
ticketId: data.ticket.id,
|
||||
status: data.ticket.open ? "close" : "reopen",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -60,12 +80,15 @@ export function PeopleHelpTicketDetailPage({ context, result }: Props) {
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
) : 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: "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: "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: "type",
|
||||
label: "Audience",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "hotel", label: "hotel" },
|
||||
{ value: "staff", label: "staff" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -50,22 +76,35 @@ export function PeopleModerationPage({ context, result }: Props) {
|
||||
isEmpty={isEmpty}
|
||||
>
|
||||
{(data) => {
|
||||
if (data.kind === "overview") return (
|
||||
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>
|
||||
<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>
|
||||
))}</ul>
|
||||
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 (
|
||||
if (data.kind === "bans")
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{context.has(PERMS.USERS_BAN) ? (
|
||||
<PeopleCommandForm
|
||||
@@ -73,27 +112,79 @@ export function PeopleModerationPage({ context, result }: Props) {
|
||||
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}))},
|
||||
{
|
||||
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) => (
|
||||
<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 />
|
||||
<PeopleCommandForm
|
||||
commandId="people.ban.lift"
|
||||
buttonLabel="Lift ban"
|
||||
input={{ id: ban.id }}
|
||||
requiresReason
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}</ul>
|
||||
))}
|
||||
</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>;
|
||||
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,7 +35,8 @@ describe("People support/moderation pages", () => {
|
||||
{
|
||||
kind: "tickets" as const,
|
||||
page: {
|
||||
items: [{
|
||||
items: [
|
||||
{
|
||||
source: "cms" as const,
|
||||
id: 7,
|
||||
subject: "Need help",
|
||||
@@ -48,7 +46,8 @@ describe("People support/moderation pages", () => {
|
||||
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: [{
|
||||
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,13 +27,24 @@ export function PeopleTicketDetailPage({ context, result }: Props) {
|
||||
result={result}
|
||||
isEmpty={(data) => data.kind !== "ticket"}
|
||||
>
|
||||
{(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>
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -44,46 +55,74 @@ export function PeopleTicketDetailPage({ context, result }: Props) {
|
||||
commandId="people.ticket.reply"
|
||||
buttonLabel="Reply"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{ name: "message", label: "Message", type: "text", required: true, maxLength: 5000 }]}
|
||||
fields={[
|
||||
{
|
||||
name: "message",
|
||||
label: "Message",
|
||||
type: "text",
|
||||
required: true,
|
||||
maxLength: 5000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PeopleCommandForm
|
||||
commandId="people.ticket.assign"
|
||||
buttonLabel="Assign"
|
||||
input={{ ticketId: data.ticket.id }}
|
||||
fields={[{
|
||||
fields={[
|
||||
{
|
||||
name: "assigneeId",
|
||||
label: "Assignee",
|
||||
type: "select",
|
||||
options: data.ticket.staff.map((staff) => ({ value: staff.id, label: staff.username })),
|
||||
}]}
|
||||
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 })),
|
||||
}]}
|
||||
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 })),
|
||||
}]}
|
||||
fields={[
|
||||
{
|
||||
name: "priority",
|
||||
label: "Priority",
|
||||
type: "select",
|
||||
options: ["low", "normal", "high", "urgent"].map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
) : null}
|
||||
) : 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,27 +27,36 @@ 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([
|
||||
expect(
|
||||
[
|
||||
peopleManifest.routes,
|
||||
peopleManifest.searchProviders,
|
||||
peopleManifest.inboxSources,
|
||||
peopleManifest.widgets,
|
||||
].every((collection) => collection.length > 0)).toBe(true);
|
||||
].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
|
||||
href:
|
||||
index === 0
|
||||
? ("https://example.invalid" as const)
|
||||
: (`/ase/people/users/${index + 1}` as const),
|
||||
capability: index === 1
|
||||
capability:
|
||||
index === 1
|
||||
? anyCapability(PERMS.USERS_EDIT)
|
||||
: anyCapability(PERMS.MOD_USERS_VIEW),
|
||||
}));
|
||||
@@ -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,7 +153,8 @@ describe("People providers", () => {
|
||||
itemId: String(index),
|
||||
deduplicationKey: `ticket:${index}`,
|
||||
domain: "people" as const,
|
||||
capability: index === 0
|
||||
capability:
|
||||
index === 0
|
||||
? anyCapability(PERMS.TICKETS_EDIT)
|
||||
: anyCapability(PERMS.MOD_TICKETS_VIEW),
|
||||
severity: "info" as const,
|
||||
@@ -154,7 +163,8 @@ describe("People providers", () => {
|
||||
state: "open",
|
||||
occurredAt: "2026-08-29T00:00:00.000Z",
|
||||
titleKey: "pages.housekeeping.items.ticket",
|
||||
href: index === 1
|
||||
href:
|
||||
index === 1
|
||||
? (`/ase/people/support/tickets/${index + 1}\u0000` as const)
|
||||
: (`/ase/people/support/tickets/${index + 1}` as const),
|
||||
freshness: "fresh" as const,
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
+65
-14
@@ -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,7 +79,8 @@ export function createPeopleQueueAggregateLoader(
|
||||
export function createPeopleWidgets(
|
||||
adapters: PeopleWidgetAdapters,
|
||||
): readonly HousekeepingWidgetDefinition[] {
|
||||
return [{
|
||||
return [
|
||||
{
|
||||
id: "people.queue",
|
||||
owner: "people",
|
||||
capability: queueCapability,
|
||||
@@ -117,7 +115,8 @@ export function createPeopleWidgets(
|
||||
);
|
||||
}
|
||||
},
|
||||
}];
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user