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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
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;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -12,7 +12,11 @@ import {
|
||||
} from "./routes";
|
||||
|
||||
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",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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} />;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -288,9 +288,9 @@ describe("People primary route registration", () => {
|
||||
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
|
||||
).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));
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -28,10 +28,7 @@ function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
}
|
||||
|
||||
async function load(granted: readonly string[]) {
|
||||
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
File diff suppressed because it is too large.
Load diff
@@ -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