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