feat(housekeeping): deliver content vertical
This commit is contained in:
1 parent
bdab924bdf
commit
fd68819d9b
67 files changed
+5742
-1916
No files matched your search
@@ -1,98 +1,62 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
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 { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { ActionError } from "@/lib/safe-action-shared";
|
import { ActionError } from "@/lib/safe-action-shared";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
import { createAd, deleteAd } from "./admin-ads";
|
import { createAd, deleteAd } from "./admin-ads";
|
||||||
|
|
||||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
const { execute } = vi.hoisted(() => ({
|
||||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" })),
|
||||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
}));
|
||||||
return { insertValues, deleteWhere };
|
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("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
update: vi.fn(() => ({
|
|
||||||
set: vi.fn(() => ({
|
|
||||||
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
},
|
|
||||||
WebsiteAds: { id: "id" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler }));
|
||||||
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
vi.mock("@/lib/safe-action-shared", () => ({ ActionError: class ActionError extends Error {}, actionOk: () => "ok" }));
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({
|
|
||||||
ActionError: class extends Error {},
|
|
||||||
actionOk: vi.fn(() => "ok"),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
|
||||||
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() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
const fakeForm = (data) => ({ get: (key) => data[key] ?? null });
|
||||||
get: (k: string) => data[k] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff);
|
||||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" });
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createAd", () => {
|
describe("Content advertisement legacy wrappers", () => {
|
||||||
it("creates ad and redirects", async () => {
|
it("delegates creation and preserves redirect", async () => {
|
||||||
await createAd(
|
await createAd(fakeForm({ image: "https://example.com/ad.png" }));
|
||||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ expectedActorId: 1, legacy: true }), "ad.change", { action: "create", image: "https://example.com/ad.png" });
|
||||||
);
|
|
||||||
expect(insertValues).toHaveBeenCalled();
|
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when image empty", async () => {
|
it("returns early when image is empty", async () => {
|
||||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
await createAd(fakeForm({ image: "" }));
|
||||||
expect(insertValues).not.toHaveBeenCalled();
|
expect(execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs error on db failure", async () => {
|
it("logs a redacted service failure", async () => {
|
||||||
insertValues.mockRejectedValue(new Error("db"));
|
execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" });
|
||||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
await createAd(fakeForm({ image: "x" }));
|
||||||
expect(logger.error).toHaveBeenCalled();
|
expect(logger.error).toHaveBeenCalledWith("Action failed: createAd", expect.objectContaining({ error: "errors.housekeeping.dependencyUnavailable" }));
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("deleteAd", () => {
|
it("delegates deletion and preserves action result", async () => {
|
||||||
it("deletes ad and returns ok", async () => {
|
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
|
||||||
const h = deleteAd as unknown as (ctx: {
|
await expect(handler({ data: { id: 99n }, session: { user: { id: "1" } }, requestId: "delete" })).resolves.toBe("ok");
|
||||||
data: { id: bigint };
|
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ correlationId: "delete" }), "ad.change", { action: "delete", id: "99" });
|
||||||
session: { user: { id: string } };
|
|
||||||
}) => Promise<string>;
|
|
||||||
expect(
|
|
||||||
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
|
||||||
).toBe("ok");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws ActionError when not found", async () => {
|
it("preserves not-found ActionError", async () => {
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
execute.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" }, correlationId: "legacy" });
|
||||||
const h = deleteAd as unknown as (ctx: {
|
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
|
||||||
data: { id: bigint };
|
await expect(handler({ data: { id: 999n }, session: { user: { id: "1" } }, requestId: "missing" })).rejects.toThrow(ActionError);
|
||||||
session: { user: { id: string } };
|
|
||||||
}) => Promise<string>;
|
|
||||||
await expect(
|
|
||||||
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
|
||||||
).rejects.toThrow(ActionError);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+30
-79
@@ -1,48 +1,30 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteAds } from "@/lib/db";
|
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
|
||||||
// table; it only stores an image URL rendered in the site layout/widgets.
|
|
||||||
|
|
||||||
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") ?? "")
|
const image = String(formData.get("image") ?? "").normalize("NFC").trim().slice(0, 255);
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!image) return;
|
if (!image) return;
|
||||||
|
const result = await contentMutationService.execute(
|
||||||
const now = new Date();
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
try {
|
"ad.change",
|
||||||
const [result] = (await db.insert(WebsiteAds).values({
|
{ action: "create", image },
|
||||||
image,
|
);
|
||||||
createdAt: now,
|
if (!result.ok) {
|
||||||
updatedAt: now,
|
logger.error("Action failed: createAd", { action: "createAd", error: result.error.messageKey });
|
||||||
})) as unknown as [ResultSetHeader];
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "ad_create",
|
|
||||||
description: `Created advertisement #${result.insertId} (${image})`,
|
|
||||||
targetType: "website_ad",
|
|
||||||
targetId: Number(result.insertId),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("Action failed: createAd", {
|
|
||||||
action: "createAd",
|
|
||||||
error: err instanceof Error ? err.message : "DB error",
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/ads");
|
revalidatePath("/admin/ads");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,66 +34,35 @@ export async function createAd(formData: FormData): Promise<void> {
|
|||||||
export async function updateAd(formData: FormData): Promise<void> {
|
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+$/.test(raw)) return;
|
if (!/^\d+$/u.test(raw)) return;
|
||||||
const id = BigInt(raw);
|
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(
|
||||||
try {
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
await db
|
"ad.change",
|
||||||
.update(WebsiteAds)
|
{ action: "update", id: raw, image },
|
||||||
.set({ image, updatedAt: new Date() })
|
);
|
||||||
.where(eq(WebsiteAds.id, id));
|
if (!result.ok) {
|
||||||
await logStaffActivity({
|
logger.error("Action failed: updateAd", { action: "updateAd", id: Number(raw), error: result.error.messageKey });
|
||||||
staffId: staff.id,
|
revalidatePath("/admin/ads/" + raw);
|
||||||
action: "ad_update",
|
|
||||||
description: `Updated advertisement #${id} (${image})`,
|
|
||||||
targetType: "website_ad",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("Action failed: updateAd", {
|
|
||||||
action: "updateAd",
|
|
||||||
id: Number(id),
|
|
||||||
error: err instanceof Error ? err.message : "DB error",
|
|
||||||
});
|
|
||||||
revalidatePath(`/admin/ads/${id}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/admin/ads");
|
redirect("/admin/ads");
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteAdInput = z.object({
|
const deleteAdInput = z.object({
|
||||||
id: z
|
id: z.union([z.string(), z.number(), z.bigint()]).transform((value) => BigInt(String(value))),
|
||||||
.union([z.string(), z.number(), z.bigint()])
|
|
||||||
.transform((v) => BigInt(String(v))),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 id = ctx.data.id;
|
const result = await contentMutationService.execute(
|
||||||
try {
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
const [result] = (await db
|
"ad.change",
|
||||||
.delete(WebsiteAds)
|
{ action: "delete", id: ctx.data.id.toString() },
|
||||||
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
|
);
|
||||||
if (!result.affectedRows) {
|
if (!result.ok) throw new ActionError("Advertisement not found");
|
||||||
throw new ActionError("Advertisement not found");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ActionError) throw err;
|
|
||||||
throw new ActionError("Advertisement not found");
|
|
||||||
}
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: Number(ctx.session.user.id),
|
|
||||||
action: "ad_delete",
|
|
||||||
description: `Deleted advertisement #${id}`,
|
|
||||||
targetType: "website_ad",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/ads");
|
revalidatePath("/admin/ads");
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,120 +1,61 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
|
||||||
import {
|
import {
|
||||||
db,
|
contentMutationService,
|
||||||
WebsiteArticleComments,
|
createContentMutationInvocation,
|
||||||
WebsiteArticleReactions,
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
WebsiteArticles,
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
} from "@/lib/db";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { slugify } from "@/lib/format";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
async function uniqueSlug(title: string): Promise<string> {
|
function articleInput(formData: FormData) {
|
||||||
const base = slugify(title);
|
return {
|
||||||
let slug = base;
|
title: String(formData.get("title") ?? "").normalize("NFC").trim(),
|
||||||
let n = 2;
|
shortStory: String(formData.get("shortStory") ?? "").normalize("NFC").trim(),
|
||||||
for (;;) {
|
fullStory: String(formData.get("fullStory") ?? "").normalize("NFC").trim(),
|
||||||
const [existing] = await db
|
image: String(formData.get("image") ?? "").normalize("NFC").trim(),
|
||||||
.select({ id: WebsiteArticles.id })
|
slug: String(formData.get("slug") ?? "").trim(),
|
||||||
.from(WebsiteArticles)
|
};
|
||||||
.where(eq(WebsiteArticles.slug, slug))
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) return slug;
|
|
||||||
slug = `${base}-${n++}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createArticle(formData: FormData): Promise<void> {
|
export async function createArticle(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const title = String(formData.get("title") ?? "")
|
const input = articleInput(formData);
|
||||||
.normalize("NFC")
|
if (!input.title) return;
|
||||||
.trim();
|
const result = await contentMutationService.execute(
|
||||||
const shortStory = String(formData.get("shortStory") ?? "")
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
.normalize("NFC")
|
"article.change",
|
||||||
.trim();
|
{ action: "create", ...input },
|
||||||
const fullStory = String(formData.get("fullStory") ?? "")
|
);
|
||||||
.normalize("NFC")
|
if (!result.ok) {
|
||||||
.trim();
|
redirect("/admin/articles/new?error=Database error while creating article. Please try again.");
|
||||||
const image = String(formData.get("image") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const now = new Date();
|
|
||||||
await db.insert(WebsiteArticles).values({
|
|
||||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
|
||||||
title: title.slice(0, 255),
|
|
||||||
shortStory: shortStory.slice(0, 255),
|
|
||||||
fullStory,
|
|
||||||
image: image.slice(0, 255),
|
|
||||||
userId: staff.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Database error — re-render unchanged with error.
|
|
||||||
redirect(
|
|
||||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
redirect("/admin/articles");
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateArticle(formData: FormData): Promise<void> {
|
export async function updateArticle(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = String(formData.get("id") ?? "");
|
||||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
const result = await contentMutationService.execute(
|
||||||
try {
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
await db
|
"article.change",
|
||||||
.update(WebsiteArticles)
|
{ action: "update", id, ...articleInput(formData) },
|
||||||
.set({
|
);
|
||||||
title: String(formData.get("title") ?? "")
|
if (!result.ok) redirect("/admin/articles?error=Update failed");
|
||||||
.normalize("NFC")
|
revalidatePath("/admin/articles/" + id);
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
|
||||||
shortStory: String(formData.get("shortStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
fullStory: String(formData.get("fullStory") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim(),
|
|
||||||
image: String(formData.get("image") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(WebsiteArticles.id, id));
|
|
||||||
} catch {
|
|
||||||
redirect("/admin/articles?error=Update failed");
|
|
||||||
}
|
|
||||||
revalidatePath(`/admin/articles/${id}`);
|
|
||||||
redirect("/admin/articles");
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.NEWS_EDIT);
|
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||||
const id = BigInt(String(formData.get("id")));
|
const id = String(formData.get("id") ?? "");
|
||||||
try {
|
const result = await contentMutationService.execute(
|
||||||
await db.transaction(async (tx) => {
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
await tx
|
"article.change",
|
||||||
.delete(WebsiteArticleReactions)
|
{ action: "delete", id },
|
||||||
.where(eq(WebsiteArticleReactions.articleId, id));
|
);
|
||||||
await tx
|
if (!result.ok) redirect("/admin/articles?error=Delete failed");
|
||||||
.delete(WebsiteArticleComments)
|
|
||||||
.where(eq(WebsiteArticleComments.articleId, id));
|
|
||||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
redirect("/admin/articles?error=Delete failed");
|
|
||||||
}
|
|
||||||
redirect("/admin/articles");
|
redirect("/admin/articles");
|
||||||
}
|
}
|
||||||
@@ -1,76 +1,49 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, EmailTemplates } from "@/lib/db";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
function templateInput(formData: FormData) {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
return {
|
||||||
const name = String(formData.get("name") ?? "")
|
name: String(formData.get("name") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||||
.normalize("NFC")
|
subject: String(formData.get("subject") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||||
.trim()
|
body: String(formData.get("body") ?? "").normalize("NFC"),
|
||||||
.slice(0, 255);
|
variables: String(formData.get("variables") ?? "").normalize("NFC").trim(),
|
||||||
const subject = String(formData.get("subject") ?? "")
|
isActive: formData.get("isActive") != null,
|
||||||
.normalize("NFC")
|
};
|
||||||
.trim()
|
}
|
||||||
.slice(0, 255);
|
|
||||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
|
||||||
const variablesRaw = String(formData.get("variables") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const isActive = formData.get("isActive") != null;
|
|
||||||
if (!name || !subject || !body) return;
|
|
||||||
|
|
||||||
await db.insert(EmailTemplates).values({
|
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||||
name,
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
subject,
|
const input = templateInput(formData);
|
||||||
body,
|
if (!input.name || !input.subject || !input.body) return;
|
||||||
variables: variablesRaw || null,
|
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "create", ...input });
|
||||||
isActive,
|
if (!result.ok) throw new Error("Email template creation failed");
|
||||||
});
|
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
const id = String(formData.get("id") ?? "").normalize("NFC");
|
||||||
if (!raw) return;
|
if (!/^\d+$/u.test(id)) return;
|
||||||
let id: bigint;
|
const input = templateInput(formData);
|
||||||
try {
|
if (!input.subject || !input.body) return;
|
||||||
id = BigInt(raw);
|
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "update", id, ...input });
|
||||||
} catch {
|
if (!result.ok) throw new Error("Email template update failed");
|
||||||
return;
|
|
||||||
}
|
|
||||||
const subject = String(formData.get("subject") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
|
||||||
const variablesRaw = String(formData.get("variables") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
const isActive = formData.get("isActive") != null;
|
|
||||||
if (!subject || !body) return;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(EmailTemplates)
|
|
||||||
.set({
|
|
||||||
subject,
|
|
||||||
body,
|
|
||||||
variables: variablesRaw || null,
|
|
||||||
isActive,
|
|
||||||
})
|
|
||||||
.where(eq(EmailTemplates.id, id));
|
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||||
if (!id) return;
|
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||||
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
|
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "delete", id });
|
||||||
|
if (!result.ok) throw new Error("Email template deletion failed");
|
||||||
revalidatePath("/admin/email-templates");
|
revalidatePath("/admin/email-templates");
|
||||||
}
|
}
|
||||||
@@ -1,77 +1,41 @@
|
|||||||
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 {
|
import { createHelpQuestion, deleteHelpQuestion, updateHelpQuestion } from "./admin-help";
|
||||||
createHelpQuestion,
|
|
||||||
deleteHelpQuestion,
|
|
||||||
updateHelpQuestion,
|
|
||||||
} from "./admin-help";
|
|
||||||
|
|
||||||
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
|
|
||||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
|
|
||||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
return { insertValues, updateWhere, deleteWhere };
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||||
|
contentMutationService: { execute },
|
||||||
|
createContentMutationInvocation: (actor: { id: number }, correlationId: string) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
|
||||||
|
}));
|
||||||
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/db", () => ({
|
|
||||||
db: {
|
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
},
|
|
||||||
WebsiteHelpCenterCategories: { id: "id" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
|
||||||
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() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string | null>) => ({
|
const fakeForm = (data: Record<string, string | null>) => ({ get: (key: string) => key in data ? data[key] : 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);
|
||||||
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "5" } }, correlationId: "legacy" });
|
||||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createHelpQuestion", () => {
|
describe("Content help legacy wrappers", () => {
|
||||||
it("creates a help question and redirects", async () => {
|
it("delegates create and redirects", async () => {
|
||||||
await createHelpQuestion(
|
await createHelpQuestion(fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData);
|
||||||
fakeForm({
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "create", name: "FAQ" }));
|
||||||
name: "FAQ",
|
|
||||||
content: "<p>Answer</p>",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(insertValues).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
it("delegates update and redirects", async () => {
|
||||||
|
await updateHelpQuestion(fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData);
|
||||||
describe("updateHelpQuestion", () => {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "update", id: "42" }));
|
||||||
it("updates and redirects", async () => {
|
|
||||||
await updateHelpQuestion(
|
|
||||||
fakeForm({
|
|
||||||
id: "42",
|
|
||||||
name: "Updated",
|
|
||||||
content: "New",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(updateWhere).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
it("delegates delete and redirects", async () => {
|
||||||
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||||
describe("deleteHelpQuestion", () => {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", { action: "delete", id: "42" });
|
||||||
it("deletes and redirects", async () => {
|
|
||||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+32
-104
@@ -1,63 +1,38 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
|
||||||
import { formPositiveBigInt } from "@/lib/form-data";
|
|
||||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
function helpInput(formData: FormData) {
|
||||||
// is a titled content block with an optional image and call-to-action button.
|
return {
|
||||||
|
name: sanitizeField(formData.get("name")),
|
||||||
function parsePosition(value: FormDataEntryValue | null): number {
|
content: canonicalize(String(formData.get("content") ?? "")),
|
||||||
const n = Number(value);
|
position: Number(formData.get("position")) > 0 ? Math.floor(Number(formData.get("position"))) : 1,
|
||||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
imageUrl: sanitizeField(formData.get("imageUrl")),
|
||||||
|
buttonText: sanitizeField(formData.get("buttonText")),
|
||||||
|
buttonUrl: sanitizeField(formData.get("buttonUrl")),
|
||||||
|
buttonColor: sanitizeField(formData.get("buttonColor"), 16) || "#eeb425",
|
||||||
|
buttonBorderColor: sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15",
|
||||||
|
smallBox: formData.get("smallBox") != null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const name = sanitizeField(formData.get("name"));
|
const input = helpInput(formData);
|
||||||
const content = canonicalize(String(formData.get("content") ?? ""));
|
if (!input.name || !input.content) return;
|
||||||
if (!name || !content) return;
|
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "create", ...input });
|
||||||
|
if (!result.ok) {
|
||||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
|
||||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
|
||||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
|
||||||
const buttonColor =
|
|
||||||
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
|
||||||
const buttonBorderColor =
|
|
||||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
|
||||||
name,
|
|
||||||
content,
|
|
||||||
position: parsePosition(formData.get("position")),
|
|
||||||
imageUrl: imageUrl || null,
|
|
||||||
buttonText: buttonText || null,
|
|
||||||
buttonUrl: buttonUrl || null,
|
|
||||||
buttonColor,
|
|
||||||
buttonBorderColor,
|
|
||||||
smallBox: formData.get("smallBox") != null,
|
|
||||||
})) as unknown as [ResultSetHeader];
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "help_create",
|
|
||||||
description: `Created help-center entry #${result.insertId} (${name})`,
|
|
||||||
targetType: "help_center_category",
|
|
||||||
targetId: Number(result.insertId),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Unique name collision or DB error — re-render unchanged with error.
|
|
||||||
revalidatePath("/admin/help-questions");
|
revalidatePath("/admin/help-questions");
|
||||||
redirect(
|
redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again.");
|
||||||
"/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");
|
||||||
@@ -65,46 +40,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
if (!id) return;
|
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||||
|
const input = helpInput(formData);
|
||||||
const name = sanitizeField(formData.get("name"));
|
if (!input.name || !input.content) return;
|
||||||
const content = canonicalize(String(formData.get("content") ?? ""));
|
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "update", id, ...input });
|
||||||
if (!name || !content) return;
|
if (!result.ok) {
|
||||||
|
revalidatePath("/admin/help-questions/" + id);
|
||||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
|
||||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
|
||||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
|
||||||
const buttonColor =
|
|
||||||
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
|
||||||
const buttonBorderColor =
|
|
||||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(WebsiteHelpCenterCategories)
|
|
||||||
.set({
|
|
||||||
name,
|
|
||||||
content,
|
|
||||||
position: parsePosition(formData.get("position")),
|
|
||||||
imageUrl: imageUrl || null,
|
|
||||||
buttonText: buttonText || null,
|
|
||||||
buttonUrl: buttonUrl || null,
|
|
||||||
buttonColor,
|
|
||||||
buttonBorderColor,
|
|
||||||
smallBox: formData.get("smallBox") != null,
|
|
||||||
})
|
|
||||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "help_update",
|
|
||||||
description: `Updated help-center entry #${id} (${name})`,
|
|
||||||
targetType: "help_center_category",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Not found, unique collision, or DB error — ignore.
|
|
||||||
revalidatePath(`/admin/help-questions/${id}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
redirect("/admin/help-questions");
|
redirect("/admin/help-questions");
|
||||||
@@ -112,22 +54,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
|||||||
|
|
||||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = formPositiveBigInt(formData, "id");
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
if (!id) return;
|
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||||
|
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "delete", id });
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.delete(WebsiteHelpCenterCategories)
|
|
||||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "help_delete",
|
|
||||||
description: `Deleted help-center entry #${id}`,
|
|
||||||
targetType: "help_center_category",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Not found or DB error — ignore.
|
|
||||||
}
|
|
||||||
redirect("/admin/help-questions");
|
redirect("/admin/help-questions");
|
||||||
}
|
}
|
||||||
@@ -1,59 +1,39 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
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 { resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||||
|
|
||||||
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||||
vi.mock("@/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/media-storage", () => {
|
|
||||||
const root = path.join("/tmp", "nexst-test-media");
|
|
||||||
return {
|
|
||||||
MEDIA_ROOT: root,
|
|
||||||
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("node:fs/promises", () => ({
|
|
||||||
mkdir: vi.fn(),
|
|
||||||
writeFile: vi.fn(),
|
|
||||||
unlink: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||||
|
execute.mockResolvedValue({ ok: true, data: { before: null, after: { name: "photo.png" }, output: { url: "/api/media/photo.png" } }, correlationId: "legacy" });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("uploadMedia", () => {
|
describe("Content media legacy wrappers", () => {
|
||||||
it("returns error when no file provided", async () => {
|
it("retains no-file validation", async () => {
|
||||||
const result = await uploadMedia(new FormData());
|
expect(await uploadMedia(new FormData())).toEqual({ ok: false, error: "No file provided" });
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.error).toBe("No file provided");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("uploadMediaAndReturn", () => {
|
|
||||||
it("returns empty string when no file", async () => {
|
|
||||||
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||||
|
expect(execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
it("delegates a valid upload and preserves both result shapes", async () => {
|
||||||
|
const file = new File(["bytes"], "photo.png", { type: "image/png" });
|
||||||
describe("deleteMedia", () => {
|
const form = new FormData();
|
||||||
it("deletes media file and revalidates", async () => {
|
form.set("file", file);
|
||||||
|
expect(await uploadMedia(form)).toEqual({ ok: true });
|
||||||
|
expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png");
|
||||||
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.upload", { file });
|
||||||
|
});
|
||||||
|
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(revalidatePath).toHaveBeenCalledWith("/api/media");
|
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||||
});
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
|
||||||
|
|
||||||
it("skips deletion when path is outside media root", async () => {
|
|
||||||
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
|
||||||
await deleteMedia("../../../etc/passwd");
|
|
||||||
const { unlink } = await import("node:fs/promises");
|
|
||||||
expect(unlink).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+44
-60
@@ -1,83 +1,67 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
const MAX_SIZE = 5 * 1024 * 1024;
|
||||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
|
||||||
export async function uploadMedia(
|
function mediaFile(formData: FormData): File | null {
|
||||||
formData: FormData,
|
const value = formData.get("file");
|
||||||
): Promise<{ ok: boolean; error?: string }> {
|
return value && typeof value === "object" ? (value as File) : null;
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
}
|
||||||
const file = formData.get("file") as File | null;
|
|
||||||
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
|
|
||||||
if (file.size > MAX_SIZE)
|
|
||||||
return { ok: false, error: "File too large (max 5MB)" };
|
|
||||||
if (!ALLOWED.includes(file.type))
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseDir = MEDIA_ROOT;
|
function validateMediaFile(file: File | null): string | null {
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
if (!file || file.size === 0) return "No file provided";
|
||||||
await mkdir(baseDir, { recursive: true });
|
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";
|
||||||
const ext = file.name.split(".").pop() ?? "png";
|
return null;
|
||||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
}
|
||||||
const bytes = await file.arrayBuffer();
|
|
||||||
const filePath = resolveMediaPath(name);
|
|
||||||
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, Buffer.from(bytes));
|
|
||||||
|
|
||||||
|
export async function uploadMedia(formData: FormData): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
const file = mediaFile(formData);
|
||||||
|
const error = validateMediaFile(file);
|
||||||
|
if (error) return { ok: false, error };
|
||||||
|
const result = await contentMutationService.execute(
|
||||||
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
|
"media.upload",
|
||||||
|
{ file },
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error("Media upload failed");
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/admin/media");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMedia(name: string): Promise<void> {
|
export async function deleteMedia(name: string): Promise<void> {
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const { unlink } = await import("node:fs/promises");
|
await contentMutationService.execute(
|
||||||
const baseDir = MEDIA_ROOT;
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
const filePath = resolveMediaPath(name);
|
"media.delete",
|
||||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
{ filename: name },
|
||||||
try {
|
);
|
||||||
await unlink(filePath);
|
|
||||||
} catch {
|
|
||||||
// File may not exist
|
|
||||||
}
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/admin/media");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadMediaAndReturn(
|
export async function uploadMediaAndReturn(formData: FormData): Promise<string> {
|
||||||
formData: FormData,
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
): Promise<string> {
|
const file = mediaFile(formData);
|
||||||
await requirePermission(PERMS.PAGES_EDIT);
|
if (validateMediaFile(file)) return "";
|
||||||
const file = formData.get("file") as File | null;
|
const result = await contentMutationService.execute(
|
||||||
if (!file || file.size === 0) return "";
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
if (file.size > MAX_SIZE) return "";
|
"media.upload",
|
||||||
if (!ALLOWED.includes(file.type)) return "";
|
{ file },
|
||||||
|
);
|
||||||
const baseDir = MEDIA_ROOT;
|
if (!result.ok) return "";
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(baseDir, { recursive: true });
|
|
||||||
|
|
||||||
const ext = file.name.split(".").pop() ?? "png";
|
|
||||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
||||||
const bytes = await file.arrayBuffer();
|
|
||||||
const filePath = resolveMediaPath(name);
|
|
||||||
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, Buffer.from(bytes));
|
|
||||||
|
|
||||||
revalidatePath("/api/media");
|
revalidatePath("/api/media");
|
||||||
revalidatePath("/admin/media");
|
revalidatePath("/admin/media");
|
||||||
return `/api/media/${name}`;
|
return typeof result.data.output?.url === "string" ? result.data.output.url : "";
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,9 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
type AdminNavConfig,
|
|
||||||
serializeAdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
groupOrder: z.array(z.string()),
|
groupOrder: z.array(z.string()),
|
||||||
@@ -26,16 +21,12 @@ export const saveAdminNavConfig = adminAction(
|
|||||||
rateLimitMax: 30,
|
rateLimitMax: 30,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const config: AdminNavConfig = {
|
const result = await contentMutationService.execute(
|
||||||
groupOrder: ctx.data.groupOrder,
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
hiddenGroups: ctx.data.hiddenGroups,
|
"navigation.update",
|
||||||
hiddenItems: ctx.data.hiddenItems,
|
ctx.data,
|
||||||
itemOrder: ctx.data.itemOrder,
|
|
||||||
};
|
|
||||||
await siteSettings.update(
|
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
serializeAdminNavConfig(config),
|
|
||||||
);
|
);
|
||||||
|
if (!result.ok) throw new Error("Navigation update failed");
|
||||||
revalidatePath("/admin", "layout");
|
revalidatePath("/admin", "layout");
|
||||||
revalidatePath("/admin/menu");
|
revalidatePath("/admin/menu");
|
||||||
return actionOk({ saved: true });
|
return actionOk({ saved: true });
|
||||||
|
|||||||
@@ -2,71 +2,29 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
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 { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
import { deletePhoto } from "./admin-photos";
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
const limit = vi.fn();
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||||
const whereSelect = vi.fn(() => ({ limit }));
|
|
||||||
const from = vi.fn(() => ({ where: whereSelect }));
|
|
||||||
const select = vi.fn(() => ({ from }));
|
|
||||||
const whereDelete = vi.fn();
|
|
||||||
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
|
||||||
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
|
||||||
});
|
|
||||||
|
|
||||||
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/admin/photo-files", () => ({
|
|
||||||
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({
|
|
||||||
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
select: (...args) => select(...args),
|
|
||||||
delete: (...args) => deleteFn(...args),
|
|
||||||
},
|
|
||||||
CameraWeb: { id: "id", url: "url" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fakeForm = (data) => ({
|
|
||||||
get: (key) => data[key] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||||
whereDelete.mockResolvedValue(undefined);
|
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
rank: 7,
|
|
||||||
username: "admin",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deletePhoto", () => {
|
describe("deletePhoto", () => {
|
||||||
it("deletes a photo and revalidates", async () => {
|
it("delegates deletion and preserves both revalidations", async () => {
|
||||||
await deletePhoto(fakeForm({ id: "42" }));
|
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
||||||
expect(select).toHaveBeenCalled();
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
||||||
expect(deleteFn).toHaveBeenCalled();
|
|
||||||
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
|
||||||
expect(logStaffActivity).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "photo_delete",
|
|
||||||
targetId: 42,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
await deletePhoto(fakeForm({ id: "0" }));
|
await deletePhoto({ get: () => "0" });
|
||||||
expect(select).not.toHaveBeenCalled();
|
expect(execute).not.toHaveBeenCalled();
|
||||||
expect(deleteFn).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,36 +1,19 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
|
||||||
import { CameraWeb, db } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
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 });
|
||||||
const [row] = await db
|
|
||||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
|
||||||
.from(CameraWeb)
|
|
||||||
.where(eq(CameraWeb.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
|
||||||
await tryRemoveLocalPhotoFile(row.url);
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "photo_delete",
|
|
||||||
description: `Deleted camera photo #${id}`,
|
|
||||||
targetType: "camera_web",
|
|
||||||
targetId: id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/admin/photos");
|
||||||
revalidatePath("/photos");
|
revalidatePath("/photos");
|
||||||
}
|
}
|
||||||
+31
-109
@@ -2,133 +2,55 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
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 { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||||
|
|
||||||
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
|
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 }) }));
|
||||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
|
||||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
const transaction = vi.fn(async (fn) =>
|
|
||||||
fn({
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return { insertValues, updateWhere, deleteWhere, transaction };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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/db", () => ({
|
|
||||||
db: {
|
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
transaction,
|
|
||||||
},
|
|
||||||
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
|
|
||||||
Taggables: { tagId: "tagId" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const form = (data) => ({ get: (key) => data[key] ?? null });
|
||||||
const fakeForm = (data: Record<string, string>) => ({
|
|
||||||
get: (key: string) => data[key] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" } }, correlationId: "legacy" });
|
||||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
transaction.mockImplementation(async (fn) =>
|
|
||||||
fn({
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createTag", () => {
|
describe("Content tag legacy wrappers", () => {
|
||||||
it("creates a tag and revalidates", async () => {
|
it("delegates create with normalized values", async () => {
|
||||||
await createTag(
|
await createTag(form({ name: "News", backgroundColor: "#ff0000" }));
|
||||||
fakeForm({
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "create", name: "News", backgroundColor: "#ff0000" });
|
||||||
name: "News",
|
|
||||||
backgroundColor: "#ff0000",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(insertValues).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ name: "News" }),
|
|
||||||
);
|
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
|
it("uses the legacy default color", async () => {
|
||||||
it("returns early when name is empty", async () => {
|
await createTag(form({ name: "Test" }));
|
||||||
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ backgroundColor: "#888888" }));
|
||||||
expect(insertValues).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
it("returns early for an empty name", async () => {
|
||||||
it("uses default color when not provided", async () => {
|
await createTag(form({ name: "" }));
|
||||||
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
expect(execute).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(insertValues).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ backgroundColor: "#888888" }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
it("revalidates after a fail-soft dependency result", async () => {
|
||||||
it("handles db error gracefully", async () => {
|
execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" });
|
||||||
insertValues.mockRejectedValue(new Error("DB error"));
|
await createTag(form({ name: "News" }));
|
||||||
|
|
||||||
await expect(
|
|
||||||
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||||
});
|
});
|
||||||
});
|
it("delegates update", async () => {
|
||||||
|
await updateTag(form({ id: "42", name: "Updated", backgroundColor: "#00ff00" }));
|
||||||
describe("updateTag", () => {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ action: "update", id: "42" }));
|
||||||
it("updates a tag and revalidates", async () => {
|
|
||||||
await updateTag(
|
|
||||||
fakeForm({
|
|
||||||
id: "42",
|
|
||||||
name: "Updated",
|
|
||||||
backgroundColor: "#00ff00",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(updateWhere).toHaveBeenCalled();
|
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
|
||||||
});
|
});
|
||||||
|
it("rejects invalid update id or name", async () => {
|
||||||
it("returns early when id is invalid", async () => {
|
await updateTag(form({ id: "", name: "Test" }));
|
||||||
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
await updateTag(form({ id: "42", name: "" }));
|
||||||
expect(updateWhere).not.toHaveBeenCalled();
|
expect(execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
it("delegates delete", async () => {
|
||||||
it("returns early when name is empty after update", async () => {
|
await deleteTag(form({ id: "42" }));
|
||||||
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "delete", id: "42" });
|
||||||
expect(updateWhere).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
it("rejects invalid delete id", async () => {
|
||||||
|
await deleteTag(form({ id: "" }));
|
||||||
describe("deleteTag", () => {
|
expect(execute).not.toHaveBeenCalled();
|
||||||
it("deletes a tag and its taggables", async () => {
|
|
||||||
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
|
||||||
|
|
||||||
expect(transaction).toHaveBeenCalled();
|
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
|
||||||
expect(logStaffActivity).toHaveBeenCalled();
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns early when id is invalid", async () => {
|
|
||||||
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+21
-91
@@ -1,113 +1,43 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, Taggables, Tags } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
function tagInput(formData: FormData) {
|
||||||
|
return {
|
||||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
name: String(formData.get("name") ?? "").trim().slice(0, 255),
|
||||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
backgroundColor: String(formData.get("backgroundColor") ?? "").trim().slice(0, 10) || "#888888",
|
||||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
};
|
||||||
try {
|
|
||||||
const id = BigInt(raw.trim());
|
|
||||||
return id > 0n ? id : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function str(raw: FormDataEntryValue | null): string {
|
|
||||||
return typeof raw === "string" ? raw : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Normalise a hex-ish colour into the 10-char background_color column. */
|
|
||||||
function normaliseColor(raw: string): string {
|
|
||||||
const v = raw.trim().slice(0, 10);
|
|
||||||
return v || "#888888";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
|
|
||||||
|
|
||||||
export async function createTag(formData: FormData): Promise<void> {
|
export async function createTag(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
const input = tagInput(formData);
|
||||||
if (!name) return;
|
if (!input.name) return;
|
||||||
|
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "create", ...input });
|
||||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [result] = (await db.insert(Tags).values({
|
|
||||||
name,
|
|
||||||
backgroundColor,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})) as unknown as [ResultSetHeader];
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "tag_create",
|
|
||||||
description: `Created tag "${name}" (#${result.insertId})`,
|
|
||||||
targetType: "tag",
|
|
||||||
targetId: Number(result.insertId),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Fail soft — DB unavailable or duplicate.
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/tags");
|
revalidatePath("/admin/tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTag(formData: FormData): Promise<void> {
|
export async function updateTag(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = parseId(formData.get("id"));
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
if (id === null) return;
|
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||||
|
const input = tagInput(formData);
|
||||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
if (!input.name) return;
|
||||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "update", id, ...input });
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(Tags)
|
|
||||||
.set({ name, backgroundColor, updatedAt: new Date() })
|
|
||||||
.where(eq(Tags.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "tag_update",
|
|
||||||
description: `Updated tag #${id} → "${name}"`,
|
|
||||||
targetType: "tag",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Row may be gone; ignore.
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/tags");
|
revalidatePath("/admin/tags");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTag(formData: FormData): Promise<void> {
|
export async function deleteTag(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = parseId(formData.get("id"));
|
const id = String(formData.get("id") ?? "").trim();
|
||||||
if (id === null) return;
|
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||||
|
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "delete", id });
|
||||||
try {
|
|
||||||
// Remove the tag and any taggable links pointing at it.
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
|
|
||||||
await tx.delete(Tags).where(eq(Tags.id, id));
|
|
||||||
});
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "tag_delete",
|
|
||||||
description: `Deleted tag #${id}`,
|
|
||||||
targetType: "tag",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Already deleted; ignore.
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/tags");
|
revalidatePath("/admin/tags");
|
||||||
}
|
}
|
||||||
+32
-180
@@ -2,214 +2,66 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
|
||||||
import {
|
import {
|
||||||
deleteCustomThemeStore,
|
contentMutationService,
|
||||||
getCustomTheme,
|
createContentMutationInvocation,
|
||||||
snapshotCurrentTheme,
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
upsertCustomTheme,
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
} from "@/lib/theme-custom-store";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
|
||||||
|
|
||||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
function formValues(formData: FormData): Record<string, string> {
|
||||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
return Object.fromEntries(Array.from(formData.entries(), ([key, value]) => [key, typeof value === "string" ? value : value.name]));
|
||||||
// Extra colour settings beyond the preset palette (buttons + links + gradients).
|
}
|
||||||
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
|
|
||||||
const CUSTOM_CSS_MAX = 20000;
|
|
||||||
|
|
||||||
async function writeSetting(key: string, value: string): Promise<void> {
|
async function executeTheme(operation: "theme.update" | "theme.apply-preset" | "theme.custom-change" | "theme.apply-custom", input: Record<string, unknown>) {
|
||||||
await db
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
.insert(WebsiteSetting)
|
return contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), operation, input);
|
||||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
|
||||||
.onDuplicateKeyUpdate({ set: { value } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTheme(formData: FormData): Promise<void> {
|
export async function saveTheme(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
const result = await executeTheme("theme.update", { values: formValues(formData) });
|
||||||
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
try {
|
|
||||||
for (const mode of ["light", "dark"] as const) {
|
|
||||||
const bag: Record<string, string> = {};
|
|
||||||
for (const key of THEME_COLOR_KEYS) {
|
|
||||||
const dbKey = settingKey(key, mode);
|
|
||||||
const raw = String(formData.get(dbKey) ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
|
||||||
}
|
|
||||||
const fixed = ensureReadableThemeColors(bag);
|
|
||||||
for (const [key, value] of Object.entries(fixed)) {
|
|
||||||
await writeSetting(
|
|
||||||
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ADMIN_KEYS = [
|
|
||||||
"admin_canvas",
|
|
||||||
"admin_surface",
|
|
||||||
"admin_text",
|
|
||||||
"admin_text_muted",
|
|
||||||
"admin_border",
|
|
||||||
"admin_sidebar_bg",
|
|
||||||
] as const;
|
|
||||||
const adminBag: Record<string, string> = {};
|
|
||||||
for (const key of ADMIN_KEYS) {
|
|
||||||
const raw = String(formData.get(key) ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
|
||||||
}
|
|
||||||
const adminFixed = ensureReadableThemeColors(adminBag);
|
|
||||||
for (const [key, value] of Object.entries(adminFixed)) {
|
|
||||||
await writeSetting(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const radius = String(formData.get("border_radius") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
|
||||||
|
|
||||||
// Typography
|
|
||||||
const font = String(formData.get("font_family") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (font in FONTS) await writeSetting("font_family", font);
|
|
||||||
for (const key of HEADING_KEYS) {
|
|
||||||
const v = String(formData.get(key) ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
|
|
||||||
if (formData.has("custom_css")) {
|
|
||||||
const cssRaw = String(formData.get("custom_css") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.slice(0, CUSTOM_CSS_MAX);
|
|
||||||
await writeSetting("custom_css", cssRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
siteSettings.reload();
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "theme_update",
|
|
||||||
description: "Updated theme settings",
|
|
||||||
});
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
} catch {
|
|
||||||
// ignore — page re-renders current state
|
|
||||||
}
|
|
||||||
redirect("/admin/theme?saved=1");
|
redirect("/admin/theme?saved=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyPreset(formData: FormData): Promise<void> {
|
export async function applyPreset(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
|
||||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
const result = await executeTheme("theme.apply-preset", { preset: name });
|
||||||
const preset = PRESETS[name];
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
if (!preset) redirect("/admin/theme");
|
redirect(result.ok ? "/admin/theme?preset=" + encodeURIComponent(name) : "/admin/theme");
|
||||||
|
|
||||||
try {
|
|
||||||
for (const [key, value] of presetSettings(preset))
|
|
||||||
await writeSetting(key, value);
|
|
||||||
await writeSetting("theme_preset", name);
|
|
||||||
siteSettings.reload();
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "theme_preset",
|
|
||||||
description: `Applied theme preset "${name}"`,
|
|
||||||
});
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
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 snapshot = await snapshotCurrentTheme();
|
const result = await executeTheme("theme.custom-change", { action: "create", name });
|
||||||
try {
|
if (result.ok) revalidatePath("/admin/theme");
|
||||||
await upsertCustomTheme(name, snapshot);
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "theme_preset",
|
|
||||||
description: `Saved custom theme "${name}"`,
|
|
||||||
});
|
|
||||||
revalidatePath("/admin/theme");
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
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 staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
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 theme = await getCustomTheme(id);
|
const result = await executeTheme("theme.apply-custom", { id });
|
||||||
if (!theme) redirect("/admin/theme");
|
if (result.ok) revalidatePath("/", "layout");
|
||||||
try {
|
const name = result.ok && typeof result.data.output?.name === "string" ? result.data.output.name : "";
|
||||||
for (const [key, value] of Object.entries(theme.settings)) {
|
redirect(result.ok ? "/admin/theme?theme=" + encodeURIComponent(name) : "/admin/theme");
|
||||||
if (value) await writeSetting(key, value);
|
|
||||||
}
|
|
||||||
await writeSetting("theme_preset", theme.name);
|
|
||||||
siteSettings.reload();
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "theme_preset",
|
|
||||||
description: `Applied custom theme "${theme.name}"`,
|
|
||||||
});
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
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 snapshot = await snapshotCurrentTheme();
|
const result = await executeTheme("theme.custom-change", { action: "rename", id, name });
|
||||||
try {
|
if (result.ok) revalidatePath("/admin/theme");
|
||||||
await upsertCustomTheme(name, snapshot, id);
|
|
||||||
revalidatePath("/admin/theme");
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
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> {
|
||||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
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");
|
||||||
try {
|
const result = await executeTheme("theme.custom-change", { action: "delete", id });
|
||||||
await deleteCustomThemeStore(id);
|
if (result.ok) revalidatePath("/admin/theme");
|
||||||
revalidatePath("/admin/theme");
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
redirect("/admin/theme?deletedTheme=1");
|
redirect("/admin/theme?deletedTheme=1");
|
||||||
}
|
}
|
||||||
@@ -1,177 +1,49 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { db, WebsiteWriteableBoxes } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
|
function boxInput(formData: FormData) {
|
||||||
// content panels rendered on the public home page. Active boxes (is_active)
|
const position = Number(formData.get("position"));
|
||||||
// are the ones shown publicly, ordered by `position`.
|
return {
|
||||||
|
title: String(formData.get("title") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||||
/** Parse a non-negative Int form value, falling back to 0. */
|
icon: String(formData.get("icon") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||||
function reqInt(formData: FormData, key: string): number {
|
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||||
const raw = String(formData.get(key) ?? "")
|
position: Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0,
|
||||||
.normalize("NFC")
|
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||||
.trim();
|
};
|
||||||
if (raw === "") return 0;
|
|
||||||
const n = Number(raw);
|
|
||||||
if (!Number.isFinite(n) || n < 0) return 0;
|
|
||||||
return Math.floor(n);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
|
function refreshBoxes(): void {
|
||||||
function parseId(formData: FormData): bigint | null {
|
|
||||||
const raw = String(formData.get("id") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim();
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
return BigInt(raw);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function revalidate(): void {
|
|
||||||
revalidatePath("/admin/writeable-boxes");
|
revalidatePath("/admin/writeable-boxes");
|
||||||
// Active boxes render on the public home page (root layout).
|
|
||||||
revalidatePath("/", "layout");
|
revalidatePath("/", "layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executeBox(formData: FormData, action: "create" | "update" | "delete" | "toggle"): Promise<boolean> {
|
||||||
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
|
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||||
|
if (action !== "create" && !/^\d+$/u.test(id)) return false;
|
||||||
|
const input = boxInput(formData);
|
||||||
|
if ((action === "create" || action === "update") && !input.title) return false;
|
||||||
|
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "writeable-box.change", { action, ...(id ? { id } : {}), ...input, next: formData.get("next") });
|
||||||
|
return result.ok;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createBox(formData: FormData): Promise<void> {
|
export async function createBox(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
if (await executeBox(formData, "create")) refreshBoxes();
|
||||||
|
|
||||||
const title = String(formData.get("title") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
try {
|
|
||||||
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
|
|
||||||
title,
|
|
||||||
icon:
|
|
||||||
String(formData.get("icon") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
|
||||||
position: reqInt(formData, "position"),
|
|
||||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})) as unknown as [ResultSetHeader];
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "writeable_box_create",
|
|
||||||
description: `Created writeable box "${title}" (#${result.insertId})`,
|
|
||||||
targetType: "writeable_box",
|
|
||||||
targetId: Number(result.insertId),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// DB unavailable — swallow and re-render.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBox(formData: FormData): Promise<void> {
|
export async function updateBox(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
if (await executeBox(formData, "update")) refreshBoxes();
|
||||||
|
|
||||||
const id = parseId(formData);
|
|
||||||
if (id == null) return;
|
|
||||||
|
|
||||||
const title = String(formData.get("title") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255);
|
|
||||||
if (!title) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(WebsiteWriteableBoxes)
|
|
||||||
.set({
|
|
||||||
title,
|
|
||||||
icon:
|
|
||||||
String(formData.get("icon") ?? "")
|
|
||||||
.normalize("NFC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 255) || null,
|
|
||||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
|
||||||
position: reqInt(formData, "position"),
|
|
||||||
isActive:
|
|
||||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "writeable_box_update",
|
|
||||||
description: `Updated writeable box #${id} ("${title}")`,
|
|
||||||
targetType: "writeable_box",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBox(formData: FormData): Promise<void> {
|
export async function deleteBox(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
if (await executeBox(formData, "delete")) refreshBoxes();
|
||||||
|
|
||||||
const id = parseId(formData);
|
|
||||||
if (id == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.delete(WebsiteWriteableBoxes)
|
|
||||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "writeable_box_delete",
|
|
||||||
description: `Deleted writeable box #${id}`,
|
|
||||||
targetType: "writeable_box",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleBox(formData: FormData): Promise<void> {
|
export async function toggleBox(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
if (await executeBox(formData, "toggle")) refreshBoxes();
|
||||||
|
|
||||||
const id = parseId(formData);
|
|
||||||
if (id == null) return;
|
|
||||||
|
|
||||||
// `next` carries the desired state ("1" to activate, anything else to hide).
|
|
||||||
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(WebsiteWriteableBoxes)
|
|
||||||
.set({ isActive: next, updatedAt: new Date() })
|
|
||||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "writeable_box_toggle",
|
|
||||||
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
|
|
||||||
targetType: "writeable_box",
|
|
||||||
targetId: Number(id),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidate();
|
|
||||||
}
|
}
|
||||||
+18
-41
@@ -1,13 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { ResultSetHeader } from "mysql2";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, WebsiteBanner } from "@/lib/db";
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
|
|
||||||
const bannerSchema = z.object({
|
const bannerSchema = z.object({
|
||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
@@ -21,46 +18,31 @@ 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") {
|
||||||
|
return contentMutationService.execute(
|
||||||
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
|
"banner.change",
|
||||||
|
{ action, ...ctx.data },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const createBanner = adminAction(
|
export const createBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [result] = (await db
|
const result = await runBanner(ctx, "create");
|
||||||
.insert(WebsiteBanner)
|
if (!result.ok) throw new ActionError("Banner creation failed");
|
||||||
.values(ctx.data)) as unknown as [ResultSetHeader];
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
const id = Number(result.insertId);
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "banner_create",
|
|
||||||
target: "WebsiteBanner",
|
|
||||||
targetId: id,
|
|
||||||
after: { title: ctx.data.title },
|
|
||||||
});
|
|
||||||
return actionOk({ id });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateBannerInput = bannerSchema
|
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||||
.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 },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const result = await runBanner(ctx, "update");
|
||||||
const [existing] = await db
|
if (!result.ok) throw new ActionError("Banner not found");
|
||||||
.select({ id: WebsiteBanner.id })
|
return actionOk({ id: ctx.data.id });
|
||||||
.from(WebsiteBanner)
|
|
||||||
.where(eq(WebsiteBanner.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) throw new ActionError("Banner not found");
|
|
||||||
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "banner_update",
|
|
||||||
target: "WebsiteBanner",
|
|
||||||
targetId: id,
|
|
||||||
});
|
|
||||||
return actionOk({ id });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,13 +51,8 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteBanner = adminAction(
|
export const deleteBanner = adminAction(
|
||||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
|
const result = await runBanner(ctx, "delete");
|
||||||
logAudit({
|
if (!result.ok) throw new ActionError("Banner not found");
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "banner_delete",
|
|
||||||
target: "WebsiteBanner",
|
|
||||||
targetId: ctx.data.id,
|
|
||||||
});
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { createAd } from "./admin-ads";
|
||||||
|
import { createArticle } from "./admin-articles";
|
||||||
|
import { uploadMedia } from "./admin-media";
|
||||||
|
import { saveFavicon } from "./save-favicon";
|
||||||
|
|
||||||
|
const { execute } = vi.hoisted(() => ({
|
||||||
|
execute: vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||||
|
correlationId: "legacy",
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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/safe-action", () => ({
|
||||||
|
adminAction: (_options: unknown, handler: unknown) => handler,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
ActionError: class ActionError extends Error {},
|
||||||
|
actionOk: (data: unknown = {}) => ({ ok: true, data }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: {
|
||||||
|
NEWS_EDIT: "news.edit",
|
||||||
|
PAGES_EDIT: "pages.edit",
|
||||||
|
SETTINGS_EDIT: "settings.edit",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({ limit: vi.fn(async () => []) })),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
WebsiteArticles: { id: "id", slug: "slug" },
|
||||||
|
WebsiteAds: { id: "id" },
|
||||||
|
WebsiteSetting: { key: "key" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { get: vi.fn(), reload: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/media-storage", () => ({
|
||||||
|
MEDIA_ROOT: "C:\\media",
|
||||||
|
resolveMediaPath: vi.fn((name: string) => `C:\\media\\${name}`),
|
||||||
|
}));
|
||||||
|
vi.mock("node:fs/promises", () => ({
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
unlink: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
|
const staff = { id: 42, rank: 7, username: "operator" };
|
||||||
|
const form = (data: Record<string, FormDataEntryValue>) => ({
|
||||||
|
get: (key: string) => data[key] ?? null,
|
||||||
|
has: (key: string) => key in data,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
|
execute.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||||
|
correlationId: "legacy",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Content legacy wrappers", () => {
|
||||||
|
it("delegates article creation and preserves redirect ordering", async () => {
|
||||||
|
await createArticle(
|
||||||
|
form({
|
||||||
|
title: "Launch",
|
||||||
|
shortStory: "Summary",
|
||||||
|
fullStory: "Body",
|
||||||
|
image: "/image.png",
|
||||||
|
}) as FormData,
|
||||||
|
);
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
||||||
|
"article.change",
|
||||||
|
expect.objectContaining({ action: "create", title: "Launch" }),
|
||||||
|
);
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/articles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||||
|
expect(
|
||||||
|
await createAd(form({ image: "https://example.test/ad.png" }) as FormData),
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
||||||
|
"ad.change",
|
||||||
|
expect.objectContaining({ action: "create" }),
|
||||||
|
);
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
||||||
|
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
||||||
|
const media = await uploadMedia(form({ file }) as FormData);
|
||||||
|
const favicon = await saveFavicon(form({ file }) as FormData);
|
||||||
|
expect(media).toEqual({ ok: true });
|
||||||
|
expect(favicon).toEqual({ success: true, url: "/api/media/x" });
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"media.upload",
|
||||||
|
expect.objectContaining({ file }),
|
||||||
|
);
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"favicon.save",
|
||||||
|
expect.objectContaining({ file }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
||||||
|
for (const path of [
|
||||||
|
"src/actions/admin-ads.ts",
|
||||||
|
"src/actions/admin-articles.ts",
|
||||||
|
"src/actions/admin-email-templates.ts",
|
||||||
|
"src/actions/admin-help.ts",
|
||||||
|
"src/actions/admin-media.ts",
|
||||||
|
"src/actions/admin-nav-menu.ts",
|
||||||
|
"src/actions/admin-photos.ts",
|
||||||
|
"src/actions/admin-tags.ts",
|
||||||
|
"src/actions/admin-theme.ts",
|
||||||
|
"src/actions/admin-writeable-boxes.ts",
|
||||||
|
"src/actions/banners.ts",
|
||||||
|
"src/actions/events.ts",
|
||||||
|
"src/actions/polls.ts",
|
||||||
|
"src/actions/prefixes.ts",
|
||||||
|
"src/actions/save-favicon.ts",
|
||||||
|
"src/actions/save-logo.ts",
|
||||||
|
"src/actions/translations.ts",
|
||||||
|
"src/actions/emulator.ts",
|
||||||
|
]) {
|
||||||
|
expect(readFileSync(path, "utf8"), path).toContain(
|
||||||
|
"contentMutationService",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +1,17 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const { insertValues } = vi.hoisted(() => {
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { keys: ["key1", "key2"] } }, correlationId: "emulator" })) }));
|
||||||
const insertValues = vi.fn(() => ({
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute } }));
|
||||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" } }));
|
||||||
}));
|
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler }));
|
||||||
return { insertValues };
|
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" }));
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
},
|
|
||||||
EmulatorSettings: { key: "key", value: "value" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
|
||||||
adminAction: vi.fn(
|
|
||||||
(_opts: unknown, fn: (...args: unknown[]) => unknown) => fn,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
|
||||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
|
||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
|
|
||||||
|
|
||||||
describe("saveEmulatorSettings", () => {
|
describe("saveEmulatorSettings", () => {
|
||||||
it("saves settings and calls rcon update", async () => {
|
it("delegates the third translation store and preserves result shape", async () => {
|
||||||
const handler = (await import("./emulator").then(
|
const handler = (await import("./emulator")).saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>;
|
||||||
(m) => m.saveEmulatorSettings,
|
const result = await handler({ data: { settings: { key1: "val1", key2: "val2" } }, session: { user: { id: "1" } }, requestId: "emulator" });
|
||||||
)) as unknown as (ctx: {
|
expect(execute).toHaveBeenCalledWith({ correlationId: "emulator", expectedActorId: 1, legacy: true }, "translation.emulator.save", { settings: { key1: "val1", key2: "val2" } });
|
||||||
data: { settings: Record<string, string> };
|
|
||||||
session: { user: { id: string } };
|
|
||||||
}) => Promise<string>;
|
|
||||||
|
|
||||||
const result = await handler({
|
|
||||||
data: { settings: { key1: "val1", key2: "val2" } },
|
|
||||||
session: { user: { id: "1" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(insertValues).toHaveBeenCalledTimes(2);
|
|
||||||
expect(rcon.updateConfig).toHaveBeenCalled();
|
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+8
-24
@@ -1,38 +1,22 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, EmulatorSettings } from "@/lib/db";
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
import { PERMS } from "@/lib/permissions";
|
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";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const saveEmulatorSettingsSchema = z.object({
|
const saveEmulatorSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) });
|
||||||
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 entries = Object.entries(ctx.data.settings);
|
const result = await contentMutationService.execute(
|
||||||
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
for (const [key, value] of entries) {
|
"translation.emulator.save",
|
||||||
await db
|
ctx.data,
|
||||||
.insert(EmulatorSettings)
|
);
|
||||||
.values({ key, value: String(value) })
|
if (!result.ok) throw new Error(result.error.messageKey);
|
||||||
.onDuplicateKeyUpdate({ set: { value: String(value) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await rcon.updateConfig();
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "emulator_settings_update",
|
|
||||||
target: "EmulatorSettings",
|
|
||||||
after: ctx.data.settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
+61
-125
@@ -3,18 +3,16 @@
|
|||||||
import { and, count, eq } from "drizzle-orm";
|
import { and, count, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
WebsiteEvent,
|
WebsiteEvent,
|
||||||
WebsiteEventPrize,
|
|
||||||
WebsiteEventRegistration,
|
WebsiteEventRegistration,
|
||||||
WebsiteEventType,
|
WebsiteEventType,
|
||||||
WebsiteEventWinner,
|
|
||||||
} from "@/lib/db";
|
} from "@/lib/db";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import {
|
import {
|
||||||
createEventSchema,
|
createEventSchema,
|
||||||
eventPrizeSchema,
|
eventPrizeSchema,
|
||||||
@@ -29,16 +27,13 @@ import {
|
|||||||
export const createEventType = adminAction(
|
export const createEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
|
const result = await contentMutationService.execute(
|
||||||
const eventTypeId = Number(result.insertId);
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
logAudit({
|
"event-type.change",
|
||||||
userId: ctx.session.user.id,
|
{ action: "create", ...ctx.data },
|
||||||
action: "event_type_create",
|
);
|
||||||
target: "WebsiteEventType",
|
if (!result.ok) throw new ActionError("Event type creation failed");
|
||||||
targetId: eventTypeId,
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
after: { name: ctx.data.name },
|
|
||||||
});
|
|
||||||
return actionOk({ id: eventTypeId });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,27 +44,13 @@ const updateEventTypeInput = eventTypeSchema.partial().extend({
|
|||||||
export const updateEventType = adminAction(
|
export const updateEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const result = await contentMutationService.execute(
|
||||||
const [existing] = await db
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
"event-type.change",
|
||||||
.from(WebsiteEventType)
|
{ action: "update", ...ctx.data },
|
||||||
.where(eq(WebsiteEventType.id, id))
|
);
|
||||||
.limit(1);
|
if (!result.ok) throw new ActionError("Event type not found");
|
||||||
if (!existing) throw new ActionError("Event type not found");
|
return actionOk({ id: ctx.data.id });
|
||||||
|
|
||||||
await db
|
|
||||||
.update(WebsiteEventType)
|
|
||||||
.set(data)
|
|
||||||
.where(eq(WebsiteEventType.id, id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "event_type_update",
|
|
||||||
target: "WebsiteEventType",
|
|
||||||
targetId: id,
|
|
||||||
before: { name: existing.name },
|
|
||||||
after: data,
|
|
||||||
});
|
|
||||||
return actionOk({ id });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,23 +61,12 @@ const deleteEventTypeInput = z.object({
|
|||||||
export const deleteEventType = adminAction(
|
export const deleteEventType = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [existing] = await db
|
const result = await contentMutationService.execute(
|
||||||
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.from(WebsiteEventType)
|
"event-type.change",
|
||||||
.where(eq(WebsiteEventType.id, ctx.data.id))
|
{ action: "delete", ...ctx.data },
|
||||||
.limit(1);
|
);
|
||||||
if (!existing) throw new ActionError("Event type not found");
|
if (!result.ok) throw new ActionError("Event type not found");
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(WebsiteEventType)
|
|
||||||
.where(eq(WebsiteEventType.id, ctx.data.id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "event_type_delete",
|
|
||||||
target: "WebsiteEventType",
|
|
||||||
targetId: ctx.data.id,
|
|
||||||
before: { name: existing.name },
|
|
||||||
});
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -106,21 +76,13 @@ export const deleteEventType = adminAction(
|
|||||||
export const createEvent = adminAction(
|
export const createEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const now = new Date();
|
const result = await contentMutationService.execute(
|
||||||
const [result] = await db.insert(WebsiteEvent).values({
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
...ctx.data,
|
"event.change",
|
||||||
hostUserId: Number(ctx.session.user.id),
|
{ action: "create", ...ctx.data },
|
||||||
updatedAt: now,
|
);
|
||||||
});
|
if (!result.ok) throw new ActionError("Event creation failed");
|
||||||
const eventId = Number(result.insertId);
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "event_create",
|
|
||||||
target: "WebsiteEvent",
|
|
||||||
targetId: eventId,
|
|
||||||
after: { title: ctx.data.title },
|
|
||||||
});
|
|
||||||
return actionOk({ id: eventId });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -131,31 +93,13 @@ const updateEventInput = updateEventSchema.extend({
|
|||||||
export const updateEvent = adminAction(
|
export const updateEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const result = await contentMutationService.execute(
|
||||||
const [existing] = await db
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.select({
|
"event.change",
|
||||||
id: WebsiteEvent.id,
|
{ action: "update", ...ctx.data },
|
||||||
title: WebsiteEvent.title,
|
);
|
||||||
status: WebsiteEvent.status,
|
if (!result.ok) throw new ActionError("Event not found");
|
||||||
})
|
return actionOk({ id: ctx.data.id });
|
||||||
.from(WebsiteEvent)
|
|
||||||
.where(eq(WebsiteEvent.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) throw new ActionError("Event not found");
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(WebsiteEvent)
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(WebsiteEvent.id, id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "event_update",
|
|
||||||
target: "WebsiteEvent",
|
|
||||||
targetId: id,
|
|
||||||
before: { title: existing.title, status: existing.status },
|
|
||||||
after: data,
|
|
||||||
});
|
|
||||||
return actionOk({ id });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -166,21 +110,12 @@ const deleteEventInput = z.object({
|
|||||||
export const deleteEvent = adminAction(
|
export const deleteEvent = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [existing] = await db
|
const result = await contentMutationService.execute(
|
||||||
.select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.from(WebsiteEvent)
|
"event.change",
|
||||||
.where(eq(WebsiteEvent.id, ctx.data.id))
|
{ action: "delete", ...ctx.data },
|
||||||
.limit(1);
|
);
|
||||||
if (!existing) throw new ActionError("Event not found");
|
if (!result.ok) throw new ActionError("Event not found");
|
||||||
|
|
||||||
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "event_delete",
|
|
||||||
target: "WebsiteEvent",
|
|
||||||
targetId: ctx.data.id,
|
|
||||||
before: { title: existing.title },
|
|
||||||
});
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -190,8 +125,13 @@ export const deleteEvent = adminAction(
|
|||||||
export const addEventPrize = adminAction(
|
export const addEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
|
const result = await contentMutationService.execute(
|
||||||
return actionOk({ id: Number(result.insertId) });
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
|
"event-prize.change",
|
||||||
|
{ action: "create", ...ctx.data },
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new ActionError("Event prize creation failed");
|
||||||
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -200,9 +140,12 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
|||||||
export const deleteEventPrize = adminAction(
|
export const deleteEventPrize = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await db
|
const result = await contentMutationService.execute(
|
||||||
.delete(WebsiteEventPrize)
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.where(eq(WebsiteEventPrize.id, ctx.data.id));
|
"event-prize.change",
|
||||||
|
{ action: "delete", ...ctx.data },
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new ActionError("Event prize deletion failed");
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -212,20 +155,13 @@ export const deleteEventPrize = adminAction(
|
|||||||
export const addEventWinner = adminAction(
|
export const addEventWinner = adminAction(
|
||||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
|
const result = await contentMutationService.execute(
|
||||||
const winnerId = Number(result.insertId);
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
logAudit({
|
"event-winner.add",
|
||||||
userId: ctx.session.user.id,
|
ctx.data,
|
||||||
action: "event_winner_add",
|
);
|
||||||
target: "WebsiteEventWinner",
|
if (!result.ok) throw new ActionError("Event winner creation failed");
|
||||||
targetId: winnerId,
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
after: {
|
|
||||||
eventId: ctx.data.eventId,
|
|
||||||
userId: ctx.data.userId,
|
|
||||||
position: ctx.data.position,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return actionOk({ id: winnerId });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+41
-66
@@ -3,6 +3,7 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
WebsitePoll,
|
WebsitePoll,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction, authAction } from "@/lib/safe-action";
|
import { adminAction, authAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
pollQuestionSchema,
|
pollQuestionSchema,
|
||||||
@@ -25,20 +25,13 @@ import {
|
|||||||
export const createPoll = adminAction(
|
export const createPoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const now = new Date();
|
const result = await contentMutationService.execute(
|
||||||
const [result] = await db.insert(WebsitePoll).values({
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
...ctx.data,
|
"poll.change",
|
||||||
updatedAt: now,
|
{ action: "create", ...ctx.data },
|
||||||
});
|
);
|
||||||
const pollId = Number(result.insertId);
|
if (!result.ok) throw new ActionError("Poll creation failed");
|
||||||
logAudit({
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "poll_create",
|
|
||||||
target: "WebsitePoll",
|
|
||||||
targetId: pollId,
|
|
||||||
after: { title: ctx.data.title },
|
|
||||||
});
|
|
||||||
return actionOk({ id: pollId });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,31 +42,13 @@ const updatePollInput = updatePollSchema.extend({
|
|||||||
export const updatePoll = adminAction(
|
export const updatePoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const result = await contentMutationService.execute(
|
||||||
const [existing] = await db
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.select({
|
"poll.change",
|
||||||
id: WebsitePoll.id,
|
{ action: "update", ...ctx.data },
|
||||||
title: WebsitePoll.title,
|
);
|
||||||
status: WebsitePoll.status,
|
if (!result.ok) throw new ActionError("Poll not found");
|
||||||
})
|
return actionOk({ id: ctx.data.id });
|
||||||
.from(WebsitePoll)
|
|
||||||
.where(eq(WebsitePoll.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!existing) throw new ActionError("Poll not found");
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(WebsitePoll)
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where(eq(WebsitePoll.id, id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "poll_update",
|
|
||||||
target: "WebsitePoll",
|
|
||||||
targetId: id,
|
|
||||||
before: { title: existing.title, status: existing.status },
|
|
||||||
after: data,
|
|
||||||
});
|
|
||||||
return actionOk({ id });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,21 +59,12 @@ const deletePollInput = z.object({
|
|||||||
export const deletePoll = adminAction(
|
export const deletePoll = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [existing] = await db
|
const result = await contentMutationService.execute(
|
||||||
.select({ id: WebsitePoll.id, title: WebsitePoll.title })
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.from(WebsitePoll)
|
"poll.change",
|
||||||
.where(eq(WebsitePoll.id, ctx.data.id))
|
{ action: "delete", ...ctx.data },
|
||||||
.limit(1);
|
);
|
||||||
if (!existing) throw new ActionError("Poll not found");
|
if (!result.ok) throw new ActionError("Poll not found");
|
||||||
|
|
||||||
await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id));
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "poll_delete",
|
|
||||||
target: "WebsitePoll",
|
|
||||||
targetId: ctx.data.id,
|
|
||||||
before: { title: existing.title },
|
|
||||||
});
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -108,8 +74,13 @@ export const deletePoll = adminAction(
|
|||||||
export const addPollQuestion = adminAction(
|
export const addPollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
|
const result = await contentMutationService.execute(
|
||||||
return actionOk({ id: Number(result.insertId) });
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
|
"poll-question.change",
|
||||||
|
{ action: "create", ...ctx.data },
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new ActionError("Poll question creation failed");
|
||||||
|
return actionOk({ id: Number(result.data.output?.id) });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,12 +91,13 @@ const updateQuestionInput = pollQuestionSchema.partial().extend({
|
|||||||
export const updatePollQuestion = adminAction(
|
export const updatePollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const { id, ...data } = ctx.data;
|
const result = await contentMutationService.execute(
|
||||||
await db
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.update(WebsitePollQuestion)
|
"poll-question.change",
|
||||||
.set(data)
|
{ action: "update", ...ctx.data },
|
||||||
.where(eq(WebsitePollQuestion.id, id));
|
);
|
||||||
return actionOk({ id });
|
if (!result.ok) throw new ActionError("Poll question update failed");
|
||||||
|
return actionOk({ id: ctx.data.id });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -136,9 +108,12 @@ const deleteQuestionInput = z.object({
|
|||||||
export const deletePollQuestion = adminAction(
|
export const deletePollQuestion = adminAction(
|
||||||
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
await db
|
const result = await contentMutationService.execute(
|
||||||
.delete(WebsitePollQuestion)
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
.where(eq(WebsitePollQuestion.id, ctx.data.id));
|
"poll-question.change",
|
||||||
|
{ action: "delete", ...ctx.data },
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new ActionError("Poll question deletion failed");
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-127
@@ -1,17 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, User } from "@/lib/db";
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||||
|
|
||||||
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
|
|
||||||
// are not represented in src/db/schema.ts yet — we use parameterized raw SQL.
|
|
||||||
|
|
||||||
// ── Create prefix ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const createPrefixSchema = z.object({
|
const createPrefixSchema = z.object({
|
||||||
username: z.string().min(1),
|
username: z.string().min(1),
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
@@ -20,30 +14,6 @@ const createPrefixSchema = z.object({
|
|||||||
effect: z.string().optional(),
|
effect: z.string().optional(),
|
||||||
active: z.coerce.number().int().min(0).max(1).default(1),
|
active: z.coerce.number().int().min(0).max(1).default(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createPrefix = adminAction(
|
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
|
||||||
async (ctx) => {
|
|
||||||
const { username, text, color, icon, effect, active } = ctx.data;
|
|
||||||
|
|
||||||
const [user] = await db
|
|
||||||
.select({ id: User.id })
|
|
||||||
.from(User)
|
|
||||||
.where(eq(User.username, username))
|
|
||||||
.limit(1);
|
|
||||||
if (!user) throw new ActionError("User not found");
|
|
||||||
|
|
||||||
await db.execute(sql`
|
|
||||||
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
|
|
||||||
VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
|
|
||||||
`);
|
|
||||||
|
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Update prefix ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const updatePrefixSchema = z.object({
|
const updatePrefixSchema = z.object({
|
||||||
id: z.coerce.number().int().positive(),
|
id: z.coerce.number().int().positive(),
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
@@ -52,101 +22,25 @@ const updatePrefixSchema = z.object({
|
|||||||
effect: z.string().optional(),
|
effect: z.string().optional(),
|
||||||
active: z.coerce.number().int().min(0).max(1).optional(),
|
active: z.coerce.number().int().min(0).max(1).optional(),
|
||||||
});
|
});
|
||||||
|
const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) });
|
||||||
|
const removeBlacklistWordSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||||
|
const updatePrefixSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) });
|
||||||
|
|
||||||
export const updatePrefix = adminAction(
|
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>) {
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
const result = await contentMutationService.execute(
|
||||||
async (ctx) => {
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
const { id, text, color, icon, effect, active } = ctx.data;
|
operation,
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
if (!result.ok && result.error.code === "NOT_FOUND") throw new ActionError("User not found");
|
||||||
|
if (!result.ok) throw new Error(result.error.messageKey);
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|
||||||
await db.execute(sql`
|
export const createPrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data }));
|
||||||
UPDATE custom_prefixes
|
export const updatePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data }));
|
||||||
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
|
export const deletePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data }));
|
||||||
WHERE id = ${id}
|
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));
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Delete prefix ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const deletePrefixSchema = z.object({
|
|
||||||
id: z.coerce.number().int().positive(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deletePrefix = adminAction(
|
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
|
||||||
async (ctx) => {
|
|
||||||
await db.execute(
|
|
||||||
sql`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`,
|
|
||||||
);
|
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Add blacklist word ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const addBlacklistWordSchema = z.object({
|
|
||||||
word: z.string().min(1).max(100),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const addBlacklistWord = adminAction(
|
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
|
||||||
async (ctx) => {
|
|
||||||
await db.execute(sql`
|
|
||||||
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
|
||||||
`);
|
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Remove blacklist word ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const removeBlacklistWordSchema = z.object({
|
|
||||||
id: z.coerce.number().int().positive(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const removeBlacklistWord = adminAction(
|
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
|
||||||
async (ctx) => {
|
|
||||||
await db.execute(
|
|
||||||
sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`,
|
|
||||||
);
|
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Update prefix settings ──────────────────────────────────────────
|
|
||||||
|
|
||||||
const SETTINGS_WHITELIST = new Set([
|
|
||||||
"enabled",
|
|
||||||
"max_length",
|
|
||||||
"min_rank",
|
|
||||||
"min_rank_to_buy",
|
|
||||||
"allow_colors",
|
|
||||||
"allow_bold",
|
|
||||||
"allow_italic",
|
|
||||||
"default_color",
|
|
||||||
"price_credits",
|
|
||||||
"price_points",
|
|
||||||
"points_type",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const updatePrefixSettingsSchema = z.object({
|
|
||||||
settings: z.record(z.string(), z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updatePrefixSettings = adminAction(
|
|
||||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
|
||||||
async (ctx) => {
|
|
||||||
for (const [key, value] of Object.entries(ctx.data.settings)) {
|
|
||||||
if (!SETTINGS_WHITELIST.has(key)) continue;
|
|
||||||
await db.execute(sql`
|
|
||||||
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
|
|
||||||
VALUES (${key}, ${value})
|
|
||||||
ON DUPLICATE KEY UPDATE \`value\` = ${value}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
return actionOk();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
+37
-127
@@ -1,136 +1,46 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import {
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
contentMutationService,
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const FAVICON_DIR = resolveMediaPath("favicon");
|
const MAX_SIZE = 2 * 1024 * 1024;
|
||||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
||||||
const ALLOWED = [
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"image/x-icon",
|
|
||||||
"image/svg+xml",
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function saveFavicon(
|
export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
formData: FormData,
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
const file = formData.get("file") as File | null;
|
||||||
try {
|
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||||
const file = formData.get("file") as File | null;
|
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||||
if (!file || file.size === 0)
|
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||||
return { success: false, error: "No file provided" };
|
const result = await contentMutationService.execute(
|
||||||
if (file.size > MAX_SIZE)
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
return { success: false, error: "File too large (max 2MB)" };
|
"favicon.save",
|
||||||
if (!ALLOWED.includes(file.type))
|
{ file },
|
||||||
return {
|
);
|
||||||
success: false,
|
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
siteRevalidate();
|
||||||
};
|
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||||
|
|
||||||
const mimeExt: Record<string, string> = {
|
|
||||||
"image/png": "png",
|
|
||||||
"image/jpeg": "jpg",
|
|
||||||
"image/gif": "gif",
|
|
||||||
"image/webp": "webp",
|
|
||||||
"image/x-icon": "ico",
|
|
||||||
"image/svg+xml": "svg",
|
|
||||||
};
|
|
||||||
const ext = mimeExt[file.type] ?? "png";
|
|
||||||
const filename = `favicon-${Date.now()}.${ext}`;
|
|
||||||
const baseDir = FAVICON_DIR;
|
|
||||||
const filePath = path.resolve(baseDir, filename);
|
|
||||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
|
||||||
return { success: false, error: "Invalid path" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(baseDir, { recursive: true });
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, buffer);
|
|
||||||
|
|
||||||
const url = `/api/media/favicon/${filename}`;
|
|
||||||
|
|
||||||
// Remove old favicon file if it exists
|
|
||||||
const oldUrl = await siteSettings.get("cms_favicon");
|
|
||||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
|
||||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
|
||||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
|
||||||
const oldPath = path.resolve(baseDir, oldName);
|
|
||||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await unlink(oldPath);
|
|
||||||
} catch {
|
|
||||||
/* ignore if file doesn't exist */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(WebsiteSetting)
|
|
||||||
.values({ key: "cms_favicon", value: url, comment: "Favicon URL" })
|
|
||||||
.onDuplicateKeyUpdate({ set: { value: url } });
|
|
||||||
|
|
||||||
siteSettings.reload();
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
revalidatePath("/admin/favicon");
|
|
||||||
|
|
||||||
return { success: true, url };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFavicon(): Promise<{
|
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> {
|
||||||
success: boolean;
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
error?: string;
|
const result = await contentMutationService.execute(
|
||||||
}> {
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
try {
|
"favicon.delete",
|
||||||
const oldUrl = await siteSettings.get("cms_favicon");
|
{},
|
||||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
);
|
||||||
const baseDir = FAVICON_DIR;
|
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
siteRevalidate();
|
||||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
return { success: true };
|
||||||
const oldPath = path.resolve(baseDir, oldName);
|
}
|
||||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await unlink(oldPath);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
function siteRevalidate(): void {
|
||||||
await db
|
revalidatePath("/", "layout");
|
||||||
.delete(WebsiteSetting)
|
revalidatePath("/admin/favicon");
|
||||||
.where(eq(WebsiteSetting.key, "cms_favicon"));
|
|
||||||
} catch {
|
|
||||||
/* ignore missing row */
|
|
||||||
}
|
|
||||||
siteSettings.reload();
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
revalidatePath("/admin/favicon");
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+19
-54
@@ -1,59 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import {
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
contentMutationService,
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MEDIA_DIR = resolveMediaPath("logo");
|
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
export async function saveLogo(
|
const file = formData.get("file") as File | null;
|
||||||
formData: FormData,
|
if (!file) return { success: false, error: "No file provided" };
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
const result = await contentMutationService.execute(
|
||||||
try {
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
const file = formData.get("file") as File | null;
|
"logo.save",
|
||||||
if (!file) return { success: false, error: "No file provided" };
|
{ file },
|
||||||
|
);
|
||||||
const ext =
|
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||||
file.type === "image/png"
|
revalidatePath("/", "layout");
|
||||||
? "png"
|
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||||
: file.type === "image/gif"
|
|
||||||
? "gif"
|
|
||||||
: file.type === "image/jpeg"
|
|
||||||
? "jpg"
|
|
||||||
: file.type === "image/webp"
|
|
||||||
? "webp"
|
|
||||||
: "png";
|
|
||||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
||||||
const baseDir = MEDIA_DIR;
|
|
||||||
const filePath = path.resolve(baseDir, filename);
|
|
||||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
|
||||||
return { success: false, error: "Invalid path" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(baseDir, { recursive: true });
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, buffer);
|
|
||||||
|
|
||||||
const url = `/api/media/logo/${filename}`;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(WebsiteSetting)
|
|
||||||
.values({ key: "cms_logo", value: url, comment: "Logo (generator)" })
|
|
||||||
.onDuplicateKeyUpdate({ set: { value: url } });
|
|
||||||
|
|
||||||
siteSettings.reload();
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
|
|
||||||
return { success: true, url };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,20 @@ import { readFileSync } from "node:fs";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
|
||||||
describe("admin-photos drizzle contract", () => {
|
describe("admin-photos Content service contract", () => {
|
||||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
|
const runtime = readFileSync(
|
||||||
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
it("deletes via Drizzle CameraWeb and attempts local file purge", () => {
|
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
||||||
expect(src).toContain("@/lib/db");
|
expect(wrapper).toContain("contentMutationService.execute");
|
||||||
expect(src).toContain("CameraWeb");
|
expect(wrapper).toContain('"photo.delete"');
|
||||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||||
expect(src).toContain('revalidatePath("/photos")');
|
expect(runtime).toContain("@/lib/db");
|
||||||
|
expect(runtime).toContain("CameraWeb");
|
||||||
|
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+21
-99
@@ -1,124 +1,46 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import * as JSONC from "jsonc-parser";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
CLIENT_TRANSLATION_FILES,
|
import { CLIENT_TRANSLATION_FILES } from "@/lib/client-translation-files";
|
||||||
getClientTranslationFile,
|
|
||||||
} from "@/lib/client-translation-files";
|
|
||||||
import { patchJson5 } from "@/lib/json5-patch";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
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([
|
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"]),
|
||||||
"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({
|
||||||
|
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]]),
|
||||||
|
data: z.record(z.string(), z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
export const saveTranslations = adminAction(
|
export const saveTranslations = adminAction(
|
||||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const filePath = path.join(
|
const result = await contentMutationService.execute(
|
||||||
process.cwd(),
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
"src",
|
"translation.cms.save",
|
||||||
"messages",
|
ctx.data,
|
||||||
`${ctx.data.locale}.json`,
|
|
||||||
);
|
);
|
||||||
await fs.writeFile(
|
if (!result.ok) throw new ActionError("Translation save failed");
|
||||||
filePath,
|
|
||||||
JSON.stringify(ctx.data.data, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const saveClientTranslationsSchema = z.object({
|
|
||||||
fileId: z.enum(
|
|
||||||
CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]],
|
|
||||||
),
|
|
||||||
data: z.record(z.string(), z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const saveClientTranslations = adminAction(
|
export const saveClientTranslations = adminAction(
|
||||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const file = getClientTranslationFile(ctx.data.fileId);
|
const result = await contentMutationService.execute(
|
||||||
if (!file) throw new ActionError("Unknown file");
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
if (file.readOnly) throw new ActionError("File is read-only");
|
"translation.client.save",
|
||||||
|
ctx.data,
|
||||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
|
||||||
// Turbopack's static tracer can't prove that — without the hint it
|
|
||||||
// pulls the entire project into the NFT list.
|
|
||||||
const absPath = path.join(
|
|
||||||
/*turbopackIgnore: true*/ process.cwd(),
|
|
||||||
file.relPath,
|
|
||||||
);
|
);
|
||||||
const raw = await fs.readFile(absPath, "utf-8");
|
if (!result.ok) throw new ActionError("Translation save failed");
|
||||||
|
return actionOk({
|
||||||
if (file.format === "json") {
|
commentsLost: result.data.output?.commentsLost === true,
|
||||||
// Plain JSON — no comments to preserve, just round-trip.
|
unpatchedKeys: Array.isArray(result.data.output?.unpatchedKeys) ? result.data.output.unpatchedKeys : [],
|
||||||
await fs.writeFile(
|
});
|
||||||
absPath,
|
|
||||||
JSON.stringify(ctx.data.data, null, 4),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON5: surgical line-level patch keeps headers and section comments
|
|
||||||
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
|
||||||
// only when an edited key cannot be located via the patch contract.
|
|
||||||
const original: Record<string, string> = {};
|
|
||||||
const parsed = JSONC.parse(raw);
|
|
||||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
||||||
for (const [k, v] of Object.entries(parsed)) {
|
|
||||||
original[k] = v == null ? "" : String(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
|
||||||
|
|
||||||
if (unpatchedKeys.length === 0) {
|
|
||||||
await fs.writeFile(absPath, content, "utf-8");
|
|
||||||
return actionOk({ commentsLost: false, unpatchedKeys });
|
|
||||||
}
|
|
||||||
|
|
||||||
// At least one key could not be patched surgically (e.g. unusual
|
|
||||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
|
||||||
// and warn the caller that comments were lost.
|
|
||||||
await fs.writeFile(
|
|
||||||
absPath,
|
|
||||||
JSON.stringify(ctx.data.data, null, 4),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
return actionOk({ commentsLost: true, unpatchedKeys });
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
import {
|
||||||
|
CONTENT_COMMAND_IDS,
|
||||||
|
createContentCommands,
|
||||||
|
} from "./content-commands";
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
|
||||||
|
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
|
||||||
|
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
|
||||||
|
[
|
||||||
|
"content.engagement.event-type.change",
|
||||||
|
"event-type.change",
|
||||||
|
PERMS.EVENTS_EDIT,
|
||||||
|
],
|
||||||
|
["content.engagement.event.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.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||||
|
[
|
||||||
|
"content.engagement.poll-question.change",
|
||||||
|
"poll-question.change",
|
||||||
|
PERMS.POLLS_EDIT,
|
||||||
|
],
|
||||||
|
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||||
|
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||||
|
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||||
|
[
|
||||||
|
"content.editorial.navigation.update",
|
||||||
|
"navigation.update",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||||
|
[
|
||||||
|
"content.engagement.prefix.change",
|
||||||
|
"prefix.change",
|
||||||
|
PERMS.PREFIXES_EDIT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.engagement.prefix-blacklist.change",
|
||||||
|
"prefix-blacklist.change",
|
||||||
|
PERMS.PREFIXES_EDIT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.engagement.prefix-settings.update",
|
||||||
|
"prefix-settings.update",
|
||||||
|
PERMS.PREFIXES_EDIT,
|
||||||
|
],
|
||||||
|
["content.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.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
|
||||||
|
[
|
||||||
|
"content.brand.theme.apply-preset",
|
||||||
|
"theme.apply-preset",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.brand.theme.custom-change",
|
||||||
|
"theme.custom-change",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.brand.theme.apply-custom",
|
||||||
|
"theme.apply-custom",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
|
||||||
|
[
|
||||||
|
"content.localization.cms.save",
|
||||||
|
"translation.cms.save",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.localization.client.save",
|
||||||
|
"translation.client.save",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.localization.emulator.save",
|
||||||
|
"translation.emulator.save",
|
||||||
|
PERMS.SETTINGS_EDIT,
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function context(): HousekeepingCapabilityContext {
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: () => true,
|
||||||
|
hasAny: () => true,
|
||||||
|
hasAll: () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Content commands", () => {
|
||||||
|
it("registers the exact complete operation matrix with existing ACLs", () => {
|
||||||
|
const service = { execute: vi.fn() };
|
||||||
|
const commands = createContentCommands(service as never);
|
||||||
|
|
||||||
|
expect(CONTENT_COMMAND_IDS).toEqual(expected.map(([id]) => id));
|
||||||
|
expect(
|
||||||
|
commands.map((command) => [
|
||||||
|
command.id,
|
||||||
|
command.operation,
|
||||||
|
command.capability.slugs[0],
|
||||||
|
]),
|
||||||
|
).toEqual(expected);
|
||||||
|
expect(commands.every((command) => command.owner === "content")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks global brand and localization writes sensitive with reason confirmation", () => {
|
||||||
|
const commands = createContentCommands({ execute: vi.fn() } as never);
|
||||||
|
const globalCommands = commands.filter(
|
||||||
|
(command) =>
|
||||||
|
command.id.startsWith("content.brand.") ||
|
||||||
|
command.id.startsWith("content.localization."),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalCommands.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
globalCommands.every(
|
||||||
|
(command) => command.risk === "sensitive" && command.requiresReason,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
globalCommands.every(
|
||||||
|
(command) =>
|
||||||
|
command.capability.mode === "any" &&
|
||||||
|
command.capability.slugs[0] === PERMS.SETTINGS_EDIT,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes the real mutation service with actor-bound authority", async () => {
|
||||||
|
const execute = vi.fn(async () => ({
|
||||||
|
ok: true as const,
|
||||||
|
data: { before: null, after: { id: "1" } },
|
||||||
|
correlationId: "command-correlation",
|
||||||
|
}));
|
||||||
|
const [command] = createContentCommands({ execute } as never);
|
||||||
|
|
||||||
|
const result = await command.execute(
|
||||||
|
{
|
||||||
|
capability: context(),
|
||||||
|
correlationId: "command-correlation",
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
},
|
||||||
|
{ action: "create", title: "Launch" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execute).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
correlationId: "command-correlation",
|
||||||
|
expectedActorId: 42,
|
||||||
|
},
|
||||||
|
"article.change",
|
||||||
|
{ action: "create", title: "Launch" },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates command validation schemas from later caller mutation", () => {
|
||||||
|
const commands = createContentCommands({ execute: vi.fn() } as never);
|
||||||
|
for (const command of commands) {
|
||||||
|
expect(command.input.safeParse(null).success, command.id).toBe(false);
|
||||||
|
expect(command.rateLimit.attempts).toBeGreaterThan(0);
|
||||||
|
expect(command.rateLimit.windowMs).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCommand } from "../../../foundation/commands/registry";
|
||||||
|
import { anyCapability } from "../../../foundation/contracts";
|
||||||
|
import {
|
||||||
|
type ContentMutationOperation,
|
||||||
|
type ContentMutationService,
|
||||||
|
contentMutationService,
|
||||||
|
} from "../services/mutations";
|
||||||
|
|
||||||
|
const CONTENT_COMMAND_DEFINITIONS = [
|
||||||
|
["content.editorial.article.change", "article.change", PERMS.NEWS_EDIT],
|
||||||
|
["content.media.ad.change", "ad.change", PERMS.PAGES_EDIT],
|
||||||
|
["content.media.banner.change", "banner.change", PERMS.BANNERS_EDIT],
|
||||||
|
["content.engagement.event-type.change", "event-type.change", PERMS.EVENTS_EDIT],
|
||||||
|
["content.engagement.event.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.poll.change", "poll.change", PERMS.POLLS_EDIT],
|
||||||
|
["content.engagement.poll-question.change", "poll-question.change", PERMS.POLLS_EDIT],
|
||||||
|
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||||
|
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||||
|
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||||
|
["content.editorial.navigation.update", "navigation.update", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||||
|
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||||
|
["content.engagement.prefix-blacklist.change", "prefix-blacklist.change", PERMS.PREFIXES_EDIT],
|
||||||
|
["content.engagement.prefix-settings.update", "prefix-settings.update", PERMS.PREFIXES_EDIT],
|
||||||
|
["content.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.brand.theme.update", "theme.update", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.theme.apply-preset", "theme.apply-preset", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.theme.custom-change", "theme.custom-change", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.theme.apply-custom", "theme.apply-custom", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.favicon.save", "favicon.save", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.favicon.delete", "favicon.delete", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.brand.logo.save", "logo.save", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.localization.cms.save", "translation.cms.save", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.localization.client.save", "translation.client.save", PERMS.SETTINGS_EDIT],
|
||||||
|
["content.localization.emulator.save", "translation.emulator.save", PERMS.SETTINGS_EDIT],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const CONTENT_COMMAND_IDS = CONTENT_COMMAND_DEFINITIONS.map(
|
||||||
|
([id]) => id,
|
||||||
|
) as ReadonlyArray<(typeof CONTENT_COMMAND_DEFINITIONS)[number][0]>;
|
||||||
|
|
||||||
|
type ContentCommand = HousekeepingCommand<Record<string, unknown>, unknown> & {
|
||||||
|
readonly operation: ContentMutationOperation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandInput = z.object({}).catchall(z.unknown());
|
||||||
|
|
||||||
|
export function createContentCommands(
|
||||||
|
service: Pick<ContentMutationService, "execute">,
|
||||||
|
): readonly ContentCommand[] {
|
||||||
|
return CONTENT_COMMAND_DEFINITIONS.map(
|
||||||
|
([id, operation, permission]): ContentCommand => ({
|
||||||
|
id,
|
||||||
|
owner: "content",
|
||||||
|
operation,
|
||||||
|
risk: "sensitive",
|
||||||
|
capability: anyCapability(permission),
|
||||||
|
input: commandInput,
|
||||||
|
requiresReason:
|
||||||
|
id.startsWith("content.brand.") ||
|
||||||
|
id.startsWith("content.localization."),
|
||||||
|
rateLimit: { attempts: 10, windowMs: 60_000 },
|
||||||
|
execute: (context, input) =>
|
||||||
|
service.execute(
|
||||||
|
{
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
expectedActorId: context.capability.actor.id,
|
||||||
|
},
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_COMMANDS = createContentCommands(contentMutationService);
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
|
import { loadContentInboxItems } from "./inbox-production";
|
||||||
|
import { loadContentSearchCandidates } from "./search-production";
|
||||||
|
import { loadContentWidget } from "./widgets-production";
|
||||||
|
|
||||||
|
const { run } = vi.hoisted(() => ({ run: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("./queries/content-queries", () => ({
|
||||||
|
contentQuery: { run },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: () => true,
|
||||||
|
hasAny: () => true,
|
||||||
|
hasAll: () => true,
|
||||||
|
} satisfies HousekeepingCapabilityContext;
|
||||||
|
|
||||||
|
function result(
|
||||||
|
routeId: string,
|
||||||
|
total: number,
|
||||||
|
items: readonly Record<string, unknown>[] = [],
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
data: {
|
||||||
|
kind: routeId.split(".")[1],
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
partialDependencies: [],
|
||||||
|
},
|
||||||
|
correlationId: "provider-production",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Content production providers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
run.mockImplementation(async (_context, input) =>
|
||||||
|
result(input.routeId, input.routeId.includes("articles") ? 7 : 3),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only truthful persisted counts from editorial and localization widgets", async () => {
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
await expect(loadContentWidget("editorial", context, signal)).resolves.toEqual(
|
||||||
|
{ articles: 7 },
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
loadContentWidget("localization", context, signal),
|
||||||
|
).resolves.toEqual({ stores: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads real search candidates from bounded query routes", async () => {
|
||||||
|
run.mockImplementation(async (_context, input) =>
|
||||||
|
result(input.routeId, 1, [
|
||||||
|
{
|
||||||
|
id: "9",
|
||||||
|
title: "Launch",
|
||||||
|
description: "Published",
|
||||||
|
href: "/ase/content/editorial/articles/9",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = await loadContentSearchCandidates(
|
||||||
|
"articles",
|
||||||
|
context,
|
||||||
|
"launch",
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
expect(candidates).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "content.editorial.articles:9",
|
||||||
|
href: "/ase/content/editorial/articles/9",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(run).toHaveBeenCalledWith(
|
||||||
|
context,
|
||||||
|
expect.objectContaining({ list: { search: "launch", pageSize: 25, offset: 0 } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds publication inbox items only from real query rows", async () => {
|
||||||
|
run.mockImplementation(async (_context, input) =>
|
||||||
|
result(input.routeId, 1, [
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
title: "Release",
|
||||||
|
status: "published",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
href: "/ase/content/editorial/articles/5",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = await loadContentInboxItems(
|
||||||
|
"publication",
|
||||||
|
context,
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]).toMatchObject({
|
||||||
|
itemId: "5",
|
||||||
|
sourceId: "content.publication",
|
||||||
|
href: "/ase/content/editorial/articles/5",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
import {
|
||||||
|
CONTENT_INBOX_SOURCE_IDS,
|
||||||
|
createContentInboxSources,
|
||||||
|
} from "./inbox";
|
||||||
|
import {
|
||||||
|
CONTENT_SEARCH_PROVIDER_IDS,
|
||||||
|
createContentSearchProviders,
|
||||||
|
} from "./search";
|
||||||
|
import { CONTENT_WIDGET_IDS, createContentWidgets } from "./widgets";
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Content search providers", () => {
|
||||||
|
it("uses the four exact IDs, filters item capabilities, and caps results at 25", async () => {
|
||||||
|
const visible = anyCapability(PERMS.NEWS_VIEW);
|
||||||
|
const hidden = anyCapability(PERMS.EVENTS_VIEW);
|
||||||
|
const candidates = Array.from({ length: 30 }, (_, index) => ({
|
||||||
|
id: `article-${index}`,
|
||||||
|
title: `Article ${index}`,
|
||||||
|
href: `/ase/content/editorial/articles/${index + 1}`,
|
||||||
|
capability: index === 0 ? hidden : visible,
|
||||||
|
}));
|
||||||
|
const providerAdapters = {
|
||||||
|
articles: vi.fn(async () => candidates),
|
||||||
|
events: vi.fn(async () => []),
|
||||||
|
media: vi.fn(async () => []),
|
||||||
|
help: vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
const providers = createContentSearchProviders(providerAdapters);
|
||||||
|
|
||||||
|
expect(CONTENT_SEARCH_PROVIDER_IDS).toEqual([
|
||||||
|
"content.articles",
|
||||||
|
"content.events",
|
||||||
|
"content.media",
|
||||||
|
"content.help",
|
||||||
|
]);
|
||||||
|
expect(providers.map((provider) => provider.id)).toEqual(
|
||||||
|
CONTENT_SEARCH_PROVIDER_IDS,
|
||||||
|
);
|
||||||
|
const result = await providers[0].search(context([PERMS.NEWS_VIEW]), {
|
||||||
|
term: " launch ",
|
||||||
|
limit: 999,
|
||||||
|
});
|
||||||
|
expect(providerAdapters.articles).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
"launch",
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.data).toHaveLength(25);
|
||||||
|
expect(result.data.some((item) => item.id === "article-0")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"/ase/content/%2e%2e/system",
|
||||||
|
"/ase/content/%252e%252e/system",
|
||||||
|
"/ase/content/%252f..%252fsystem",
|
||||||
|
"/ase/content/%255c..%255csystem",
|
||||||
|
"https://example.test/ase/content/editorial",
|
||||||
|
"//example.test/ase/content/editorial",
|
||||||
|
])("rejects normalized and double-encoded traversal href %s", async (href) => {
|
||||||
|
const adapters = {
|
||||||
|
articles: async () => [
|
||||||
|
{
|
||||||
|
id: "unsafe",
|
||||||
|
title: "Unsafe",
|
||||||
|
href,
|
||||||
|
capability: anyCapability(PERMS.NEWS_VIEW),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: async () => [],
|
||||||
|
media: async () => [],
|
||||||
|
help: async () => [],
|
||||||
|
};
|
||||||
|
const [provider] = createContentSearchProviders(adapters);
|
||||||
|
const result = await provider.search(context([PERMS.NEWS_VIEW]), {
|
||||||
|
term: "",
|
||||||
|
limit: 25,
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: true, data: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Content inbox and widgets", () => {
|
||||||
|
it("provides capability-selective publication and attention sources", async () => {
|
||||||
|
const publication = vi.fn(async () => []);
|
||||||
|
const attention = vi.fn(async () => []);
|
||||||
|
const sources = createContentInboxSources({ publication, attention });
|
||||||
|
|
||||||
|
expect(CONTENT_INBOX_SOURCE_IDS).toEqual([
|
||||||
|
"content.publication",
|
||||||
|
"content.attention",
|
||||||
|
]);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const news = context([PERMS.NEWS_VIEW]);
|
||||||
|
await sources[0].getItems(news, controller.signal);
|
||||||
|
const forbidden = await sources[1].getItems(news, controller.signal);
|
||||||
|
expect(publication).toHaveBeenCalledTimes(1);
|
||||||
|
expect(attention).not.toHaveBeenCalled();
|
||||||
|
expect(forbidden).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps editorial mandatory and media/localization optional without preview DB imports", async () => {
|
||||||
|
const adapters = {
|
||||||
|
editorial: vi.fn(async () => ({ drafts: 2, scheduled: 1 })),
|
||||||
|
media: vi.fn(async () => ({ items: 4 })),
|
||||||
|
localization: vi.fn(async () => ({ stores: 3, pending: 0 })),
|
||||||
|
};
|
||||||
|
const widgets = createContentWidgets(adapters);
|
||||||
|
|
||||||
|
expect(CONTENT_WIDGET_IDS).toEqual([
|
||||||
|
"content.editorial-summary",
|
||||||
|
"content.media-summary",
|
||||||
|
"content.localization-summary",
|
||||||
|
]);
|
||||||
|
expect(widgets.map((widget) => widget.kind)).toEqual([
|
||||||
|
"mandatory",
|
||||||
|
"optional",
|
||||||
|
"optional",
|
||||||
|
]);
|
||||||
|
const result = await widgets[0].load(
|
||||||
|
context([PERMS.NEWS_VIEW]),
|
||||||
|
new AbortController().signal,
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { drafts: 2, scheduled: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingWorkItem,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
import { contentQuery } from "./queries/content-queries";
|
||||||
|
|
||||||
|
type ContentInboxKind = "publication" | "attention";
|
||||||
|
|
||||||
|
function time(value: string | null | undefined) {
|
||||||
|
if (!value) return null;
|
||||||
|
const timestamp = Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return null;
|
||||||
|
const ageMs = Math.max(0, Date.now() - timestamp);
|
||||||
|
return {
|
||||||
|
occurredAt: new Date(timestamp).toISOString(),
|
||||||
|
ageMs,
|
||||||
|
freshness: ageMs > 86_400_000 ? ("stale" as const) : ("fresh" as const),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadContentInboxItems(
|
||||||
|
kind: ContentInboxKind,
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<readonly HousekeepingWorkItem[]> {
|
||||||
|
if (signal.aborted) throw new Error("aborted Content inbox");
|
||||||
|
const definitions =
|
||||||
|
kind === "publication"
|
||||||
|
? ([
|
||||||
|
["content.editorial.articles", PERMS.NEWS_VIEW, "content.publication"],
|
||||||
|
] as const)
|
||||||
|
: ([
|
||||||
|
["content.engagement.events", PERMS.EVENTS_VIEW, "content.attention"],
|
||||||
|
["content.engagement.polls", PERMS.POLLS_VIEW, "content.attention"],
|
||||||
|
] as const);
|
||||||
|
const items: HousekeepingWorkItem[] = [];
|
||||||
|
for (const [routeId, permission, sourceId] of definitions) {
|
||||||
|
const result = await contentQuery.run(context, {
|
||||||
|
routeId,
|
||||||
|
list: { pageSize: 25, offset: 0 },
|
||||||
|
});
|
||||||
|
if (!result.ok) continue;
|
||||||
|
for (const item of result.data.items) {
|
||||||
|
const date = time(item.updatedAt);
|
||||||
|
if (!date || !item.href) continue;
|
||||||
|
items.push({
|
||||||
|
sourceId,
|
||||||
|
itemId: item.id,
|
||||||
|
deduplicationKey: sourceId + ":" + routeId + ":" + item.id,
|
||||||
|
domain: "content",
|
||||||
|
capability: anyCapability(permission),
|
||||||
|
severity: item.status === "failed" ? "warning" : "info",
|
||||||
|
priority: item.status === "failed" ? "high" : "normal",
|
||||||
|
...date,
|
||||||
|
state: item.status ?? "ready",
|
||||||
|
titleKey: "pages.housekeeping.items.content",
|
||||||
|
context: { title: item.title },
|
||||||
|
href: item.href as `/ase/${string}`,
|
||||||
|
actions: [],
|
||||||
|
});
|
||||||
|
if (items.length >= 25) return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||||
|
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type CapabilityRequirement,
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingInboxSource,
|
||||||
|
type HousekeepingWorkItem,
|
||||||
|
ok,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||||
|
|
||||||
|
export const CONTENT_INBOX_SOURCE_IDS = [
|
||||||
|
"content.publication",
|
||||||
|
"content.attention",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ContentInboxLoader = (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<readonly HousekeepingWorkItem[]>;
|
||||||
|
|
||||||
|
export interface ContentInboxAdapters {
|
||||||
|
readonly publication: ContentInboxLoader;
|
||||||
|
readonly attention: ContentInboxLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSource(
|
||||||
|
id: (typeof CONTENT_INBOX_SOURCE_IDS)[number],
|
||||||
|
capability: CapabilityRequirement,
|
||||||
|
load: ContentInboxLoader,
|
||||||
|
): HousekeepingInboxSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
owner: "content",
|
||||||
|
capability,
|
||||||
|
async getItems(context, signal) {
|
||||||
|
const authorization = authorizeHousekeeping(context, capability);
|
||||||
|
if (!authorization.ok) return authorization;
|
||||||
|
try {
|
||||||
|
const items = await load(context, signal);
|
||||||
|
return ok(
|
||||||
|
{
|
||||||
|
availability: "available" as const,
|
||||||
|
items: items
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
isSafeHousekeepingHref(item.href) &&
|
||||||
|
satisfiesCapability(context, item.capability),
|
||||||
|
)
|
||||||
|
.slice(0, 25),
|
||||||
|
},
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentInboxSources(
|
||||||
|
adapters: ContentInboxAdapters,
|
||||||
|
): readonly HousekeepingInboxSource[] {
|
||||||
|
return [
|
||||||
|
createSource(
|
||||||
|
"content.publication",
|
||||||
|
anyCapability(PERMS.NEWS_VIEW),
|
||||||
|
adapters.publication,
|
||||||
|
),
|
||||||
|
createSource(
|
||||||
|
"content.attention",
|
||||||
|
anyCapability(
|
||||||
|
PERMS.EVENTS_VIEW,
|
||||||
|
PERMS.POLLS_VIEW,
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
PERMS.BANNERS_VIEW,
|
||||||
|
),
|
||||||
|
adapters.attention,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_INBOX_SOURCES = createContentInboxSources({
|
||||||
|
async publication(context, signal) {
|
||||||
|
const { loadContentInboxItems } = await import("./inbox-production");
|
||||||
|
return loadContentInboxItems("publication", context, signal);
|
||||||
|
},
|
||||||
|
async attention(context, signal) {
|
||||||
|
const { loadContentInboxItems } = await import("./inbox-production");
|
||||||
|
return loadContentInboxItems("attention", context, signal);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
anyCapability,
|
anyCapability,
|
||||||
type HousekeepingDomainManifest,
|
type HousekeepingDomainManifest,
|
||||||
} from "../../foundation/contracts";
|
} from "../../foundation/contracts";
|
||||||
|
import { CONTENT_INBOX_SOURCES } from "./inbox";
|
||||||
|
import { CONTENT_ROUTES } from "./routes";
|
||||||
|
import { CONTENT_SEARCH_PROVIDERS } from "./search";
|
||||||
|
import { CONTENT_WIDGETS } from "./widgets";
|
||||||
|
|
||||||
export const contentManifest = {
|
export const contentManifest = {
|
||||||
id: "content",
|
id: "content",
|
||||||
@@ -26,8 +30,8 @@ export const contentManifest = {
|
|||||||
PERMS.SETTINGS_VIEW,
|
PERMS.SETTINGS_VIEW,
|
||||||
PERMS.SETTINGS_EDIT,
|
PERMS.SETTINGS_EDIT,
|
||||||
),
|
),
|
||||||
routes: [],
|
routes: CONTENT_ROUTES,
|
||||||
searchProviders: [],
|
searchProviders: CONTENT_SEARCH_PROVIDERS,
|
||||||
inboxSources: [],
|
inboxSources: CONTENT_INBOX_SOURCES,
|
||||||
widgets: [],
|
widgets: CONTENT_WIDGETS,
|
||||||
} satisfies HousekeepingDomainManifest;
|
} satisfies HousekeepingDomainManifest;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
|
import { contentQuery } from "../queries/content-queries";
|
||||||
|
import { ContentCommandForm } from "./content-command-form";
|
||||||
|
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||||
|
|
||||||
|
export function ContentBrandPage({ context, result, routeId }: ContentPageProps) {
|
||||||
|
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{routeId === "content.brand.theme" ? <>
|
||||||
|
<ContentCommandForm commandId="content.brand.theme.update" buttonLabel="Save theme values" input={{}} fields={[{ name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason />
|
||||||
|
<ContentCommandForm commandId="content.brand.theme.apply-preset" buttonLabel="Apply preset" input={{}} fields={[{ name: "preset", label: "Preset", type: "text", required: true, maxLength: 100 }]} requiresReason />
|
||||||
|
<ContentCommandForm commandId="content.brand.theme.custom-change" buttonLabel="Save custom theme" input={{ action: "update" }} fields={[{ name: "id", label: "Theme ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 100 }, { name: "values", label: "Theme values JSON", type: "json", required: true, maxLength: 20_000 }]} requiresReason />
|
||||||
|
<ContentCommandForm commandId="content.brand.theme.apply-custom" buttonLabel="Apply custom theme" input={{}} fields={[{ name: "id", label: "Theme ID", type: "identifier", required: true }]} requiresReason />
|
||||||
|
</> : null}
|
||||||
|
{routeId === "content.brand.favicon" ? <>
|
||||||
|
<ContentCommandForm commandId="content.brand.favicon.save" buttonLabel="Save favicon" input={{}} fields={[{ name: "file", label: "Favicon", type: "file", required: true }]} requiresReason />
|
||||||
|
<ContentCommandForm commandId="content.brand.favicon.delete" buttonLabel="Delete favicon" input={{}} requiresReason />
|
||||||
|
<ContentCommandForm commandId="content.brand.logo.save" buttonLabel="Save logo" input={{}} fields={[{ name: "file", label: "Logo", type: "file", required: true }]} requiresReason />
|
||||||
|
</> : null}
|
||||||
|
</div> : null;
|
||||||
|
return <ContentPageFrame title="Brand" description="Manage theme, favicon, and logo assets." result={result} forms={forms} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderContentBrandPage(input: HousekeepingPageInput) {
|
||||||
|
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||||
|
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.brand.theme", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||||
|
return <ContentBrandPage context={input.context} result={result} routeId={routeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import type { HousekeepingResult } from "../../../foundation/contracts";
|
||||||
|
|
||||||
|
export interface ContentCommandField {
|
||||||
|
readonly name: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly type:
|
||||||
|
| "identifier"
|
||||||
|
| "json"
|
||||||
|
| "text"
|
||||||
|
| "textarea"
|
||||||
|
| "number"
|
||||||
|
| "checkbox"
|
||||||
|
| "select"
|
||||||
|
| "file";
|
||||||
|
readonly required?: boolean;
|
||||||
|
readonly min?: number;
|
||||||
|
readonly max?: number;
|
||||||
|
readonly maxLength?: number;
|
||||||
|
readonly defaultValue?: string | number;
|
||||||
|
readonly options?: readonly Readonly<{
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentCommandSubmission {
|
||||||
|
readonly commandId: string;
|
||||||
|
readonly input: Readonly<Record<string, unknown>>;
|
||||||
|
readonly fields?: readonly ContentCommandField[];
|
||||||
|
readonly requiresReason?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContentCommandFormProps extends ContentCommandSubmission {
|
||||||
|
readonly buttonLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||||
|
const rawValue = formData.get(field.name);
|
||||||
|
if (field.type === "checkbox") return rawValue === "on";
|
||||||
|
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||||
|
const raw = String(rawValue ?? "").normalize("NFC").trim();
|
||||||
|
if (field.type === "number") {
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isSafeInteger(value)) return 0;
|
||||||
|
return Math.min(field.max ?? value, Math.max(field.min ?? value, value));
|
||||||
|
}
|
||||||
|
if (field.type === "select") {
|
||||||
|
const selected = field.options?.find(
|
||||||
|
(option) => String(option.value) === raw,
|
||||||
|
)?.value;
|
||||||
|
return selected ?? raw.slice(0, 500);
|
||||||
|
}
|
||||||
|
if (field.type === "json") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw.slice(0, field.maxLength ?? 20_000));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw.slice(0, field.maxLength ?? (field.type === "textarea" ? 20_000 : 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: HousekeepingResult<unknown> | null = null;
|
||||||
|
|
||||||
|
export async function submitContentCommandForm(
|
||||||
|
configuration: ContentCommandSubmission,
|
||||||
|
_previous: HousekeepingResult<unknown> | null,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<HousekeepingResult<unknown>> {
|
||||||
|
const input = {
|
||||||
|
...configuration.input,
|
||||||
|
...Object.fromEntries(
|
||||||
|
(configuration.fields ?? []).map((field) => [
|
||||||
|
field.name,
|
||||||
|
parseField(field, formData),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const reason = String(formData.get("reason") ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 1000);
|
||||||
|
const { executeHousekeepingCommand } = await import(
|
||||||
|
"@/actions/housekeeping-command"
|
||||||
|
);
|
||||||
|
return executeHousekeepingCommand({
|
||||||
|
commandId: configuration.commandId,
|
||||||
|
input,
|
||||||
|
...(configuration.requiresReason ? { reason } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentCommandForm({
|
||||||
|
commandId,
|
||||||
|
buttonLabel,
|
||||||
|
input,
|
||||||
|
fields = [],
|
||||||
|
requiresReason = false,
|
||||||
|
}: ContentCommandFormProps) {
|
||||||
|
const [result, submit, pending] = useActionState(
|
||||||
|
submitContentCommandForm.bind(null, {
|
||||||
|
commandId,
|
||||||
|
input,
|
||||||
|
fields,
|
||||||
|
requiresReason,
|
||||||
|
}),
|
||||||
|
initialState,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={submit}
|
||||||
|
data-housekeeping-command={commandId}
|
||||||
|
className="space-y-3 rounded border border-[var(--admin-border)] p-3"
|
||||||
|
>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<label
|
||||||
|
key={field.name}
|
||||||
|
htmlFor={`${commandId}-${field.name}`}
|
||||||
|
className="block text-sm"
|
||||||
|
>
|
||||||
|
{field.type === "checkbox" ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
id={`${commandId}-${field.name}`}
|
||||||
|
name={field.name}
|
||||||
|
type="checkbox"
|
||||||
|
/> {field.label}
|
||||||
|
</>
|
||||||
|
) : field.type === "select" ? (
|
||||||
|
<>
|
||||||
|
{field.label}
|
||||||
|
<select
|
||||||
|
id={`${commandId}-${field.name}`}
|
||||||
|
name={field.name}
|
||||||
|
defaultValue={field.defaultValue}
|
||||||
|
required={field.required}
|
||||||
|
className="mt-1 block w-full"
|
||||||
|
>
|
||||||
|
{field.options?.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
) : field.type === "textarea" || field.type === "json" ? (
|
||||||
|
<>
|
||||||
|
{field.label}
|
||||||
|
<textarea
|
||||||
|
id={`${commandId}-${field.name}`}
|
||||||
|
name={field.name}
|
||||||
|
required={field.required}
|
||||||
|
maxLength={field.maxLength}
|
||||||
|
className="mt-1 block w-full"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{field.label}
|
||||||
|
<input
|
||||||
|
id={`${commandId}-${field.name}`}
|
||||||
|
name={field.name}
|
||||||
|
type={
|
||||||
|
field.type === "number"
|
||||||
|
? "number"
|
||||||
|
: field.type === "file"
|
||||||
|
? "file"
|
||||||
|
: "text"
|
||||||
|
}
|
||||||
|
defaultValue={
|
||||||
|
field.type === "file" ? undefined : field.defaultValue
|
||||||
|
}
|
||||||
|
required={field.required}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
maxLength={field.maxLength}
|
||||||
|
className="mt-1 block w-full"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{requiresReason ? (
|
||||||
|
<label htmlFor={`${commandId}-reason`} className="block text-sm">
|
||||||
|
Reason
|
||||||
|
<textarea
|
||||||
|
id={`${commandId}-reason`}
|
||||||
|
name="reason"
|
||||||
|
required
|
||||||
|
maxLength={1000}
|
||||||
|
className="mt-1 block w-full"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? "Working..." : buttonLabel}
|
||||||
|
</button>
|
||||||
|
{result ? (
|
||||||
|
<p role="status" className="text-xs text-[var(--admin-text-muted)]">
|
||||||
|
{result.ok ? "Completed" : "Failed"} ({result.correlationId})
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type {
|
||||||
|
HousekeepingResult,
|
||||||
|
} from "../../../foundation/contracts";
|
||||||
|
import type { ContentQueryData } from "../queries/content-queries";
|
||||||
|
|
||||||
|
export interface ContentPageProps {
|
||||||
|
readonly context: import("../../../foundation/contracts").HousekeepingCapabilityContext;
|
||||||
|
readonly result?: HousekeepingResult<ContentQueryData>;
|
||||||
|
readonly routeId: import("../routes").ContentRouteId | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentPageFrame({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
result,
|
||||||
|
forms,
|
||||||
|
}: {
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly result?: HousekeepingResult<ContentQueryData>;
|
||||||
|
readonly forms?: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (!result) {
|
||||||
|
return (
|
||||||
|
<section data-housekeeping-state="loading">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
<p>Loading content…</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.ok) {
|
||||||
|
const state = result.error.code === "FORBIDDEN" ? "forbidden" : "error";
|
||||||
|
return (
|
||||||
|
<section data-housekeeping-state={state} role="alert">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{result.error.messageKey}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const state = result.data.items.length === 0 ? "empty" : "ready";
|
||||||
|
return (
|
||||||
|
<section data-housekeeping-state={state} className="space-y-4">
|
||||||
|
<header>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{description}</p>
|
||||||
|
</header>
|
||||||
|
{forms}
|
||||||
|
{result.data.items.length === 0 ? (
|
||||||
|
<p>No matching content.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-[var(--admin-border)]">
|
||||||
|
{result.data.items.map((item) => (
|
||||||
|
<li key={item.id} className="py-2">
|
||||||
|
{item.href ? <a href={item.href}>{item.title}</a> : item.title}
|
||||||
|
{item.status ? <span> — {item.status}</span> : null}
|
||||||
|
{item.description ? <p>{item.description}</p> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseContentListInput(
|
||||||
|
searchParams: Readonly<Record<string, string | readonly string[] | undefined>>,
|
||||||
|
) {
|
||||||
|
const first = (value: string | readonly string[] | undefined) =>
|
||||||
|
Array.isArray(value) ? value[0] : value;
|
||||||
|
return {
|
||||||
|
search: first(searchParams.search),
|
||||||
|
pageSize: Number(first(searchParams.pageSize) ?? 25),
|
||||||
|
offset: Number(first(searchParams.offset) ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import {
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
ok,
|
||||||
|
} from "../../../foundation/contracts";
|
||||||
|
import { ContentBrandPage } from "./brand";
|
||||||
|
import { submitContentCommandForm } from "./content-command-form";
|
||||||
|
import { ContentEditorialPage } from "./editorial";
|
||||||
|
import { ContentEngagementPage } from "./engagement";
|
||||||
|
import { ContentHelpPage } from "./help";
|
||||||
|
import { ContentLocalizationPage } from "./localization";
|
||||||
|
import { ContentMediaPage } from "./media";
|
||||||
|
|
||||||
|
vi.mock("@/actions/housekeeping-command", () => ({
|
||||||
|
executeHousekeepingCommand: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
["editorial", ContentEditorialPage, PERMS.NEWS_EDIT],
|
||||||
|
["media", ContentMediaPage, PERMS.PAGES_EDIT],
|
||||||
|
["engagement", ContentEngagementPage, PERMS.EVENTS_EDIT],
|
||||||
|
["help", ContentHelpPage, PERMS.PAGES_EDIT],
|
||||||
|
["brand", ContentBrandPage, PERMS.SETTINGS_EDIT],
|
||||||
|
["localization", ContentLocalizationPage, PERMS.SETTINGS_EDIT],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe.each(cases)("Content %s page", (kind, Component, editPermission) => {
|
||||||
|
it("renders loading, forbidden, empty, and real ready states", () => {
|
||||||
|
const read = context(Object.values(PERMS));
|
||||||
|
const render = (result?: unknown) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
<Component context={read} result={result as never} routeId={null} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(render()).toContain('data-housekeeping-state="loading"');
|
||||||
|
expect(
|
||||||
|
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", "forbidden")),
|
||||||
|
).toContain('data-housekeeping-state="forbidden"');
|
||||||
|
expect(
|
||||||
|
render(
|
||||||
|
ok(
|
||||||
|
{ kind, items: [], total: 0, partialDependencies: [] },
|
||||||
|
"empty",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toContain('data-housekeeping-state="empty"');
|
||||||
|
const ready = render(
|
||||||
|
ok(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "18446744073709551615",
|
||||||
|
title: "Canonical item",
|
||||||
|
status: "ready",
|
||||||
|
href: `/ase/content/${kind}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
partialDependencies: [],
|
||||||
|
},
|
||||||
|
"ready",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(ready).toContain('data-housekeeping-state="ready"');
|
||||||
|
expect(ready).toContain("Canonical item");
|
||||||
|
expect(ready).not.toContain("/admin");
|
||||||
|
expect(read.has(editPermission)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Content actionable form wiring", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("submits canonical identifiers and a bounded reason through the real server action", async () => {
|
||||||
|
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||||
|
ok({ before: null, after: { id: "18446744073709551615" } }, "form"),
|
||||||
|
);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("id", "18446744073709551615");
|
||||||
|
formData.set("title", " Updated title ");
|
||||||
|
formData.set("reason", ` ${"r".repeat(1100)} `);
|
||||||
|
|
||||||
|
const result = await submitContentCommandForm(
|
||||||
|
{
|
||||||
|
commandId: "content.editorial.article.change",
|
||||||
|
input: { action: "update" },
|
||||||
|
fields: [
|
||||||
|
{ name: "id", label: "ID", type: "identifier" },
|
||||||
|
{ name: "title", label: "Title", type: "text", maxLength: 255 },
|
||||||
|
],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||||
|
commandId: "content.editorial.article.change",
|
||||||
|
input: {
|
||||||
|
action: "update",
|
||||||
|
id: "18446744073709551615",
|
||||||
|
title: "Updated title",
|
||||||
|
},
|
||||||
|
reason: "r".repeat(1000),
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses structured JSON fields before dispatching real brand and localization forms", async () => {
|
||||||
|
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||||
|
ok({ before: null, after: { keyCount: 1 } }, "json-form"),
|
||||||
|
);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("data", '{ "welcome": "Hello" }');
|
||||||
|
|
||||||
|
await submitContentCommandForm(
|
||||||
|
{
|
||||||
|
commandId: "content.localization.cms.save",
|
||||||
|
input: { locale: "en" },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "data",
|
||||||
|
label: "Translations",
|
||||||
|
type: "json",
|
||||||
|
maxLength: 500_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requiresReason: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
formData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||||
|
commandId: "content.localization.cms.save",
|
||||||
|
input: { locale: "en", data: { welcome: "Hello" } },
|
||||||
|
reason: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders mutation forms only with the exact capability", () => {
|
||||||
|
const result = ok(
|
||||||
|
{
|
||||||
|
kind: "brand" as const,
|
||||||
|
items: [{ id: "theme", title: "Theme", status: "active" }],
|
||||||
|
total: 1,
|
||||||
|
partialDependencies: [],
|
||||||
|
},
|
||||||
|
"brand",
|
||||||
|
);
|
||||||
|
const readOnly = renderToStaticMarkup(
|
||||||
|
<ContentBrandPage
|
||||||
|
context={context([PERMS.SETTINGS_VIEW])}
|
||||||
|
result={result}
|
||||||
|
routeId="content.brand.theme"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const editor = renderToStaticMarkup(
|
||||||
|
<ContentBrandPage
|
||||||
|
context={context([PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT])}
|
||||||
|
result={result}
|
||||||
|
routeId="content.brand.theme"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readOnly).not.toContain("content.brand.theme.update");
|
||||||
|
expect(editor).toContain("content.brand.theme.update");
|
||||||
|
expect(editor).toContain("<form");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render email template bodies or localization payloads in summaries", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ContentHelpPage
|
||||||
|
context={context([PERMS.PAGES_VIEW])}
|
||||||
|
result={ok(
|
||||||
|
{
|
||||||
|
kind: "help",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Welcome",
|
||||||
|
status: "active",
|
||||||
|
privatePayload: "secret template body",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
partialDependencies: [],
|
||||||
|
},
|
||||||
|
"help",
|
||||||
|
)}
|
||||||
|
routeId="content.help.email-templates"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("Welcome");
|
||||||
|
expect(html).not.toContain("secret template body");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
|
import { contentQuery } from "../queries/content-queries";
|
||||||
|
import { ContentCommandForm } from "./content-command-form";
|
||||||
|
import {
|
||||||
|
ContentPageFrame,
|
||||||
|
type ContentPageProps,
|
||||||
|
parseContentListInput,
|
||||||
|
} from "./content-page-frame";
|
||||||
|
|
||||||
|
export function ContentEditorialPage({ context, result, routeId }: ContentPageProps) {
|
||||||
|
const canNews = context.has(PERMS.NEWS_EDIT);
|
||||||
|
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||||
|
const canSettings = context.has(PERMS.SETTINGS_EDIT);
|
||||||
|
return (
|
||||||
|
<ContentPageFrame
|
||||||
|
title="Editorial content"
|
||||||
|
description="Manage articles, navigation, tags, and writable boxes."
|
||||||
|
result={result}
|
||||||
|
forms={
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{canNews && routeId?.includes("article") ? (
|
||||||
|
<ContentCommandForm
|
||||||
|
commandId="content.editorial.article.change"
|
||||||
|
buttonLabel="Save article"
|
||||||
|
input={{ action: routeId.endsWith("create") ? "create" : "update" }}
|
||||||
|
fields={[
|
||||||
|
{ name: "id", label: "Article ID", type: "identifier" },
|
||||||
|
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
|
||||||
|
{ name: "content", label: "Body", type: "textarea", required: true, maxLength: 100_000 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canSettings && routeId === "content.editorial.articles" ? (
|
||||||
|
<ContentCommandForm
|
||||||
|
commandId="content.editorial.navigation.update"
|
||||||
|
buttonLabel="Save navigation"
|
||||||
|
input={{}}
|
||||||
|
fields={[{ name: "items", label: "Navigation JSON", type: "json", required: true, maxLength: 20_000 }]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canPages && routeId === "content.editorial.tags" ? (
|
||||||
|
<ContentCommandForm
|
||||||
|
commandId="content.editorial.tag.change"
|
||||||
|
buttonLabel="Save tag"
|
||||||
|
input={{ action: "update" }}
|
||||||
|
fields={[
|
||||||
|
{ name: "id", label: "Tag ID", type: "identifier" },
|
||||||
|
{ name: "name", label: "Name", type: "text", required: true, maxLength: 100 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{canPages && routeId === "content.editorial.writeable-boxes" ? (
|
||||||
|
<ContentCommandForm
|
||||||
|
commandId="content.editorial.writeable-box.change"
|
||||||
|
buttonLabel="Save box"
|
||||||
|
input={{ action: "update" }}
|
||||||
|
fields={[
|
||||||
|
{ name: "id", label: "Box ID", type: "identifier" },
|
||||||
|
{ name: "title", label: "Title", type: "text", required: true, maxLength: 255 },
|
||||||
|
{ name: "content", label: "Content", type: "textarea", maxLength: 20_000 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderContentEditorialPage(input: HousekeepingPageInput) {
|
||||||
|
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||||
|
const result = await contentQuery.run(input.context, {
|
||||||
|
routeId: routeId ?? "content.editorial.articles",
|
||||||
|
params: input.match.params,
|
||||||
|
list: parseContentListInput(input.searchParams ?? {}),
|
||||||
|
});
|
||||||
|
return <ContentEditorialPage context={input.context} result={result} routeId={routeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
|
import { contentQuery } from "../queries/content-queries";
|
||||||
|
import { ContentCommandForm } from "./content-command-form";
|
||||||
|
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||||
|
|
||||||
|
export function ContentEngagementPage({ context, result, routeId }: ContentPageProps) {
|
||||||
|
const canEvents = context.has(PERMS.EVENTS_EDIT);
|
||||||
|
const canPolls = context.has(PERMS.POLLS_EDIT);
|
||||||
|
const canPrefixes = context.has(PERMS.PREFIXES_EDIT);
|
||||||
|
return <ContentPageFrame title="Engagement" description="Manage events, polls, and community prefixes." result={result} forms={<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{canEvents && routeId === "content.engagement.event-types" ? <ContentCommandForm commandId="content.engagement.event-type.change" buttonLabel="Save event type" input={{ action: "update" }} fields={[{ name: "id", label: "Type ID", type: "identifier" }, { name: "name", label: "Name", type: "text", required: true, maxLength: 255 }]} /> : null}
|
||||||
|
{canEvents && routeId?.includes("event") && routeId !== "content.engagement.event-types" ? <>
|
||||||
|
<ContentCommandForm commandId="content.engagement.event.change" buttonLabel="Save event" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Event ID", type: "identifier" }, { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }]} />
|
||||||
|
<ContentCommandForm commandId="content.engagement.event-prize.change" buttonLabel="Save prize" input={{ action: "create" }} fields={[{ name: "eventId", label: "Event ID", type: "identifier", required: true }, { name: "prize", label: "Prize", type: "text", required: true, maxLength: 255 }]} />
|
||||||
|
<ContentCommandForm commandId="content.engagement.event-winner.add" buttonLabel="Add winner" input={{}} fields={[{ name: "eventId", label: "Event ID", type: "identifier", required: true }, { name: "userId", label: "User ID", type: "identifier", required: true }]} />
|
||||||
|
</> : null}
|
||||||
|
{canPolls && routeId?.includes("poll") ? <>
|
||||||
|
<ContentCommandForm commandId="content.engagement.poll.change" buttonLabel="Save poll" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Poll ID", type: "identifier" }, { name: "title", label: "Title", type: "text", required: true, maxLength: 255 }]} />
|
||||||
|
<ContentCommandForm commandId="content.engagement.poll-question.change" buttonLabel="Save question" input={{ action: "create" }} fields={[{ name: "pollId", label: "Poll ID", type: "identifier", required: true }, { name: "question", label: "Question", type: "text", required: true, maxLength: 500 }]} />
|
||||||
|
</> : null}
|
||||||
|
{canPrefixes && routeId === "content.engagement.prefixes" ? <>
|
||||||
|
<ContentCommandForm commandId="content.engagement.prefix.change" buttonLabel="Save prefix" input={{ action: "update" }} fields={[{ name: "id", label: "Prefix ID", type: "identifier" }, { name: "text", label: "Text", type: "text", required: true, maxLength: 64 }]} />
|
||||||
|
<ContentCommandForm commandId="content.engagement.prefix-blacklist.change" buttonLabel="Update blacklist" input={{ action: "add" }} fields={[{ name: "word", label: "Word", type: "text", required: true, maxLength: 255 }]} />
|
||||||
|
<ContentCommandForm commandId="content.engagement.prefix-settings.update" buttonLabel="Save prefix settings" input={{}} fields={[{ name: "enabled", label: "Enable prefixes", type: "checkbox" }]} />
|
||||||
|
</> : null}
|
||||||
|
</div>} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderContentEngagementPage(input: HousekeepingPageInput) {
|
||||||
|
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||||
|
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.engagement.events", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||||
|
return <ContentEngagementPage context={input.context} result={result} routeId={routeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
|
import { contentQuery } from "../queries/content-queries";
|
||||||
|
import { ContentCommandForm } from "./content-command-form";
|
||||||
|
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||||
|
|
||||||
|
export function ContentHelpPage({ context, result, routeId }: ContentPageProps) {
|
||||||
|
const forms = context.has(PERMS.PAGES_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{routeId?.includes("question") ? <ContentCommandForm commandId="content.help.question.change" buttonLabel="Save help question" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Question ID", type: "identifier" }, { name: "name", label: "Question", type: "text", required: true, maxLength: 255 }, { name: "answer", label: "Answer", type: "textarea", required: true, maxLength: 20_000 }]} /> : null}
|
||||||
|
{routeId === "content.help.email-templates" ? <ContentCommandForm commandId="content.help.email-template.change" buttonLabel="Save email template" input={{ action: "update" }} fields={[{ name: "id", label: "Template ID", type: "identifier", required: true }, { name: "subject", label: "Subject", type: "text", required: true, maxLength: 255 }, { name: "body", label: "Body", type: "textarea", required: true, maxLength: 100_000 }]} /> : null}
|
||||||
|
</div> : null;
|
||||||
|
return <ContentPageFrame title="Help content" description="Manage help questions and email templates." result={result} forms={forms} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderContentHelpPage(input: HousekeepingPageInput) {
|
||||||
|
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||||
|
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.help.questions", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||||
|
return <ContentHelpPage context={input.context} result={result} routeId={routeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
|
import { contentQuery } from "../queries/content-queries";
|
||||||
|
import { ContentCommandForm } from "./content-command-form";
|
||||||
|
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||||
|
|
||||||
|
export function ContentLocalizationPage({ context, result, routeId }: ContentPageProps) {
|
||||||
|
const forms = context.has(PERMS.SETTINGS_EDIT) ? <div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{routeId === "content.localization.cms" ? <ContentCommandForm commandId="content.localization.cms.save" buttonLabel="Save CMS translations" input={{}} fields={[{ name: "locale", label: "Locale", type: "text", required: true, maxLength: 16 }, { name: "data", label: "Translation JSON", type: "json", required: true, maxLength: 500_000 }]} requiresReason /> : null}
|
||||||
|
{routeId === "content.localization.client" ? <ContentCommandForm commandId="content.localization.client.save" buttonLabel="Save client translations" input={{}} fields={[{ name: "fileId", label: "Translation file", type: "text", required: true, maxLength: 100 }, { name: "data", label: "Translation JSON", type: "json", required: true, maxLength: 500_000 }]} requiresReason /> : null}
|
||||||
|
{routeId === "content.localization.emulator" ? <ContentCommandForm commandId="content.localization.emulator.save" buttonLabel="Save emulator translation" input={{}} fields={[{ name: "key", label: "Key", type: "text", required: true, maxLength: 255 }, { name: "value", label: "Value", type: "textarea", required: true, maxLength: 20_000 }]} requiresReason /> : null}
|
||||||
|
</div> : null;
|
||||||
|
return <ContentPageFrame title="Localization" description="Manage CMS, client, and emulator translation stores." result={result} forms={forms} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderContentLocalizationPage(input: HousekeepingPageInput) {
|
||||||
|
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||||
|
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.localization.overview", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||||
|
return <ContentLocalizationPage context={input.context} result={result} routeId={routeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingPageInput } from "../../../route-handlers";
|
||||||
|
import { contentQuery } from "../queries/content-queries";
|
||||||
|
import { ContentCommandForm } from "./content-command-form";
|
||||||
|
import { ContentPageFrame, type ContentPageProps, parseContentListInput } from "./content-page-frame";
|
||||||
|
|
||||||
|
export function ContentMediaPage({ context, result, routeId }: ContentPageProps) {
|
||||||
|
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||||
|
const canBanners = context.has(PERMS.BANNERS_EDIT);
|
||||||
|
return (
|
||||||
|
<ContentPageFrame
|
||||||
|
title="Media"
|
||||||
|
description="Manage photos, uploaded media, banners, and advertisements."
|
||||||
|
result={result}
|
||||||
|
forms={<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{canPages && routeId === "content.media.photos" ? <ContentCommandForm commandId="content.media.photo.delete" buttonLabel="Delete photo" input={{}} fields={[{ name: "id", label: "Photo ID", type: "identifier", required: true }]} /> : null}
|
||||||
|
{canPages && routeId === "content.media.library" ? <>
|
||||||
|
<ContentCommandForm commandId="content.media.asset.upload" buttonLabel="Upload media" input={{}} fields={[{ name: "file", label: "Media file", type: "file", required: true }]} />
|
||||||
|
<ContentCommandForm commandId="content.media.asset.delete" buttonLabel="Delete media" input={{}} fields={[{ name: "filename", label: "Filename", type: "text", required: true, maxLength: 255 }]} />
|
||||||
|
</> : null}
|
||||||
|
{canBanners && routeId === "content.media.banners" ? <ContentCommandForm commandId="content.media.banner.change" buttonLabel="Save banner" input={{ action: "update" }} fields={[{ name: "id", label: "Banner ID", type: "identifier" }, { name: "title", label: "Title", type: "text", maxLength: 255 }]} /> : null}
|
||||||
|
{canPages && routeId?.includes("ad") ? <ContentCommandForm commandId="content.media.ad.change" buttonLabel="Save advertisement" input={{ action: routeId.endsWith("create") ? "create" : "update" }} fields={[{ name: "id", label: "Advertisement ID", type: "identifier" }, { name: "image", label: "Image path", type: "text", required: true, maxLength: 500 }, { name: "url", label: "Destination", type: "text", maxLength: 1000 }]} /> : null}
|
||||||
|
</div>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderContentMediaPage(input: HousekeepingPageInput) {
|
||||||
|
const routeId = input.match.routeId as ContentPageProps["routeId"];
|
||||||
|
const result = await contentQuery.run(input.context, { routeId: routeId ?? "content.media.photos", params: input.match.params, list: parseContentListInput(input.searchParams ?? {}) });
|
||||||
|
return <ContentMediaPage context={input.context} result={result} routeId={routeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { type ContentRouteId, contentRouteGroup } from "../routes";
|
||||||
|
import type {
|
||||||
|
ContentQueryData,
|
||||||
|
ContentQueryItem,
|
||||||
|
NormalizedContentQueryInput,
|
||||||
|
} from "./content-queries";
|
||||||
|
|
||||||
|
type RawRow = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
|
interface QueryDefinition {
|
||||||
|
readonly statement: string;
|
||||||
|
readonly href: string;
|
||||||
|
readonly appendId?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_QUERY_DEFINITIONS = {
|
||||||
|
"content.editorial.articles": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/editorial/articles/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.editorial.article-detail": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/editorial/articles/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.editorial.tags": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name LIMIT 500",
|
||||||
|
href: "/ase/content/editorial/tags",
|
||||||
|
},
|
||||||
|
"content.editorial.writeable-boxes": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id LIMIT 500",
|
||||||
|
href: "/ase/content/editorial/writeable-boxes",
|
||||||
|
},
|
||||||
|
"content.media.photos": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC LIMIT 500",
|
||||||
|
href: "/ase/content/media/photos",
|
||||||
|
},
|
||||||
|
"content.media.banners": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id LIMIT 500",
|
||||||
|
href: "/ase/content/media/banners",
|
||||||
|
},
|
||||||
|
"content.media.ads": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/media/ads/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.media.ad-detail": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/media/ads/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.engagement.events": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/engagement/events/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.engagement.event-detail": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/engagement/events/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.engagement.event-types": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name LIMIT 500",
|
||||||
|
href: "/ase/content/engagement/events/types",
|
||||||
|
},
|
||||||
|
"content.engagement.polls": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/engagement/polls/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.engagement.poll-detail": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
|
||||||
|
href: "/ase/content/engagement/polls/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.engagement.prefixes": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC LIMIT 500",
|
||||||
|
href: "/ase/content/engagement/prefixes",
|
||||||
|
},
|
||||||
|
"content.help.questions": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
|
||||||
|
href: "/ase/content/help/questions/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.help.question-detail": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
|
||||||
|
href: "/ase/content/help/questions/",
|
||||||
|
appendId: true,
|
||||||
|
},
|
||||||
|
"content.help.email-templates": {
|
||||||
|
statement:
|
||||||
|
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name LIMIT 500",
|
||||||
|
href: "/ase/content/help/email-templates",
|
||||||
|
},
|
||||||
|
"content.localization.emulator": {
|
||||||
|
statement:
|
||||||
|
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key LIMIT 500",
|
||||||
|
href: "/ase/content/localization/emulator",
|
||||||
|
},
|
||||||
|
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
|
||||||
|
|
||||||
|
const EMPTY_ROUTES = new Set<ContentRouteId>([
|
||||||
|
"content.editorial.article-create",
|
||||||
|
"content.media.ad-create",
|
||||||
|
"content.engagement.event-create",
|
||||||
|
"content.engagement.poll-create",
|
||||||
|
"content.help.question-create",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function rows(result: unknown): readonly RawRow[] {
|
||||||
|
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||||
|
throw new Error("invalid Content query result");
|
||||||
|
}
|
||||||
|
return result[0] as readonly RawRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : value == null ? fallback : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatedAt(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const parsed = value instanceof Date ? value : new Date(String(value));
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
items: readonly ContentQueryItem[],
|
||||||
|
total = items.length,
|
||||||
|
): ContentQueryData {
|
||||||
|
const start = input.list.offset;
|
||||||
|
return {
|
||||||
|
kind: contentRouteGroup(input.routeId),
|
||||||
|
items: items.slice(start, start + input.list.pageSize),
|
||||||
|
total,
|
||||||
|
partialDependencies: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(input: NormalizedContentQueryInput, item: ContentQueryItem): boolean {
|
||||||
|
const needle = input.list.search.toLocaleLowerCase();
|
||||||
|
return (
|
||||||
|
needle.length === 0 ||
|
||||||
|
item.title.toLocaleLowerCase().includes(needle) ||
|
||||||
|
item.description?.toLocaleLowerCase().includes(needle) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRows(
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
definition: QueryDefinition,
|
||||||
|
rawRows: readonly RawRow[],
|
||||||
|
): readonly ContentQueryItem[] {
|
||||||
|
let items = rawRows.map((row) => {
|
||||||
|
const id = value(row.id);
|
||||||
|
if (!id) throw new Error("invalid Content identifier");
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: value(row.title, "Untitled content"),
|
||||||
|
description: value(row.description) || undefined,
|
||||||
|
status: value(row.status) || undefined,
|
||||||
|
updatedAt: updatedAt(row.updated_at),
|
||||||
|
href: definition.appendId ? definition.href + id : definition.href,
|
||||||
|
};
|
||||||
|
}).filter((item) => matches(input, item));
|
||||||
|
if (input.params.id) {
|
||||||
|
items = items.filter((item) => item.id === input.params.id);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function databaseRoute(
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
definition: QueryDefinition,
|
||||||
|
): Promise<ContentQueryData> {
|
||||||
|
const [{ sql }, { db }] = await Promise.all([
|
||||||
|
import("drizzle-orm"),
|
||||||
|
import("@/lib/db"),
|
||||||
|
]);
|
||||||
|
const items = mapRows(
|
||||||
|
input,
|
||||||
|
definition,
|
||||||
|
rows(await db.execute(sql.raw(definition.statement))),
|
||||||
|
);
|
||||||
|
return response(input, items, items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mediaLibrary(
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
): Promise<ContentQueryData> {
|
||||||
|
const [{ readdir, stat }, { resolve }, { MEDIA_ROOT }] = await Promise.all([
|
||||||
|
import("node:fs/promises"),
|
||||||
|
import("node:path"),
|
||||||
|
import("@/lib/media-storage"),
|
||||||
|
]);
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = (await readdir(MEDIA_ROOT)).filter((name) =>
|
||||||
|
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return response(input, []);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const entries = await Promise.all(
|
||||||
|
names.map(async (name) => ({ name, metadata: await stat(resolve(MEDIA_ROOT, name)) })),
|
||||||
|
);
|
||||||
|
entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
|
||||||
|
const items = entries.map(({ name, metadata }) => ({
|
||||||
|
id: name,
|
||||||
|
title: name,
|
||||||
|
description: String(metadata.size) + " bytes",
|
||||||
|
status: "stored",
|
||||||
|
updatedAt: metadata.mtime.toISOString(),
|
||||||
|
href: "/ase/content/media/library",
|
||||||
|
})).filter((item) => matches(input, item));
|
||||||
|
return response(input, items, items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function brand(input: NormalizedContentQueryInput): Promise<ContentQueryData> {
|
||||||
|
const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
|
||||||
|
import("@/lib/services/site-settings"),
|
||||||
|
import("@/lib/theme-custom-store"),
|
||||||
|
]);
|
||||||
|
if (input.routeId === "content.brand.favicon") {
|
||||||
|
const favicon = await siteSettings.get("cms_favicon", null);
|
||||||
|
return response(input, [{
|
||||||
|
id: "favicon",
|
||||||
|
title: favicon || "Default favicon",
|
||||||
|
status: favicon ? "custom" : "default",
|
||||||
|
href: "/ase/content/brand/favicon",
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
const [preset, customThemes] = await Promise.all([
|
||||||
|
siteSettings.get("theme_preset", "Atom (golden)"),
|
||||||
|
listCustomThemes(),
|
||||||
|
]);
|
||||||
|
return response(input, [{
|
||||||
|
id: "active-theme",
|
||||||
|
title: preset || "Atom (golden)",
|
||||||
|
status: "active",
|
||||||
|
href: "/ase/content/brand/theme",
|
||||||
|
}, ...customThemes.map((theme) => ({
|
||||||
|
id: theme.id,
|
||||||
|
title: theme.name,
|
||||||
|
status: "saved",
|
||||||
|
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||||
|
href: "/ase/content/brand/theme",
|
||||||
|
}))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function localizationFiles(
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
): Promise<ContentQueryData> {
|
||||||
|
if (input.routeId === "content.localization.client") {
|
||||||
|
const { CLIENT_TRANSLATION_FILES } = await import(
|
||||||
|
"@/lib/client-translation-files"
|
||||||
|
);
|
||||||
|
return response(input, CLIENT_TRANSLATION_FILES.map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
title: file.id,
|
||||||
|
description: file.relPath,
|
||||||
|
status: file.readOnly ? "read-only" : "editable",
|
||||||
|
href: "/ase/content/localization/client",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
const [{ readdir }, { join }] = await Promise.all([
|
||||||
|
import("node:fs/promises"),
|
||||||
|
import("node:path"),
|
||||||
|
]);
|
||||||
|
let locales: string[] = [];
|
||||||
|
try {
|
||||||
|
locales = (await readdir(join(process.cwd(), "src", "messages")))
|
||||||
|
.filter((name) => name.endsWith(".json"))
|
||||||
|
.map((name) => name.slice(0, -5))
|
||||||
|
.sort();
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
if (input.routeId === "content.localization.overview") {
|
||||||
|
return response(input, [
|
||||||
|
{
|
||||||
|
id: "cms",
|
||||||
|
title: "CMS translations",
|
||||||
|
status: String(locales.length) + " locales",
|
||||||
|
href: "/ase/content/localization/cms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "client",
|
||||||
|
title: "Client translations",
|
||||||
|
status: "configured sources",
|
||||||
|
href: "/ase/content/localization/client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "emulator",
|
||||||
|
title: "Emulator translations",
|
||||||
|
status: "database store",
|
||||||
|
href: "/ase/content/localization/emulator",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return response(input, locales.map((locale) => ({
|
||||||
|
id: locale,
|
||||||
|
title: locale,
|
||||||
|
status: "editable",
|
||||||
|
href: "/ase/content/localization/cms",
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProductionContentQuery(
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
): Promise<ContentQueryData> {
|
||||||
|
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
|
||||||
|
if (input.routeId === "content.media.library") return mediaLibrary(input);
|
||||||
|
if (input.routeId.startsWith("content.brand.")) return brand(input);
|
||||||
|
if (
|
||||||
|
input.routeId === "content.localization.overview" ||
|
||||||
|
input.routeId === "content.localization.client" ||
|
||||||
|
input.routeId === "content.localization.cms"
|
||||||
|
) {
|
||||||
|
return localizationFiles(input);
|
||||||
|
}
|
||||||
|
const definition = (CONTENT_QUERY_DEFINITIONS as Partial<
|
||||||
|
Record<ContentRouteId, QueryDefinition>
|
||||||
|
>)[input.routeId];
|
||||||
|
if (!definition) throw new Error("missing Content query adapter");
|
||||||
|
return databaseRoute(input, definition);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
import { CONTENT_ROUTE_IDS } from "../routes";
|
||||||
|
import {
|
||||||
|
type ContentQueryData,
|
||||||
|
type ContentQueryInput,
|
||||||
|
createContentQuery,
|
||||||
|
} from "./content-queries";
|
||||||
|
import { CONTENT_QUERY_DEFINITIONS } from "./content-queries-production";
|
||||||
|
|
||||||
|
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready: ContentQueryData = {
|
||||||
|
kind: "editorial",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "18446744073709551615",
|
||||||
|
title: "Published article",
|
||||||
|
status: "published",
|
||||||
|
href: "/ase/content/editorial/articles/18446744073709551615",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
partialDependencies: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Content query", () => {
|
||||||
|
it("never selects email template bodies into summary query results", () => {
|
||||||
|
const emailTemplates =
|
||||||
|
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
||||||
|
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed before production adapters are invoked", async () => {
|
||||||
|
const load = vi.fn(async () => ready);
|
||||||
|
const query = createContentQuery({ load });
|
||||||
|
|
||||||
|
const result = await query.run(context([]), {
|
||||||
|
routeId: "content.editorial.articles",
|
||||||
|
list: { search: "", pageSize: 25, offset: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
expect(load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds list input, preserves canonical BIGINT strings, and returns real adapter data", async () => {
|
||||||
|
const load = vi.fn(async (_input: ContentQueryInput) => ready);
|
||||||
|
const query = createContentQuery({ load });
|
||||||
|
|
||||||
|
const result = await query.run(context([PERMS.NEWS_VIEW]), {
|
||||||
|
routeId: "content.editorial.articles",
|
||||||
|
list: {
|
||||||
|
search: " launch ",
|
||||||
|
pageSize: 1000,
|
||||||
|
offset: 999_999,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(load).toHaveBeenCalledWith({
|
||||||
|
routeId: "content.editorial.articles",
|
||||||
|
params: {},
|
||||||
|
list: { search: "launch", pageSize: 100, offset: 100_000 },
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
items: [{ id: "18446744073709551615" }],
|
||||||
|
total: 1,
|
||||||
|
partialDependencies: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a fake partial result and maps complete adapter failure", async () => {
|
||||||
|
const malformed = createContentQuery({
|
||||||
|
load: async () =>
|
||||||
|
({
|
||||||
|
kind: "media",
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
partialDependencies: ["imaginary"],
|
||||||
|
}) as ContentQueryData,
|
||||||
|
});
|
||||||
|
const unavailable = createContentQuery({
|
||||||
|
load: async () => {
|
||||||
|
throw new Error("database unavailable");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await malformed.run(context([PERMS.PAGES_VIEW]), {
|
||||||
|
routeId: "content.media.library",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await unavailable.run(context([PERMS.PAGES_VIEW]), {
|
||||||
|
routeId: "content.media.library",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has an authorized production query path for every Content route", async () => {
|
||||||
|
const load = vi.fn(async (input: ContentQueryInput) => ({
|
||||||
|
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
partialDependencies: [],
|
||||||
|
}));
|
||||||
|
const query = createContentQuery({ load });
|
||||||
|
const all = context(Object.values(PERMS));
|
||||||
|
|
||||||
|
for (const routeId of CONTENT_ROUTE_IDS) {
|
||||||
|
const result = await query.run(all, { routeId });
|
||||||
|
expect(result.ok, routeId).toBe(true);
|
||||||
|
}
|
||||||
|
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||||
|
import {
|
||||||
|
fail,
|
||||||
|
type HousekeepingQuery,
|
||||||
|
ok,
|
||||||
|
} from "../../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
|
||||||
|
import {
|
||||||
|
type ContentRouteGroup,
|
||||||
|
type ContentRouteId,
|
||||||
|
contentRouteById,
|
||||||
|
contentRouteGroup,
|
||||||
|
} from "../routes";
|
||||||
|
|
||||||
|
export interface ContentQueryListInput {
|
||||||
|
readonly search?: string;
|
||||||
|
readonly pageSize?: number;
|
||||||
|
readonly offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentQueryInput {
|
||||||
|
readonly routeId: ContentRouteId;
|
||||||
|
readonly params?: Readonly<Record<string, string>>;
|
||||||
|
readonly list?: ContentQueryListInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentQueryItem {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly status?: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly href?: string;
|
||||||
|
readonly updatedAt?: string | null;
|
||||||
|
readonly privatePayload?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentQueryData {
|
||||||
|
readonly kind: ContentRouteGroup;
|
||||||
|
readonly items: readonly ContentQueryItem[];
|
||||||
|
readonly total: number;
|
||||||
|
readonly partialDependencies: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedContentQueryInput {
|
||||||
|
readonly routeId: ContentRouteId;
|
||||||
|
readonly params: Readonly<Record<string, string>>;
|
||||||
|
readonly list: Readonly<{
|
||||||
|
search: string;
|
||||||
|
pageSize: number;
|
||||||
|
offset: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentQueryAdapters {
|
||||||
|
load(input: NormalizedContentQueryInput): Promise<ContentQueryData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedInteger(
|
||||||
|
value: unknown,
|
||||||
|
fallback: number,
|
||||||
|
minimum: number,
|
||||||
|
maximum: number,
|
||||||
|
): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||||
|
return Math.min(maximum, Math.max(minimum, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
|
||||||
|
return {
|
||||||
|
routeId: input.routeId,
|
||||||
|
params: Object.fromEntries(
|
||||||
|
Object.entries(input.params ?? {}).map(([key, value]) => [
|
||||||
|
key.normalize("NFC").trim().slice(0, 128),
|
||||||
|
value.normalize("NFC").trim().slice(0, 128),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
list: {
|
||||||
|
search: String(input.list?.search ?? "")
|
||||||
|
.normalize("NFC")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 128),
|
||||||
|
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||||
|
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidData(
|
||||||
|
data: ContentQueryData,
|
||||||
|
input: NormalizedContentQueryInput,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
data.kind !== contentRouteGroup(input.routeId) ||
|
||||||
|
!Array.isArray(data.items) ||
|
||||||
|
!Number.isSafeInteger(data.total) ||
|
||||||
|
data.total < 0 ||
|
||||||
|
!Array.isArray(data.partialDependencies) ||
|
||||||
|
data.partialDependencies.length !== 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return data.items.every(
|
||||||
|
(item) =>
|
||||||
|
typeof item.id === "string" &&
|
||||||
|
item.id.length > 0 &&
|
||||||
|
typeof item.title === "string" &&
|
||||||
|
item.title.length > 0 &&
|
||||||
|
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentQuery(
|
||||||
|
adapters: ContentQueryAdapters,
|
||||||
|
): HousekeepingQuery<ContentQueryInput, ContentQueryData> {
|
||||||
|
return {
|
||||||
|
id: "content.query",
|
||||||
|
owner: "content",
|
||||||
|
capability: contentRouteById("content.editorial.articles").capability,
|
||||||
|
async run(context, input) {
|
||||||
|
const route = contentRouteById(input.routeId);
|
||||||
|
const authorization = authorizeHousekeeping(context, route.capability);
|
||||||
|
if (!authorization.ok) return authorization;
|
||||||
|
const normalized = normalizeInput(input);
|
||||||
|
try {
|
||||||
|
const data = await adapters.load(normalized);
|
||||||
|
if (!isValidData(data, normalized)) {
|
||||||
|
throw new Error("invalid Content query data");
|
||||||
|
}
|
||||||
|
return ok(data, authorization.correlationId);
|
||||||
|
} catch {
|
||||||
|
return fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contentQuery = createContentQuery({
|
||||||
|
async load(input) {
|
||||||
|
const { loadProductionContentQuery } = await import(
|
||||||
|
"./content-queries-production"
|
||||||
|
);
|
||||||
|
return loadProductionContentQuery(input);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { HousekeepingRouteHandler } from "../../route-handlers";
|
||||||
|
import { renderContentBrandPage } from "./pages/brand";
|
||||||
|
import { renderContentEditorialPage } from "./pages/editorial";
|
||||||
|
import { renderContentEngagementPage } from "./pages/engagement";
|
||||||
|
import { renderContentHelpPage } from "./pages/help";
|
||||||
|
import { renderContentLocalizationPage } from "./pages/localization";
|
||||||
|
import { renderContentMediaPage } from "./pages/media";
|
||||||
|
import {
|
||||||
|
CONTENT_ROUTE_IDS,
|
||||||
|
type ContentRouteId,
|
||||||
|
contentRouteGroup,
|
||||||
|
} from "./routes";
|
||||||
|
|
||||||
|
function rendererFor(
|
||||||
|
routeId: ContentRouteId,
|
||||||
|
): HousekeepingRouteHandler["render"] {
|
||||||
|
const group = contentRouteGroup(routeId);
|
||||||
|
if (group === "editorial") return renderContentEditorialPage;
|
||||||
|
if (group === "media") return renderContentMediaPage;
|
||||||
|
if (group === "engagement") return renderContentEngagementPage;
|
||||||
|
if (group === "help") return renderContentHelpPage;
|
||||||
|
if (group === "brand") return renderContentBrandPage;
|
||||||
|
return renderContentLocalizationPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||||
|
Object.freeze(
|
||||||
|
CONTENT_ROUTE_IDS.map((routeId) =>
|
||||||
|
Object.freeze({ routeId, render: rendererFor(routeId) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { HOUSEKEEPING_MANIFESTS } from "../../manifests";
|
||||||
|
import { contentMigrationEntries } from "../../migration/content";
|
||||||
|
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../route-handlers";
|
||||||
|
import { contentManifest } from "./manifest";
|
||||||
|
import {
|
||||||
|
CONTENT_ROUTE_GROUPS,
|
||||||
|
CONTENT_ROUTE_IDS,
|
||||||
|
CONTENT_ROUTES,
|
||||||
|
type ContentRouteId,
|
||||||
|
contentRouteGroup,
|
||||||
|
} from "./routes";
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
["content.editorial.articles", "/ase/content/editorial/articles", "editorial"],
|
||||||
|
[
|
||||||
|
"content.editorial.article-create",
|
||||||
|
"/ase/content/editorial/articles/new",
|
||||||
|
"editorial",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.editorial.article-detail",
|
||||||
|
"/ase/content/editorial/articles/:id",
|
||||||
|
"editorial",
|
||||||
|
],
|
||||||
|
["content.media.photos", "/ase/content/media/photos", "media"],
|
||||||
|
["content.media.library", "/ase/content/media/library", "media"],
|
||||||
|
["content.media.banners", "/ase/content/media/banners", "media"],
|
||||||
|
["content.media.ads", "/ase/content/media/ads", "media"],
|
||||||
|
["content.media.ad-create", "/ase/content/media/ads/new", "media"],
|
||||||
|
["content.media.ad-detail", "/ase/content/media/ads/:id", "media"],
|
||||||
|
["content.engagement.events", "/ase/content/engagement/events", "engagement"],
|
||||||
|
[
|
||||||
|
"content.engagement.event-create",
|
||||||
|
"/ase/content/engagement/events/create",
|
||||||
|
"engagement",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.engagement.event-types",
|
||||||
|
"/ase/content/engagement/events/types",
|
||||||
|
"engagement",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.engagement.event-detail",
|
||||||
|
"/ase/content/engagement/events/:id",
|
||||||
|
"engagement",
|
||||||
|
],
|
||||||
|
["content.engagement.polls", "/ase/content/engagement/polls", "engagement"],
|
||||||
|
[
|
||||||
|
"content.engagement.poll-create",
|
||||||
|
"/ase/content/engagement/polls/create",
|
||||||
|
"engagement",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.engagement.poll-detail",
|
||||||
|
"/ase/content/engagement/polls/:id",
|
||||||
|
"engagement",
|
||||||
|
],
|
||||||
|
["content.help.questions", "/ase/content/help/questions", "help"],
|
||||||
|
[
|
||||||
|
"content.help.question-create",
|
||||||
|
"/ase/content/help/questions/new",
|
||||||
|
"help",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.help.question-detail",
|
||||||
|
"/ase/content/help/questions/:id",
|
||||||
|
"help",
|
||||||
|
],
|
||||||
|
["content.editorial.tags", "/ase/content/editorial/tags", "editorial"],
|
||||||
|
[
|
||||||
|
"content.engagement.prefixes",
|
||||||
|
"/ase/content/engagement/prefixes",
|
||||||
|
"engagement",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.editorial.writeable-boxes",
|
||||||
|
"/ase/content/editorial/writeable-boxes",
|
||||||
|
"editorial",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"content.help.email-templates",
|
||||||
|
"/ase/content/help/email-templates",
|
||||||
|
"help",
|
||||||
|
],
|
||||||
|
["content.brand.theme", "/ase/content/brand/theme", "brand"],
|
||||||
|
["content.brand.favicon", "/ase/content/brand/favicon", "brand"],
|
||||||
|
["content.localization.overview", "/ase/content/localization", "localization"],
|
||||||
|
[
|
||||||
|
"content.localization.client",
|
||||||
|
"/ase/content/localization/client",
|
||||||
|
"localization",
|
||||||
|
],
|
||||||
|
["content.localization.cms", "/ase/content/localization/cms", "localization"],
|
||||||
|
[
|
||||||
|
"content.localization.emulator",
|
||||||
|
"/ase/content/localization/emulator",
|
||||||
|
"localization",
|
||||||
|
],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("Content routes", () => {
|
||||||
|
it("declares the six exact route groups", () => {
|
||||||
|
expect(CONTENT_ROUTE_GROUPS).toEqual([
|
||||||
|
"editorial",
|
||||||
|
"media",
|
||||||
|
"engagement",
|
||||||
|
"help",
|
||||||
|
"brand",
|
||||||
|
"localization",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps every migration row to its canonical route and group", () => {
|
||||||
|
expect(
|
||||||
|
CONTENT_ROUTES.map((route) => [
|
||||||
|
route.id,
|
||||||
|
route.href,
|
||||||
|
contentRouteGroup(route.id as ContentRouteId),
|
||||||
|
]),
|
||||||
|
).toEqual(expected);
|
||||||
|
expect(CONTENT_ROUTE_IDS).toEqual(expected.map(([id]) => id));
|
||||||
|
expect(CONTENT_ROUTES.map((route) => route.href).sort()).toEqual(
|
||||||
|
contentMigrationEntries
|
||||||
|
.filter((entry) => entry.targetPath !== null)
|
||||||
|
.map((entry) => entry.targetPath)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps manifest routes and real handlers in exact equality", () => {
|
||||||
|
expect(contentManifest.routes).toBe(CONTENT_ROUTES);
|
||||||
|
expect(contentManifest.routes.map((route) => route.id)).toEqual(
|
||||||
|
HOUSEKEEPING_ROUTE_HANDLERS.filter((handler) =>
|
||||||
|
handler.routeId.startsWith("content."),
|
||||||
|
).map((handler) => handler.routeId),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
HOUSEKEEPING_MANIFESTS.flatMap((manifest) =>
|
||||||
|
manifest.routes.map((route) => route.id),
|
||||||
|
),
|
||||||
|
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type CanonicalHousekeepingHref,
|
||||||
|
type HousekeepingRouteDefinition,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
|
||||||
|
export const CONTENT_ROUTE_GROUPS = [
|
||||||
|
"editorial",
|
||||||
|
"media",
|
||||||
|
"engagement",
|
||||||
|
"help",
|
||||||
|
"brand",
|
||||||
|
"localization",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ContentRouteGroup = (typeof CONTENT_ROUTE_GROUPS)[number];
|
||||||
|
|
||||||
|
export const CONTENT_ROUTE_IDS = [
|
||||||
|
"content.editorial.articles",
|
||||||
|
"content.editorial.article-create",
|
||||||
|
"content.editorial.article-detail",
|
||||||
|
"content.media.photos",
|
||||||
|
"content.media.library",
|
||||||
|
"content.media.banners",
|
||||||
|
"content.media.ads",
|
||||||
|
"content.media.ad-create",
|
||||||
|
"content.media.ad-detail",
|
||||||
|
"content.engagement.events",
|
||||||
|
"content.engagement.event-create",
|
||||||
|
"content.engagement.event-types",
|
||||||
|
"content.engagement.event-detail",
|
||||||
|
"content.engagement.polls",
|
||||||
|
"content.engagement.poll-create",
|
||||||
|
"content.engagement.poll-detail",
|
||||||
|
"content.help.questions",
|
||||||
|
"content.help.question-create",
|
||||||
|
"content.help.question-detail",
|
||||||
|
"content.editorial.tags",
|
||||||
|
"content.engagement.prefixes",
|
||||||
|
"content.editorial.writeable-boxes",
|
||||||
|
"content.help.email-templates",
|
||||||
|
"content.brand.theme",
|
||||||
|
"content.brand.favicon",
|
||||||
|
"content.localization.overview",
|
||||||
|
"content.localization.client",
|
||||||
|
"content.localization.cms",
|
||||||
|
"content.localization.emulator",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ContentRouteId = (typeof CONTENT_ROUTE_IDS)[number];
|
||||||
|
|
||||||
|
function contentRoute(
|
||||||
|
id: ContentRouteId,
|
||||||
|
href: CanonicalHousekeepingHref,
|
||||||
|
capabilities: readonly string[],
|
||||||
|
): HousekeepingRouteDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
labelKey: `pages.housekeeping.routes.${id}`,
|
||||||
|
href,
|
||||||
|
capability: anyCapability(...capabilities),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_ROUTES = [
|
||||||
|
contentRoute("content.editorial.articles", "/ase/content/editorial/articles", [
|
||||||
|
PERMS.NEWS_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute(
|
||||||
|
"content.editorial.article-create",
|
||||||
|
"/ase/content/editorial/articles/new",
|
||||||
|
[PERMS.NEWS_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.editorial.article-detail",
|
||||||
|
"/ase/content/editorial/articles/:id",
|
||||||
|
[PERMS.NEWS_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute("content.media.photos", "/ase/content/media/photos", [
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute("content.media.library", "/ase/content/media/library", [
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute("content.media.banners", "/ase/content/media/banners", [
|
||||||
|
PERMS.BANNERS_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute("content.media.ads", "/ase/content/media/ads", [
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute("content.media.ad-create", "/ase/content/media/ads/new", [
|
||||||
|
PERMS.PAGES_EDIT,
|
||||||
|
]),
|
||||||
|
contentRoute("content.media.ad-detail", "/ase/content/media/ads/:id", [
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.events",
|
||||||
|
"/ase/content/engagement/events",
|
||||||
|
[PERMS.EVENTS_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.event-create",
|
||||||
|
"/ase/content/engagement/events/create",
|
||||||
|
[PERMS.EVENTS_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.event-types",
|
||||||
|
"/ase/content/engagement/events/types",
|
||||||
|
[PERMS.EVENTS_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.event-detail",
|
||||||
|
"/ase/content/engagement/events/:id",
|
||||||
|
[PERMS.EVENTS_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.polls",
|
||||||
|
"/ase/content/engagement/polls",
|
||||||
|
[PERMS.POLLS_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.poll-create",
|
||||||
|
"/ase/content/engagement/polls/create",
|
||||||
|
[PERMS.POLLS_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.poll-detail",
|
||||||
|
"/ase/content/engagement/polls/:id",
|
||||||
|
[PERMS.POLLS_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute("content.help.questions", "/ase/content/help/questions", [
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute(
|
||||||
|
"content.help.question-create",
|
||||||
|
"/ase/content/help/questions/new",
|
||||||
|
[PERMS.PAGES_EDIT],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.help.question-detail",
|
||||||
|
"/ase/content/help/questions/:id",
|
||||||
|
[PERMS.PAGES_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute("content.editorial.tags", "/ase/content/editorial/tags", [
|
||||||
|
PERMS.PAGES_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute(
|
||||||
|
"content.engagement.prefixes",
|
||||||
|
"/ase/content/engagement/prefixes",
|
||||||
|
[PERMS.PREFIXES_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.editorial.writeable-boxes",
|
||||||
|
"/ase/content/editorial/writeable-boxes",
|
||||||
|
[PERMS.PAGES_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.help.email-templates",
|
||||||
|
"/ase/content/help/email-templates",
|
||||||
|
[PERMS.PAGES_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute("content.brand.theme", "/ase/content/brand/theme", [
|
||||||
|
PERMS.SETTINGS_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute("content.brand.favicon", "/ase/content/brand/favicon", [
|
||||||
|
PERMS.SETTINGS_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute(
|
||||||
|
"content.localization.overview",
|
||||||
|
"/ase/content/localization",
|
||||||
|
[PERMS.SETTINGS_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute(
|
||||||
|
"content.localization.client",
|
||||||
|
"/ase/content/localization/client",
|
||||||
|
[PERMS.SETTINGS_VIEW],
|
||||||
|
),
|
||||||
|
contentRoute("content.localization.cms", "/ase/content/localization/cms", [
|
||||||
|
PERMS.SETTINGS_VIEW,
|
||||||
|
]),
|
||||||
|
contentRoute(
|
||||||
|
"content.localization.emulator",
|
||||||
|
"/ase/content/localization/emulator",
|
||||||
|
[PERMS.SETTINGS_VIEW],
|
||||||
|
),
|
||||||
|
] as const satisfies readonly HousekeepingRouteDefinition[];
|
||||||
|
|
||||||
|
const routeGroups = new Map<ContentRouteId, ContentRouteGroup>(
|
||||||
|
CONTENT_ROUTE_IDS.map((routeId) => [
|
||||||
|
routeId,
|
||||||
|
routeId.split(".")[1] as ContentRouteGroup,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function contentRouteGroup(routeId: ContentRouteId): ContentRouteGroup {
|
||||||
|
const group = routeGroups.get(routeId);
|
||||||
|
if (!group) throw new Error(`unknown Content route: ${routeId}`);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contentRouteById(
|
||||||
|
routeId: ContentRouteId,
|
||||||
|
): (typeof CONTENT_ROUTES)[number] {
|
||||||
|
const route = CONTENT_ROUTES.find((candidate) => candidate.id === routeId);
|
||||||
|
if (!route) throw new Error(`unknown Content route: ${routeId}`);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
import { contentQuery } from "./queries/content-queries";
|
||||||
|
import type { ContentSearchCandidate } from "./search";
|
||||||
|
|
||||||
|
type ContentSearchKind = "articles" | "events" | "media" | "help";
|
||||||
|
|
||||||
|
const SEARCH_ROUTES = {
|
||||||
|
articles: [
|
||||||
|
["content.editorial.articles", anyCapability(PERMS.NEWS_VIEW)],
|
||||||
|
],
|
||||||
|
events: [
|
||||||
|
["content.engagement.events", anyCapability(PERMS.EVENTS_VIEW)],
|
||||||
|
],
|
||||||
|
media: [
|
||||||
|
["content.media.photos", anyCapability(PERMS.PAGES_VIEW)],
|
||||||
|
["content.media.library", anyCapability(PERMS.PAGES_VIEW)],
|
||||||
|
["content.media.banners", anyCapability(PERMS.BANNERS_VIEW)],
|
||||||
|
["content.media.ads", anyCapability(PERMS.PAGES_VIEW)],
|
||||||
|
],
|
||||||
|
help: [
|
||||||
|
["content.help.questions", anyCapability(PERMS.PAGES_VIEW)],
|
||||||
|
["content.help.email-templates", anyCapability(PERMS.PAGES_VIEW)],
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function loadContentSearchCandidates(
|
||||||
|
kind: ContentSearchKind,
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
term: string,
|
||||||
|
limit: number,
|
||||||
|
): Promise<readonly ContentSearchCandidate[]> {
|
||||||
|
const candidates: ContentSearchCandidate[] = [];
|
||||||
|
for (const [routeId, capability] of SEARCH_ROUTES[kind]) {
|
||||||
|
const result = await contentQuery.run(context, {
|
||||||
|
routeId,
|
||||||
|
list: { search: term, pageSize: limit, offset: 0 },
|
||||||
|
});
|
||||||
|
if (!result.ok) continue;
|
||||||
|
for (const item of result.data.items) {
|
||||||
|
if (!item.href) continue;
|
||||||
|
candidates.push({
|
||||||
|
id: routeId + ":" + item.id,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description ?? item.status,
|
||||||
|
href: item.href,
|
||||||
|
capability,
|
||||||
|
});
|
||||||
|
if (candidates.length >= limit) return candidates;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||||
|
import { satisfiesCapability } from "../../foundation/capability-context";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type CapabilityRequirement,
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingSearchProvider,
|
||||||
|
ok,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
import { isSafeHousekeepingHref } from "../../foundation/housekeeping-href";
|
||||||
|
|
||||||
|
export const CONTENT_SEARCH_PROVIDER_IDS = [
|
||||||
|
"content.articles",
|
||||||
|
"content.events",
|
||||||
|
"content.media",
|
||||||
|
"content.help",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface ContentSearchCandidate {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly href: string;
|
||||||
|
readonly capability: CapabilityRequirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentSearchLoader = (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
term: string,
|
||||||
|
limit: number,
|
||||||
|
) => Promise<readonly ContentSearchCandidate[]>;
|
||||||
|
|
||||||
|
export interface ContentSearchAdapters {
|
||||||
|
readonly articles: ContentSearchLoader;
|
||||||
|
readonly events: ContentSearchLoader;
|
||||||
|
readonly media: ContentSearchLoader;
|
||||||
|
readonly help: ContentSearchLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProvider(
|
||||||
|
id: (typeof CONTENT_SEARCH_PROVIDER_IDS)[number],
|
||||||
|
capability: CapabilityRequirement,
|
||||||
|
load: ContentSearchLoader,
|
||||||
|
): HousekeepingSearchProvider {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
owner: "content",
|
||||||
|
capability,
|
||||||
|
async search(context, input) {
|
||||||
|
const authorization = authorizeHousekeeping(context, capability);
|
||||||
|
if (!authorization.ok) return authorization;
|
||||||
|
const limit = Number.isFinite(input.limit)
|
||||||
|
? Math.min(25, Math.max(1, Math.trunc(input.limit)))
|
||||||
|
: 25;
|
||||||
|
const term = input.term.normalize("NFC").trim().slice(0, 128);
|
||||||
|
try {
|
||||||
|
const candidates = await load(context, term, limit);
|
||||||
|
return ok(
|
||||||
|
candidates
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
isSafeHousekeepingHref(item.href) &&
|
||||||
|
satisfiesCapability(context, item.capability),
|
||||||
|
)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
domain: "content" as const,
|
||||||
|
type: "entity" as const,
|
||||||
|
href: item.href as `/ase/${string}`,
|
||||||
|
})),
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentSearchProviders(
|
||||||
|
adapters: ContentSearchAdapters,
|
||||||
|
): readonly HousekeepingSearchProvider[] {
|
||||||
|
return [
|
||||||
|
createProvider(
|
||||||
|
"content.articles",
|
||||||
|
anyCapability(PERMS.NEWS_VIEW),
|
||||||
|
adapters.articles,
|
||||||
|
),
|
||||||
|
createProvider(
|
||||||
|
"content.events",
|
||||||
|
anyCapability(PERMS.EVENTS_VIEW),
|
||||||
|
adapters.events,
|
||||||
|
),
|
||||||
|
createProvider(
|
||||||
|
"content.media",
|
||||||
|
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
|
||||||
|
adapters.media,
|
||||||
|
),
|
||||||
|
createProvider(
|
||||||
|
"content.help",
|
||||||
|
anyCapability(PERMS.PAGES_VIEW),
|
||||||
|
adapters.help,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_SEARCH_PROVIDERS = createContentSearchProviders({
|
||||||
|
async articles(context, term, limit) {
|
||||||
|
const { loadContentSearchCandidates } = await import("./search-production");
|
||||||
|
return loadContentSearchCandidates("articles", context, term, limit);
|
||||||
|
},
|
||||||
|
async events(context, term, limit) {
|
||||||
|
const { loadContentSearchCandidates } = await import("./search-production");
|
||||||
|
return loadContentSearchCandidates("events", context, term, limit);
|
||||||
|
},
|
||||||
|
async media(context, term, limit) {
|
||||||
|
const { loadContentSearchCandidates } = await import("./search-production");
|
||||||
|
return loadContentSearchCandidates("media", context, term, limit);
|
||||||
|
},
|
||||||
|
async help(context, term, limit) {
|
||||||
|
const { loadContentSearchCandidates } = await import("./search-production");
|
||||||
|
return loadContentSearchCandidates("help", context, term, limit);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import type { ResultSetHeader } from "mysql2";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
EmailTemplates,
|
||||||
|
Taggables,
|
||||||
|
Tags,
|
||||||
|
User,
|
||||||
|
WebsiteAds,
|
||||||
|
WebsiteArticleComments,
|
||||||
|
WebsiteArticleReactions,
|
||||||
|
WebsiteArticles,
|
||||||
|
WebsiteBanner,
|
||||||
|
WebsiteEvent,
|
||||||
|
WebsiteEventPrize,
|
||||||
|
WebsiteEventType,
|
||||||
|
WebsiteEventWinner,
|
||||||
|
WebsiteHelpCenterCategories,
|
||||||
|
WebsitePoll,
|
||||||
|
WebsitePollQuestion,
|
||||||
|
WebsiteWriteableBoxes,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { slugify } from "@/lib/format";
|
||||||
|
import { canonicalize } from "@/lib/foundation/security";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import {
|
||||||
|
createEventSchema,
|
||||||
|
eventPrizeSchema,
|
||||||
|
eventTypeSchema,
|
||||||
|
eventWinnerSchema,
|
||||||
|
updateEventSchema,
|
||||||
|
} from "@/lib/validators/event";
|
||||||
|
import {
|
||||||
|
createPollSchema,
|
||||||
|
pollQuestionSchema,
|
||||||
|
updatePollSchema,
|
||||||
|
} from "@/lib/validators/poll";
|
||||||
|
import {
|
||||||
|
type ContentMutationContext,
|
||||||
|
ContentMutationFailure,
|
||||||
|
type ContentMutationOperation,
|
||||||
|
type ContentMutationSnapshot,
|
||||||
|
} from "./mutations";
|
||||||
|
|
||||||
|
type ContentDatabase = typeof db;
|
||||||
|
|
||||||
|
function database(transaction: unknown): ContentDatabase {
|
||||||
|
return (transaction ?? db) as ContentDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
throw validation();
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validation(): ContentMutationFailure {
|
||||||
|
return new ContentMutationFailure(
|
||||||
|
"VALIDATION",
|
||||||
|
"errors.housekeeping.validation",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(): ContentMutationFailure {
|
||||||
|
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, maximum: number, required = false): string {
|
||||||
|
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
|
||||||
|
if (required && !normalized) throw validation();
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawText(value: unknown, maximum = 100_000): string {
|
||||||
|
return String(value ?? "").normalize("NFC").slice(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveBigInt(value: unknown): bigint {
|
||||||
|
const normalized = typeof value === "bigint" ? value.toString() : String(value ?? "").trim();
|
||||||
|
if (!/^[1-9]\d*$/u.test(normalized)) throw validation();
|
||||||
|
return BigInt(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw validation();
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: unknown, fallback = 0): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertedId(result: unknown): string {
|
||||||
|
const value = (result as ResultSetHeader | undefined)?.insertId;
|
||||||
|
return positiveBigInt(value).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activity(
|
||||||
|
context: ContentMutationContext,
|
||||||
|
action: string,
|
||||||
|
description: string,
|
||||||
|
targetType?: string,
|
||||||
|
targetId?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: context.capability.actor.id,
|
||||||
|
action,
|
||||||
|
description,
|
||||||
|
...(targetType ? { targetType } : {}),
|
||||||
|
...(targetId === undefined ? {} : { targetId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueSlug(
|
||||||
|
databaseConnection: ContentDatabase,
|
||||||
|
value: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const base = slugify(value) || "article";
|
||||||
|
let candidate = base;
|
||||||
|
let suffix = 2;
|
||||||
|
for (;;) {
|
||||||
|
const [existing] = await databaseConnection
|
||||||
|
.select({ id: WebsiteArticles.id })
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.slug, candidate))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) return candidate;
|
||||||
|
candidate = base + "-" + suffix;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function articleChange(
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
transaction: unknown,
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const title = text(data.title, 255, true);
|
||||||
|
const now = new Date();
|
||||||
|
const rawSlug = text(data.slug, 255);
|
||||||
|
const slug = await uniqueSlug(connection, rawSlug || title);
|
||||||
|
const [result] = (await connection.insert(WebsiteArticles).values({
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
shortStory: text(data.shortStory ?? data.summary, 255),
|
||||||
|
fullStory: rawText(data.fullStory ?? data.content),
|
||||||
|
image: text(data.image, 255),
|
||||||
|
userId: context.capability.actor.id,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, title, slug }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
const [existing] = await connection
|
||||||
|
.select({ id: WebsiteArticles.id, title: WebsiteArticles.title, slug: WebsiteArticles.slug })
|
||||||
|
.from(WebsiteArticles)
|
||||||
|
.where(eq(WebsiteArticles.id, id))
|
||||||
|
.limit(1);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsiteArticleReactions).where(eq(WebsiteArticleReactions.articleId, id));
|
||||||
|
await connection.delete(WebsiteArticleComments).where(eq(WebsiteArticleComments.articleId, id));
|
||||||
|
await connection.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||||
|
return { before: { id: id.toString(), title: existing.title, slug: existing.slug }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const title = text(data.title, 255, true);
|
||||||
|
const rawSlug = text(data.slug, 255);
|
||||||
|
const slug = rawSlug ? await uniqueSlug(connection, rawSlug) : existing.slug;
|
||||||
|
await connection.update(WebsiteArticles).set({
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
shortStory: text(data.shortStory ?? data.summary, 255),
|
||||||
|
fullStory: rawText(data.fullStory ?? data.content),
|
||||||
|
image: text(data.image, 255),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(WebsiteArticles.id, id));
|
||||||
|
return {
|
||||||
|
before: { id: id.toString(), title: existing.title, slug: existing.slug },
|
||||||
|
after: { id: id.toString(), title, slug },
|
||||||
|
output: { id: id.toString() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adChange(
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
transaction: unknown,
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const image = text(data.image, 255, true);
|
||||||
|
const now = new Date();
|
||||||
|
const [result] = (await connection.insert(WebsiteAds).values({ image, createdAt: now, updatedAt: now })) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
await activity(context, "ad_create", "Created advertisement #" + id + " (" + image + ")", "website_ad", Number(id));
|
||||||
|
return { before: null, after: { id, image }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
const [existing] = await connection.select({ id: WebsiteAds.id, image: WebsiteAds.image }).from(WebsiteAds).where(eq(WebsiteAds.id, id)).limit(1);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
|
||||||
|
await activity(context, "ad_delete", "Deleted advertisement #" + id, "website_ad", Number(id));
|
||||||
|
return { before: { id: id.toString(), image: existing.image }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const image = text(data.image, 255, true);
|
||||||
|
await connection.update(WebsiteAds).set({ image, updatedAt: new Date() }).where(eq(WebsiteAds.id, id));
|
||||||
|
await activity(context, "ad_update", "Updated advertisement #" + id + " (" + image + ")", "website_ad", Number(id));
|
||||||
|
return { before: { id: id.toString(), image: existing.image }, after: { id: id.toString(), image }, output: { id: id.toString() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bannerValues(data: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
title: text(data.title, 255, true),
|
||||||
|
subtitle: text(data.subtitle, 500),
|
||||||
|
image: text(data.image, 500),
|
||||||
|
link: text(data.link, 500),
|
||||||
|
color: text(data.color, 20),
|
||||||
|
isActive: Math.min(1, nonNegativeInteger(data.isActive, 1)),
|
||||||
|
sortOrder: nonNegativeInteger(data.sortOrder),
|
||||||
|
startDate: data.startDate ? text(data.startDate, 50) : null,
|
||||||
|
endDate: data.endDate ? text(data.endDate, 50) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bannerChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const values = bannerValues(data);
|
||||||
|
const [result] = (await connection.insert(WebsiteBanner).values(values)) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, title: values.title }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
const [existing] = await connection.select({ id: WebsiteBanner.id, title: WebsiteBanner.title }).from(WebsiteBanner).where(eq(WebsiteBanner.id, id)).limit(1);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsiteBanner).where(eq(WebsiteBanner.id, id));
|
||||||
|
return { before: existing, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const values = bannerValues({ ...data, title: data.title ?? existing.title });
|
||||||
|
await connection.update(WebsiteBanner).set(values).where(eq(WebsiteBanner.id, id));
|
||||||
|
return { before: existing, after: { id, title: values.title }, output: { id: String(id) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eventTypeChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const parsed = eventTypeSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const [result] = await connection.insert(WebsiteEventType).values(parsed.data);
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, name: parsed.data.name }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
const [existing] = await connection.select({ id: WebsiteEventType.id, name: WebsiteEventType.name }).from(WebsiteEventType).where(eq(WebsiteEventType.id, id)).limit(1);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsiteEventType).where(eq(WebsiteEventType.id, id));
|
||||||
|
return { before: existing, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const parsed = eventTypeSchema.partial().safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
|
||||||
|
await connection.update(WebsiteEventType).set(values).where(eq(WebsiteEventType.id, id));
|
||||||
|
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eventChange(
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
transaction: unknown,
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const parsed = createEventSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const [result] = await connection.insert(WebsiteEvent).values({ ...parsed.data, hostUserId: context.capability.actor.id, updatedAt: new Date() });
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, title: parsed.data.title, status: parsed.data.status }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
const [existing] = await connection.select({ id: WebsiteEvent.id, title: WebsiteEvent.title, status: WebsiteEvent.status }).from(WebsiteEvent).where(eq(WebsiteEvent.id, id)).limit(1);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsiteEvent).where(eq(WebsiteEvent.id, id));
|
||||||
|
return { before: existing, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const parsed = updateEventSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
|
||||||
|
await connection.update(WebsiteEvent).set({ ...values, updatedAt: new Date() }).where(eq(WebsiteEvent.id, id));
|
||||||
|
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eventPrizeChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
await connection.delete(WebsiteEventPrize).where(eq(WebsiteEventPrize.id, id));
|
||||||
|
return { before: { id }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "create") throw validation();
|
||||||
|
const parsed = eventPrizeSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const [result] = await connection.insert(WebsiteEventPrize).values(parsed.data);
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, eventId: parsed.data.eventId, position: parsed.data.position }, output: { id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eventWinnerAdd(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const parsed = eventWinnerSchema.safeParse(record(input));
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const [result] = await database(transaction).insert(WebsiteEventWinner).values(parsed.data);
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, eventId: parsed.data.eventId, userId: parsed.data.userId, position: parsed.data.position }, output: { id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const parsed = createPollSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const [result] = await connection.insert(WebsitePoll).values({ ...parsed.data, updatedAt: new Date() });
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, title: parsed.data.title, status: parsed.data.status }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
const [existing] = await connection.select({ id: WebsitePoll.id, title: WebsitePoll.title, status: WebsitePoll.status }).from(WebsitePoll).where(eq(WebsitePoll.id, id)).limit(1);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsitePoll).where(eq(WebsitePoll.id, id));
|
||||||
|
return { before: existing, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const parsed = updatePollSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
|
||||||
|
await connection.update(WebsitePoll).set({ ...values, updatedAt: new Date() }).where(eq(WebsitePoll.id, id));
|
||||||
|
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollQuestionChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const parsed = pollQuestionSchema.safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const [result] = await connection.insert(WebsitePollQuestion).values(parsed.data);
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, pollId: parsed.data.pollId, question: parsed.data.question }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(WebsitePollQuestion).where(eq(WebsitePollQuestion.id, id));
|
||||||
|
return { before: { id }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const parsed = pollQuestionSchema.partial().safeParse(data);
|
||||||
|
if (!parsed.success) throw validation();
|
||||||
|
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
|
||||||
|
await connection.update(WebsitePollQuestion).set(values).where(eq(WebsitePollQuestion.id, id));
|
||||||
|
return { before: { id }, after: { id, ...values }, output: { id: String(id) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tagChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const name = text(data.name, 255, true);
|
||||||
|
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
|
||||||
|
const now = new Date();
|
||||||
|
const [result] = (await connection.insert(Tags).values({ name, backgroundColor, createdAt: now, updatedAt: now })) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
await activity(context, "tag_create", "Created tag " + name + " (#" + id + ")", "tag", Number(id));
|
||||||
|
return { before: null, after: { id, name, backgroundColor }, output: { id } };
|
||||||
|
}
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.delete(Taggables).where(eq(Taggables.tagId, id));
|
||||||
|
await connection.delete(Tags).where(eq(Tags.id, id));
|
||||||
|
await activity(context, "tag_delete", "Deleted tag #" + id, "tag", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const name = text(data.name, 255, true);
|
||||||
|
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
|
||||||
|
await connection.update(Tags).set({ name, backgroundColor, updatedAt: new Date() }).where(eq(Tags.id, id));
|
||||||
|
await activity(context, "tag_update", "Updated tag #" + id + " to " + name, "tag", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: { id: id.toString(), name, backgroundColor }, output: { id: id.toString() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefixChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "create") {
|
||||||
|
const username = text(data.username, 255, true);
|
||||||
|
const [user] = await connection.select({ id: User.id }).from(User).where(eq(User.username, username)).limit(1);
|
||||||
|
if (!user) throw notFound();
|
||||||
|
await connection.execute(sql`INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) VALUES (${user.id}, ${text(data.text, 255, true)}, ${text(data.color, 32, true)}, ${text(data.icon, 255)}, ${text(data.effect, 255)}, ${Math.min(1, nonNegativeInteger(data.active, 1))})`);
|
||||||
|
return { before: null, after: { username, text: text(data.text, 255, true) } };
|
||||||
|
}
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
if (action === "delete") {
|
||||||
|
await connection.execute(sql`DELETE FROM custom_prefixes WHERE id = ${id}`);
|
||||||
|
return { before: { id }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const prefixText = text(data.text, 255, true);
|
||||||
|
const color = text(data.color, 32, true);
|
||||||
|
const icon = text(data.icon, 255);
|
||||||
|
const effect = text(data.effect, 255);
|
||||||
|
const active = Math.min(1, nonNegativeInteger(data.active, 1));
|
||||||
|
await connection.execute(sql`UPDATE custom_prefixes SET text = ${prefixText}, color = ${color}, icon = ${icon}, effect = ${effect}, active = ${active} WHERE id = ${id}`);
|
||||||
|
return { before: { id }, after: { id, text: prefixText, color, icon, effect, active }, output: { id: String(id) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefixBlacklistChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "add") {
|
||||||
|
const word = text(data.word, 100, true);
|
||||||
|
await connection.execute(sql`INSERT INTO custom_prefix_blacklist (word) VALUES (${word})`);
|
||||||
|
return { before: null, after: { word } };
|
||||||
|
}
|
||||||
|
if (action !== "remove" && action !== "delete") throw validation();
|
||||||
|
const id = positiveInteger(data.id);
|
||||||
|
await connection.execute(sql`DELETE FROM custom_prefix_blacklist WHERE id = ${id}`);
|
||||||
|
return { before: { id }, after: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIX_SETTINGS = new Set(["enabled", "max_length", "min_rank", "min_rank_to_buy", "allow_colors", "allow_bold", "allow_italic", "default_color", "price_credits", "price_points", "points_type"]);
|
||||||
|
|
||||||
|
async function prefixSettingsUpdate(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const settings = record(record(input).settings ?? input);
|
||||||
|
const accepted: Record<string, string> = {};
|
||||||
|
for (const [key, rawValue] of Object.entries(settings)) {
|
||||||
|
if (!PREFIX_SETTINGS.has(key)) continue;
|
||||||
|
const value = text(rawValue, 255);
|
||||||
|
accepted[key] = value;
|
||||||
|
await database(transaction).execute(sql`INSERT INTO custom_prefix_settings (\`key\`, \`value\`) VALUES (${key}, ${value}) ON DUPLICATE KEY UPDATE \`value\` = ${value}`);
|
||||||
|
}
|
||||||
|
return { before: null, after: { keys: Object.keys(accepted).sort() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function helpQuestionChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
await connection.delete(WebsiteHelpCenterCategories).where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
|
await activity(context, "help_delete", "Deleted help-center entry #" + id, "help_center_category", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: null };
|
||||||
|
}
|
||||||
|
const name = text(data.name, 255, true);
|
||||||
|
const content = canonicalize(rawText(data.content ?? data.answer, 100_000));
|
||||||
|
if (!content) throw validation();
|
||||||
|
const values = {
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
position: Math.max(1, positiveInteger(data.position ?? 1)),
|
||||||
|
imageUrl: text(data.imageUrl, 255) || null,
|
||||||
|
buttonText: text(data.buttonText, 255) || null,
|
||||||
|
buttonUrl: text(data.buttonUrl, 255) || null,
|
||||||
|
buttonColor: text(data.buttonColor, 16) || "#eeb425",
|
||||||
|
buttonBorderColor: text(data.buttonBorderColor, 16) || "#facc15",
|
||||||
|
smallBox: Boolean(data.smallBox),
|
||||||
|
};
|
||||||
|
if (action === "create") {
|
||||||
|
const [result] = (await connection.insert(WebsiteHelpCenterCategories).values(values)) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
await activity(context, "help_create", "Created help-center entry #" + id + " (" + name + ")", "help_center_category", Number(id));
|
||||||
|
return { before: null, after: { id, name }, output: { id } };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
await connection.update(WebsiteHelpCenterCategories).set(values).where(eq(WebsiteHelpCenterCategories.id, id));
|
||||||
|
await activity(context, "help_update", "Updated help-center entry #" + id + " (" + name + ")", "help_center_category", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: { id: id.toString(), name }, output: { id: id.toString() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeableBoxChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
await connection.delete(WebsiteWriteableBoxes).where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
|
await activity(context, "writeable_box_delete", "Deleted writeable box #" + id, "writeable_box", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: null };
|
||||||
|
}
|
||||||
|
if (action === "toggle") {
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
const isActive = data.next === "1" || data.next === 1 || data.next === true;
|
||||||
|
await connection.update(WebsiteWriteableBoxes).set({ isActive, updatedAt: new Date() }).where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
|
await activity(context, "writeable_box_toggle", (isActive ? "Activated" : "Hid") + " writeable box #" + id, "writeable_box", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: { id: id.toString(), isActive }, output: { id: id.toString() } };
|
||||||
|
}
|
||||||
|
const title = text(data.title, 255, true);
|
||||||
|
const values = {
|
||||||
|
title,
|
||||||
|
icon: text(data.icon, 255) || null,
|
||||||
|
content: rawText(data.content, 100_000),
|
||||||
|
position: nonNegativeInteger(data.position),
|
||||||
|
isActive: data.isActive === "1" || data.isActive === 1 || data.isActive === true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (action === "create") {
|
||||||
|
const now = new Date();
|
||||||
|
const [result] = (await connection.insert(WebsiteWriteableBoxes).values({ ...values, createdAt: now })) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
await activity(context, "writeable_box_create", "Created writeable box " + title + " (#" + id + ")", "writeable_box", Number(id));
|
||||||
|
return { before: null, after: { id, title }, output: { id } };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
await connection.update(WebsiteWriteableBoxes).set(values).where(eq(WebsiteWriteableBoxes.id, id));
|
||||||
|
await activity(context, "writeable_box_update", "Updated writeable box #" + id + " (" + title + ")", "writeable_box", Number(id));
|
||||||
|
return { before: { id: id.toString() }, after: { id: id.toString(), title }, output: { id: id.toString() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailTemplateChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const connection = database(transaction);
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
await connection.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
|
||||||
|
return { before: { id: id.toString() }, after: null };
|
||||||
|
}
|
||||||
|
const subject = text(data.subject, 255, true);
|
||||||
|
const body = rawText(data.body, 500_000);
|
||||||
|
if (!body) throw validation();
|
||||||
|
const values = { subject, body, variables: text(data.variables, 20_000) || null, isActive: Boolean(data.isActive) };
|
||||||
|
if (action === "create") {
|
||||||
|
const name = text(data.name, 255, true);
|
||||||
|
const [result] = (await connection.insert(EmailTemplates).values({ name, ...values })) as unknown as [ResultSetHeader];
|
||||||
|
const id = insertedId(result);
|
||||||
|
return { before: null, after: { id, name, subject, isActive: values.isActive }, output: { id } };
|
||||||
|
}
|
||||||
|
if (action !== "update") throw validation();
|
||||||
|
const id = positiveBigInt(data.id);
|
||||||
|
await connection.update(EmailTemplates).set(values).where(eq(EmailTemplates.id, id));
|
||||||
|
return { before: { id: id.toString() }, after: { id: id.toString(), subject, isActive: values.isActive }, output: { id: id.toString() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATABASE_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext, transaction: unknown) => Promise<ContentMutationSnapshot>>> = {
|
||||||
|
"article.change": articleChange,
|
||||||
|
"ad.change": adChange,
|
||||||
|
"banner.change": (input, _context, transaction) => bannerChange(input, transaction),
|
||||||
|
"event-type.change": (input, _context, transaction) => eventTypeChange(input, transaction),
|
||||||
|
"event.change": eventChange,
|
||||||
|
"event-prize.change": (input, _context, transaction) => eventPrizeChange(input, transaction),
|
||||||
|
"event-winner.add": (input, _context, transaction) => eventWinnerAdd(input, transaction),
|
||||||
|
"poll.change": (input, _context, transaction) => pollChange(input, transaction),
|
||||||
|
"poll-question.change": (input, _context, transaction) => pollQuestionChange(input, transaction),
|
||||||
|
"tag.change": tagChange,
|
||||||
|
"prefix.change": (input, _context, transaction) => prefixChange(input, transaction),
|
||||||
|
"prefix-blacklist.change": (input, _context, transaction) => prefixBlacklistChange(input, transaction),
|
||||||
|
"prefix-settings.update": (input, _context, transaction) => prefixSettingsUpdate(input, transaction),
|
||||||
|
"help-question.change": helpQuestionChange,
|
||||||
|
"writeable-box.change": writeableBoxChange,
|
||||||
|
"email-template.change": (input, _context, transaction) => emailTemplateChange(input, transaction),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeContentDatabaseMutation(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
transaction: unknown,
|
||||||
|
): Promise<ContentMutationSnapshot | null> {
|
||||||
|
const handler = DATABASE_HANDLERS[operation];
|
||||||
|
return handler ? handler(input, context, transaction) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
import { executeContentExternalMutation } from "./mutation-runtime-external";
|
||||||
|
import type { ContentMutationFailure } from "./mutations";
|
||||||
|
|
||||||
|
const fsMocks = vi.hoisted(() => ({
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
readFile: vi.fn(),
|
||||||
|
unlink: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => fsMocks);
|
||||||
|
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db", () => ({
|
||||||
|
CameraWeb: {},
|
||||||
|
EmulatorSettings: {},
|
||||||
|
WebsiteSetting: {},
|
||||||
|
db: {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { updateConfig: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: {
|
||||||
|
get: vi.fn(),
|
||||||
|
reload: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/photo-files", () => ({
|
||||||
|
tryRemoveLocalPhotoFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const capability = {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: () => true,
|
||||||
|
hasAny: () => true,
|
||||||
|
hasAll: () => true,
|
||||||
|
} satisfies HousekeepingCapabilityContext;
|
||||||
|
const context = {
|
||||||
|
capability,
|
||||||
|
correlationId: "external-runtime",
|
||||||
|
legacy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Content external mutation runtime", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
|
||||||
|
const file = new File(["image"], "photo.PNG", { type: "image/png" });
|
||||||
|
const snapshot = await executeContentExternalMutation(
|
||||||
|
"media.upload",
|
||||||
|
{ file },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
before: null,
|
||||||
|
after: { size: file.size, type: "image/png" },
|
||||||
|
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
|
||||||
|
});
|
||||||
|
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fsMocks.writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("storage"),
|
||||||
|
expect.any(Buffer),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a real storage failure before optimistic success", async () => {
|
||||||
|
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
|
||||||
|
const file = new File(["image"], "photo.png", { type: "image/png" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeContentExternalMutation("media.upload", { file }, context),
|
||||||
|
).rejects.toThrow("disk offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps normalized media traversal to validation instead of dependency failure", async () => {
|
||||||
|
await expect(
|
||||||
|
executeContentExternalMutation(
|
||||||
|
"media.delete",
|
||||||
|
{ filename: "../private.txt" },
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "VALIDATION",
|
||||||
|
messageKey: "errors.housekeeping.validation",
|
||||||
|
} satisfies Partial<ContentMutationFailure>);
|
||||||
|
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import * as JSONC from "jsonc-parser";
|
||||||
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
import {
|
||||||
|
ADMIN_NAV_CONFIG_KEY,
|
||||||
|
type AdminNavConfig,
|
||||||
|
serializeAdminNavConfig,
|
||||||
|
} from "@/lib/admin-nav-config";
|
||||||
|
import { getClientTranslationFile } from "@/lib/client-translation-files";
|
||||||
|
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
|
||||||
|
import { patchJson5 } from "@/lib/json5-patch";
|
||||||
|
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||||
|
import { rcon } from "@/lib/services/rcon";
|
||||||
|
import { siteSettings } from "@/lib/services/site-settings";
|
||||||
|
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||||
|
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||||
|
import {
|
||||||
|
deleteCustomThemeStore,
|
||||||
|
getCustomTheme,
|
||||||
|
snapshotCurrentTheme,
|
||||||
|
upsertCustomTheme,
|
||||||
|
} from "@/lib/theme-custom-store";
|
||||||
|
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||||
|
import {
|
||||||
|
type ContentMutationContext,
|
||||||
|
ContentMutationFailure,
|
||||||
|
type ContentMutationOperation,
|
||||||
|
type ContentMutationSnapshot,
|
||||||
|
} from "./mutations";
|
||||||
|
import { ContentCommittedExternalFailure } from "./mutations-production";
|
||||||
|
|
||||||
|
const MEDIA_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
const FAVICON_TYPES = [
|
||||||
|
...MEDIA_TYPES,
|
||||||
|
"image/x-icon",
|
||||||
|
"image/svg+xml",
|
||||||
|
];
|
||||||
|
const CMS_LOCALES = new Set([
|
||||||
|
"en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro",
|
||||||
|
"hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru",
|
||||||
|
]);
|
||||||
|
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||||
|
const ADMIN_COLOR_KEYS = [
|
||||||
|
"admin_canvas",
|
||||||
|
"admin_surface",
|
||||||
|
"admin_text",
|
||||||
|
"admin_text_muted",
|
||||||
|
"admin_border",
|
||||||
|
"admin_sidebar_bg",
|
||||||
|
] as const;
|
||||||
|
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"] as const;
|
||||||
|
|
||||||
|
function validation(): ContentMutationFailure {
|
||||||
|
return new ContentMutationFailure("VALIDATION", "errors.housekeeping.validation");
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(): ContentMutationFailure {
|
||||||
|
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw validation();
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, maximum: number, required = false): string {
|
||||||
|
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
|
||||||
|
if (required && !normalized) throw validation();
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
return record(JSON.parse(value));
|
||||||
|
} catch {
|
||||||
|
throw validation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileValue(value: unknown): File {
|
||||||
|
if (
|
||||||
|
typeof value !== "object" ||
|
||||||
|
value === null ||
|
||||||
|
typeof (value as File).arrayBuffer !== "function" ||
|
||||||
|
typeof (value as File).name !== "string" ||
|
||||||
|
typeof (value as File).type !== "string" ||
|
||||||
|
typeof (value as File).size !== "number"
|
||||||
|
) {
|
||||||
|
throw validation();
|
||||||
|
}
|
||||||
|
return value as File;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeWebsiteSetting(
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
comment: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.insert(WebsiteSetting)
|
||||||
|
.values({ key, value, comment })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const file = fileValue(record(input).file);
|
||||||
|
if (file.size <= 0 || file.size > 5 * 1024 * 1024 || !MEDIA_TYPES.includes(file.type)) throw validation();
|
||||||
|
await mkdir(MEDIA_ROOT, { recursive: true });
|
||||||
|
const extension = text(file.name.split(".").pop() ?? "png", 10, true).toLowerCase();
|
||||||
|
const name = Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||||
|
const filePath = resolveMediaPath(name);
|
||||||
|
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
||||||
|
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||||
|
return {
|
||||||
|
before: null,
|
||||||
|
after: { name, size: file.size, type: file.type },
|
||||||
|
output: { name, url: "/api/media/" + name },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mediaDelete(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const name = text(record(input).filename ?? record(input).name, 255, true);
|
||||||
|
let filePath: string;
|
||||||
|
try {
|
||||||
|
filePath = resolveMediaPath(name);
|
||||||
|
} catch {
|
||||||
|
throw validation();
|
||||||
|
}
|
||||||
|
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
|
||||||
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
return { before: { name }, after: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function photoDelete(
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
const id = Number(record(input).id);
|
||||||
|
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
|
||||||
|
const [row] = await db.select({ id: CameraWeb.id, url: CameraWeb.url }).from(CameraWeb).where(eq(CameraWeb.id, id)).limit(1);
|
||||||
|
if (!row) throw notFound();
|
||||||
|
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||||
|
await tryRemoveLocalPhotoFile(row.url);
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: context.capability.actor.id,
|
||||||
|
action: "photo_delete",
|
||||||
|
description: "Deleted camera photo #" + id,
|
||||||
|
targetType: "camera_web",
|
||||||
|
targetId: id,
|
||||||
|
});
|
||||||
|
return { before: { id, url: row.url }, after: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigationUpdate(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const raw = data.items ?? data.config ?? data;
|
||||||
|
const config = jsonRecord(raw) as AdminNavConfig;
|
||||||
|
const value = serializeAdminNavConfig(config);
|
||||||
|
const beforeValue = await siteSettings.get(ADMIN_NAV_CONFIG_KEY, null);
|
||||||
|
await siteSettings.update(ADMIN_NAV_CONFIG_KEY, value);
|
||||||
|
return {
|
||||||
|
before: { configured: Boolean(beforeValue) },
|
||||||
|
after: {
|
||||||
|
configured: true,
|
||||||
|
groups: config.groupOrder?.length ?? 0,
|
||||||
|
hiddenGroups: config.hiddenGroups?.length ?? 0,
|
||||||
|
hiddenItems: config.hiddenItems?.length ?? 0,
|
||||||
|
},
|
||||||
|
output: { saved: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeUpdate(
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const source = jsonRecord(data.values ?? data);
|
||||||
|
const changed: string[] = [];
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
for (const mode of ["light", "dark"] as const) {
|
||||||
|
const bag: Record<string, string> = {};
|
||||||
|
for (const key of THEME_COLOR_KEYS) {
|
||||||
|
const databaseKey = settingKey(key, mode);
|
||||||
|
const raw = text(source[databaseKey], 255);
|
||||||
|
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(ensureReadableThemeColors(bag))) {
|
||||||
|
const databaseKey = settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode);
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key: databaseKey, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
changed.push(databaseKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const adminBag: Record<string, string> = {};
|
||||||
|
for (const key of ADMIN_COLOR_KEYS) {
|
||||||
|
const raw = text(source[key], 255);
|
||||||
|
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(ensureReadableThemeColors(adminBag))) {
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
changed.push(key);
|
||||||
|
}
|
||||||
|
const radius = text(source.border_radius, 3);
|
||||||
|
if (/^\d{1,3}$/u.test(radius)) {
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key: "border_radius", value: radius, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: radius } });
|
||||||
|
changed.push("border_radius");
|
||||||
|
}
|
||||||
|
const font = text(source.font_family, 100);
|
||||||
|
if (font in FONTS) {
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key: "font_family", value: font, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: font } });
|
||||||
|
changed.push("font_family");
|
||||||
|
}
|
||||||
|
for (const key of HEADING_KEYS) {
|
||||||
|
const value = text(source[key], 3);
|
||||||
|
if (!/^\d{1,3}$/u.test(value)) continue;
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
changed.push(key);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(source, "custom_css")) {
|
||||||
|
const value = String(source.custom_css ?? "").normalize("NFC").slice(0, 20_000);
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key: "custom_css", value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
changed.push("custom_css");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: null, after: { changedKeys: changed.sort() } };
|
||||||
|
try {
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_update", description: "Updated theme settings" });
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeApplyPreset(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||||
|
const name = text(record(input).preset, 100, true);
|
||||||
|
const preset = PRESETS[name];
|
||||||
|
if (!preset) throw validation();
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
for (const [key, value] of presetSettings(preset)) {
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: name } });
|
||||||
|
});
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: null, after: { preset: name }, output: { name } };
|
||||||
|
try {
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied theme preset " + name });
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeCustomChange(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const action = text(data.action, 16, true);
|
||||||
|
const id = text(data.id, 100);
|
||||||
|
if (action === "delete") {
|
||||||
|
if (!id) throw validation();
|
||||||
|
const existing = await getCustomTheme(id);
|
||||||
|
if (!existing) throw notFound();
|
||||||
|
await deleteCustomThemeStore(id);
|
||||||
|
return { before: { id, name: existing.name }, after: null };
|
||||||
|
}
|
||||||
|
if (action !== "create" && action !== "update" && action !== "rename") throw validation();
|
||||||
|
const name = text(data.name, 100, true);
|
||||||
|
const settings = data.values ? Object.fromEntries(Object.entries(jsonRecord(data.values)).map(([key, value]) => [key, String(value)])) : await snapshotCurrentTheme();
|
||||||
|
const theme = await upsertCustomTheme(name, settings, id || undefined);
|
||||||
|
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Saved custom theme " + theme.name });
|
||||||
|
return { before: id ? { id } : null, after: { id: theme.id, name: theme.name }, output: { id: theme.id, name: theme.name } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeApplyCustom(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||||
|
const id = text(record(input).id, 100, true);
|
||||||
|
const theme = await getCustomTheme(id);
|
||||||
|
if (!theme) throw notFound();
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
for (const [key, value] of Object.entries(theme.settings)) {
|
||||||
|
if (!value) continue;
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}
|
||||||
|
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: theme.name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: theme.name } });
|
||||||
|
});
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: null, after: { id, name: theme.name }, output: { name: theme.name } };
|
||||||
|
try {
|
||||||
|
siteSettings.reload();
|
||||||
|
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied custom theme " + theme.name });
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faviconExtension(type: string): string {
|
||||||
|
return ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg" } as Record<string, string>)[type] ?? "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeStoredAsset(url: string | null | undefined, directory: string, prefix: string): Promise<void> {
|
||||||
|
if (!url?.startsWith(prefix)) return;
|
||||||
|
const name = url.slice(prefix.length);
|
||||||
|
if (!name || name.includes("..") || name.includes("/") || name.includes("\\")) return;
|
||||||
|
const filePath = path.resolve(directory, name);
|
||||||
|
if (!filePath.startsWith(directory + path.sep)) return;
|
||||||
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const file = fileValue(record(input).file);
|
||||||
|
if (file.size <= 0 || file.size > 2 * 1024 * 1024 || !FAVICON_TYPES.includes(file.type)) throw validation();
|
||||||
|
const directory = resolveMediaPath("favicon");
|
||||||
|
const filename = "favicon-" + Date.now() + "." + faviconExtension(file.type);
|
||||||
|
const filePath = path.resolve(directory, filename);
|
||||||
|
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||||
|
const oldUrl = await siteSettings.get("cms_favicon");
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||||
|
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
|
||||||
|
const url = "/api/media/favicon/" + filename;
|
||||||
|
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
|
||||||
|
try {
|
||||||
|
siteSettings.reload();
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function faviconDelete(): Promise<ContentMutationSnapshot> {
|
||||||
|
const oldUrl = await siteSettings.get("cms_favicon");
|
||||||
|
await removeStoredAsset(oldUrl, resolveMediaPath("favicon"), "/api/media/favicon/");
|
||||||
|
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: null };
|
||||||
|
try {
|
||||||
|
siteSettings.reload();
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const file = fileValue(record(input).file);
|
||||||
|
const extension = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
|
||||||
|
const directory = resolveMediaPath("logo");
|
||||||
|
const filename = "logo-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
|
||||||
|
const filePath = path.resolve(directory, filename);
|
||||||
|
if (!filePath.startsWith(directory + path.sep)) throw validation();
|
||||||
|
const oldUrl = await siteSettings.get("cms_logo");
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
|
||||||
|
const url = "/api/media/logo/" + filename;
|
||||||
|
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
|
||||||
|
try {
|
||||||
|
siteSettings.reload();
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmsTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const locale = text(data.locale, 16, true);
|
||||||
|
if (!CMS_LOCALES.has(locale)) throw validation();
|
||||||
|
const translations = jsonRecord(data.data);
|
||||||
|
const filePath = path.join(process.cwd(), "src", "messages", locale + ".json");
|
||||||
|
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
|
||||||
|
return { before: null, after: { locale, keyCount: Object.keys(translations).length } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clientTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const fileId = text(data.fileId, 100, true);
|
||||||
|
const translations = Object.fromEntries(Object.entries(jsonRecord(data.data)).map(([key, value]) => [key, String(value)]));
|
||||||
|
const file = getClientTranslationFile(fileId);
|
||||||
|
if (!file || file.readOnly) throw validation();
|
||||||
|
const absolutePath = path.join(process.cwd(), file.relPath);
|
||||||
|
const raw = await readFile(absolutePath, "utf-8");
|
||||||
|
if (file.format === "json") {
|
||||||
|
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
|
||||||
|
return { before: null, after: { fileId, keyCount: Object.keys(translations).length }, output: { commentsLost: false, unpatchedKeys: [] } };
|
||||||
|
}
|
||||||
|
const original: Record<string, string> = {};
|
||||||
|
const parsed = JSONC.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
for (const [key, value] of Object.entries(parsed)) original[key] = value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
const patched = patchJson5(raw, original, translations);
|
||||||
|
if (patched.unpatchedKeys.length === 0) {
|
||||||
|
await writeFile(absolutePath, patched.content, "utf-8");
|
||||||
|
} else {
|
||||||
|
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
before: null,
|
||||||
|
after: { fileId, keyCount: Object.keys(translations).length },
|
||||||
|
output: { commentsLost: patched.unpatchedKeys.length > 0, unpatchedKeys: patched.unpatchedKeys },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emulatorTranslationSave(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
|
||||||
|
const data = record(input);
|
||||||
|
const source = data.settings ? jsonRecord(data.settings) : data.key ? { [text(data.key, 255, true)]: text(data.value, 20_000) } : jsonRecord(data);
|
||||||
|
const entries = Object.entries(source).map(([key, value]) => [text(key, 255, true), String(value)] as const);
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
await transaction.insert(EmulatorSettings).values({ key, value }).onDuplicateKeyUpdate({ set: { value } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const snapshot: ContentMutationSnapshot = { before: null, after: { keys: entries.map(([key]) => key).sort() } };
|
||||||
|
try {
|
||||||
|
const delivered = await rcon.updateConfig();
|
||||||
|
if (!context.legacy && !delivered) throw new Error("emulator configuration sync failed");
|
||||||
|
} catch {
|
||||||
|
throw new ContentCommittedExternalFailure(snapshot);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext) => Promise<ContentMutationSnapshot>>> = {
|
||||||
|
"media.upload": (input) => mediaUpload(input),
|
||||||
|
"media.delete": (input) => mediaDelete(input),
|
||||||
|
"photo.delete": photoDelete,
|
||||||
|
"navigation.update": (input) => navigationUpdate(input),
|
||||||
|
"theme.update": themeUpdate,
|
||||||
|
"theme.apply-preset": themeApplyPreset,
|
||||||
|
"theme.custom-change": themeCustomChange,
|
||||||
|
"theme.apply-custom": themeApplyCustom,
|
||||||
|
"favicon.save": (input) => faviconSave(input),
|
||||||
|
"favicon.delete": () => faviconDelete(),
|
||||||
|
"logo.save": (input) => logoSave(input),
|
||||||
|
"translation.cms.save": (input) => cmsTranslationSave(input),
|
||||||
|
"translation.client.save": (input) => clientTranslationSave(input),
|
||||||
|
"translation.emulator.save": emulatorTranslationSave,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeContentExternalMutation(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
): Promise<ContentMutationSnapshot | null> {
|
||||||
|
const handler = EXTERNAL_HANDLERS[operation];
|
||||||
|
return handler ? handler(input, context) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AuditEntry } from "@/lib/services/audit";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
import { CONTENT_MUTATION_OPERATIONS } from "./mutations";
|
||||||
|
import {
|
||||||
|
CONTENT_DATABASE_OPERATIONS,
|
||||||
|
CONTENT_EXTERNAL_OPERATIONS,
|
||||||
|
CONTENT_MIXED_OPERATIONS,
|
||||||
|
ContentCommittedExternalFailure,
|
||||||
|
createContentProductionMutationAdapter,
|
||||||
|
} from "./mutations-production";
|
||||||
|
|
||||||
|
const capability: HousekeepingCapabilityContext = {
|
||||||
|
actor: { id: 42, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: () => true,
|
||||||
|
hasAny: () => true,
|
||||||
|
hasAll: () => true,
|
||||||
|
};
|
||||||
|
const mutationContext = {
|
||||||
|
capability,
|
||||||
|
correlationId: "production-matrix",
|
||||||
|
legacy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function dependencies() {
|
||||||
|
const transactionToken = { transaction: true };
|
||||||
|
const writeAudit = vi.fn(
|
||||||
|
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
|
||||||
|
);
|
||||||
|
const executeOperation = vi.fn(async (operation: string) => ({
|
||||||
|
before: { operation, state: "before" },
|
||||||
|
after: { operation, state: "after" },
|
||||||
|
output: { id: "18446744073709551615" },
|
||||||
|
}));
|
||||||
|
const transaction = vi.fn(async (run) => run(transactionToken));
|
||||||
|
return {
|
||||||
|
transactionToken,
|
||||||
|
writeAudit,
|
||||||
|
executeOperation,
|
||||||
|
transaction,
|
||||||
|
adapter: createContentProductionMutationAdapter({
|
||||||
|
transaction,
|
||||||
|
writeAudit,
|
||||||
|
executeOperation,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Content production mutation adapter", () => {
|
||||||
|
it("classifies every operation exactly once", () => {
|
||||||
|
const classified = [
|
||||||
|
...CONTENT_DATABASE_OPERATIONS,
|
||||||
|
...CONTENT_EXTERNAL_OPERATIONS,
|
||||||
|
...CONTENT_MIXED_OPERATIONS,
|
||||||
|
];
|
||||||
|
expect([...classified].sort()).toEqual(
|
||||||
|
[...CONTENT_MUTATION_OPERATIONS].sort(),
|
||||||
|
);
|
||||||
|
expect(new Set(classified).size).toBe(classified.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes every production operation and serializes identifiers", async () => {
|
||||||
|
const deps = dependencies();
|
||||||
|
for (const operation of CONTENT_MUTATION_OPERATIONS) {
|
||||||
|
const snapshot = await deps.adapter.execute(
|
||||||
|
operation,
|
||||||
|
{ action: "update" },
|
||||||
|
mutationContext,
|
||||||
|
);
|
||||||
|
expect(snapshot.output?.id, operation).toBe("18446744073709551615");
|
||||||
|
}
|
||||||
|
expect(deps.executeOperation).toHaveBeenCalledTimes(
|
||||||
|
CONTENT_MUTATION_OPERATIONS.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits database mutation and canonical success audit in one transaction", async () => {
|
||||||
|
const deps = dependencies();
|
||||||
|
await deps.adapter.execute(
|
||||||
|
"article.change",
|
||||||
|
{ action: "update", id: "18446744073709551615" },
|
||||||
|
mutationContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deps.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deps.executeOperation).toHaveBeenCalledWith(
|
||||||
|
"article.change",
|
||||||
|
expect.anything(),
|
||||||
|
mutationContext,
|
||||||
|
deps.transactionToken,
|
||||||
|
);
|
||||||
|
expect(deps.writeAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "content.article.change",
|
||||||
|
outcome: "success",
|
||||||
|
domain: "content",
|
||||||
|
correlationId: "production-matrix",
|
||||||
|
}),
|
||||||
|
deps.transactionToken,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists correlated intent and truthful outcome around external storage writes", async () => {
|
||||||
|
const deps = dependencies();
|
||||||
|
await deps.adapter.execute(
|
||||||
|
"translation.cms.save",
|
||||||
|
{ locale: "en", data: { welcome: "Hello" } },
|
||||||
|
mutationContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deps.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
|
||||||
|
"intent",
|
||||||
|
"success",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
deps.writeAudit.mock.calls.every(
|
||||||
|
([entry]) => entry.correlationId === "production-matrix",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain("Hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns typed partial when database committed but the external effect failed", async () => {
|
||||||
|
const deps = dependencies();
|
||||||
|
deps.executeOperation.mockRejectedValueOnce(
|
||||||
|
new ContentCommittedExternalFailure({
|
||||||
|
before: { value: "/old.ico" },
|
||||||
|
after: { value: "/new.ico" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const snapshot = await deps.adapter.execute(
|
||||||
|
"favicon.save",
|
||||||
|
{ file: { name: "favicon.ico" } },
|
||||||
|
mutationContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
before: { value: "/old.ico" },
|
||||||
|
after: { value: "/new.ico" },
|
||||||
|
completion: {
|
||||||
|
status: "partial",
|
||||||
|
external: "failed",
|
||||||
|
audit: "persisted",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
outcome: "partial",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records failure without optimistic after-state when external storage fails before commit", async () => {
|
||||||
|
const deps = dependencies();
|
||||||
|
deps.executeOperation.mockRejectedValueOnce(new Error("disk offline"));
|
||||||
|
await expect(
|
||||||
|
deps.adapter.execute(
|
||||||
|
"media.upload",
|
||||||
|
{ file: { name: "image.png" } },
|
||||||
|
mutationContext,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("disk offline");
|
||||||
|
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||||
|
outcome: "failure",
|
||||||
|
before: undefined,
|
||||||
|
after: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import type { AuditEntry } from "@/lib/services/audit";
|
||||||
|
import type {
|
||||||
|
ContentMutationAdapter,
|
||||||
|
ContentMutationContext,
|
||||||
|
ContentMutationOperation,
|
||||||
|
ContentMutationSnapshot,
|
||||||
|
} from "./mutations";
|
||||||
|
|
||||||
|
export const CONTENT_DATABASE_OPERATIONS = [
|
||||||
|
"article.change",
|
||||||
|
"ad.change",
|
||||||
|
"banner.change",
|
||||||
|
"event-type.change",
|
||||||
|
"event.change",
|
||||||
|
"event-prize.change",
|
||||||
|
"event-winner.add",
|
||||||
|
"poll.change",
|
||||||
|
"poll-question.change",
|
||||||
|
"tag.change",
|
||||||
|
"prefix.change",
|
||||||
|
"prefix-blacklist.change",
|
||||||
|
"prefix-settings.update",
|
||||||
|
"help-question.change",
|
||||||
|
"writeable-box.change",
|
||||||
|
"email-template.change",
|
||||||
|
] as const satisfies readonly ContentMutationOperation[];
|
||||||
|
|
||||||
|
export const CONTENT_EXTERNAL_OPERATIONS = [
|
||||||
|
"media.upload",
|
||||||
|
"media.delete",
|
||||||
|
"translation.cms.save",
|
||||||
|
"translation.client.save",
|
||||||
|
] as const satisfies readonly ContentMutationOperation[];
|
||||||
|
|
||||||
|
export const CONTENT_MIXED_OPERATIONS = [
|
||||||
|
"photo.delete",
|
||||||
|
"navigation.update",
|
||||||
|
"theme.update",
|
||||||
|
"theme.apply-preset",
|
||||||
|
"theme.custom-change",
|
||||||
|
"theme.apply-custom",
|
||||||
|
"favicon.save",
|
||||||
|
"favicon.delete",
|
||||||
|
"logo.save",
|
||||||
|
"translation.emulator.save",
|
||||||
|
] as const satisfies readonly ContentMutationOperation[];
|
||||||
|
|
||||||
|
type TransactionToken = unknown;
|
||||||
|
|
||||||
|
export interface ContentProductionMutationDependencies {
|
||||||
|
transaction<T>(run: (transaction: TransactionToken) => Promise<T>): Promise<T>;
|
||||||
|
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
|
||||||
|
executeOperation(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
transaction?: TransactionToken,
|
||||||
|
): Promise<ContentMutationSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContentCommittedExternalFailure extends Error {
|
||||||
|
constructor(readonly snapshot: ContentMutationSnapshot) {
|
||||||
|
super("Content database change committed but external effect failed");
|
||||||
|
this.name = "ContentCommittedExternalFailure";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditEntry(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
outcome: NonNullable<AuditEntry["outcome"]>,
|
||||||
|
snapshot?: ContentMutationSnapshot,
|
||||||
|
): AuditEntry {
|
||||||
|
return {
|
||||||
|
userId: context.capability.actor.id,
|
||||||
|
action: "content." + operation,
|
||||||
|
target: "Content",
|
||||||
|
correlationId: context.correlationId,
|
||||||
|
domain: "content",
|
||||||
|
outcome,
|
||||||
|
before:
|
||||||
|
snapshot?.before === null || snapshot?.before === undefined
|
||||||
|
? undefined
|
||||||
|
: { ...snapshot.before },
|
||||||
|
after:
|
||||||
|
snapshot?.after === null || snapshot?.after === undefined
|
||||||
|
? undefined
|
||||||
|
: { ...snapshot.after },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesOperation(
|
||||||
|
operations: readonly ContentMutationOperation[],
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
): boolean {
|
||||||
|
return operations.includes(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeOutcomeOrMarkUnavailable(
|
||||||
|
dependencies: ContentProductionMutationDependencies,
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
snapshot: ContentMutationSnapshot,
|
||||||
|
outcome: "success" | "partial",
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
try {
|
||||||
|
await dependencies.writeAudit(
|
||||||
|
auditEntry(operation, context, outcome, snapshot),
|
||||||
|
);
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
const completion = {
|
||||||
|
status: "partial" as const,
|
||||||
|
external: outcome === "partial" ? ("failed" as const) : ("completed" as const),
|
||||||
|
audit: "unavailable" as const,
|
||||||
|
};
|
||||||
|
return { ...snapshot, completion };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentProductionMutationAdapter(
|
||||||
|
dependencies: ContentProductionMutationDependencies,
|
||||||
|
): ContentMutationAdapter {
|
||||||
|
return {
|
||||||
|
async execute(operation, input, context) {
|
||||||
|
if (includesOperation(CONTENT_DATABASE_OPERATIONS, operation)) {
|
||||||
|
return dependencies.transaction(async (transaction) => {
|
||||||
|
const snapshot = await dependencies.executeOperation(
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
await dependencies.writeAudit(
|
||||||
|
auditEntry(operation, context, "success", snapshot),
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
return snapshot;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
|
||||||
|
try {
|
||||||
|
const snapshot = await dependencies.executeOperation(
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
return writeOutcomeOrMarkUnavailable(
|
||||||
|
dependencies,
|
||||||
|
operation,
|
||||||
|
context,
|
||||||
|
snapshot,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
includesOperation(CONTENT_MIXED_OPERATIONS, operation) &&
|
||||||
|
error instanceof ContentCommittedExternalFailure
|
||||||
|
) {
|
||||||
|
const snapshot: ContentMutationSnapshot = {
|
||||||
|
...error.snapshot,
|
||||||
|
completion: {
|
||||||
|
status: "partial",
|
||||||
|
external: "failed",
|
||||||
|
audit: "persisted",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return writeOutcomeOrMarkUnavailable(
|
||||||
|
dependencies,
|
||||||
|
operation,
|
||||||
|
context,
|
||||||
|
snapshot,
|
||||||
|
"partial",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await dependencies.writeAudit(
|
||||||
|
auditEntry(operation, context, "failure"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Preserve the original dependency failure; the missing outcome is observable
|
||||||
|
// through the durable correlated intent.
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contentProductionMutationAdapter =
|
||||||
|
createContentProductionMutationAdapter({
|
||||||
|
async transaction(run) {
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
|
return db.transaction((transaction) => run(transaction));
|
||||||
|
},
|
||||||
|
async writeAudit(entry, transaction) {
|
||||||
|
const { logAudit } = await import("@/lib/services/audit");
|
||||||
|
await logAudit(entry, transaction as never);
|
||||||
|
},
|
||||||
|
async executeOperation(operation, input, context, transaction) {
|
||||||
|
const { executeContentMutationOperation } = await import(
|
||||||
|
"./mutations-runtime"
|
||||||
|
);
|
||||||
|
return executeContentMutationOperation(
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
|
||||||
|
import { executeContentExternalMutation } from "./mutation-runtime-external";
|
||||||
|
import type {
|
||||||
|
ContentMutationContext,
|
||||||
|
ContentMutationOperation,
|
||||||
|
ContentMutationSnapshot,
|
||||||
|
} from "./mutations";
|
||||||
|
import { ContentMutationFailure } from "./mutations";
|
||||||
|
|
||||||
|
export async function executeContentMutationOperation(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
transaction?: unknown,
|
||||||
|
): Promise<ContentMutationSnapshot> {
|
||||||
|
const databaseResult = await executeContentDatabaseMutation(
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
|
if (databaseResult) return databaseResult;
|
||||||
|
const externalResult = await executeContentExternalMutation(
|
||||||
|
operation,
|
||||||
|
input,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
if (externalResult) return externalResult;
|
||||||
|
throw new ContentMutationFailure(
|
||||||
|
"VALIDATION",
|
||||||
|
"errors.housekeeping.validation",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||||
|
import {
|
||||||
|
CONTENT_MUTATION_OPERATIONS,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
createContentMutationService,
|
||||||
|
} from "./mutations";
|
||||||
|
|
||||||
|
function context(
|
||||||
|
granted: readonly string[],
|
||||||
|
actorId = 42,
|
||||||
|
): HousekeepingCapabilityContext {
|
||||||
|
const permissions = new Set(granted);
|
||||||
|
return {
|
||||||
|
actor: { id: actorId, username: "operator", rank: 7 },
|
||||||
|
isSuperAdmin: false,
|
||||||
|
has: (slug) => permissions.has(slug),
|
||||||
|
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||||
|
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Content mutation service authority", () => {
|
||||||
|
it("rehydrates server authority and rejects forged actor identity", async () => {
|
||||||
|
const adapter = { execute: vi.fn() };
|
||||||
|
const service = createContentMutationService(
|
||||||
|
adapter,
|
||||||
|
async () => context([PERMS.NEWS_EDIT], 42),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.execute(
|
||||||
|
{ correlationId: "forged", expectedActorId: 7 },
|
||||||
|
"article.change",
|
||||||
|
{ action: "create", title: "Forged" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
expect(adapter.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the exact operation ACL even after dispatcher authorization", async () => {
|
||||||
|
const adapter = { execute: vi.fn() };
|
||||||
|
const service = createContentMutationService(
|
||||||
|
adapter,
|
||||||
|
async () => context([PERMS.NEWS_EDIT]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.execute(
|
||||||
|
{ correlationId: "brand", expectedActorId: 42 },
|
||||||
|
"theme.update",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
|
expect(adapter.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes every declared operation through the real service boundary", async () => {
|
||||||
|
const execute = vi.fn(async (_operation, _input, mutationContext) => ({
|
||||||
|
before: null,
|
||||||
|
after: { actorId: mutationContext.capability.actor.id },
|
||||||
|
}));
|
||||||
|
const service = createContentMutationService(
|
||||||
|
{ execute },
|
||||||
|
async () => context(Object.values(PERMS)),
|
||||||
|
);
|
||||||
|
const invocation = createContentMutationInvocation(
|
||||||
|
{ id: 42 },
|
||||||
|
"operation-matrix",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const operation of CONTENT_MUTATION_OPERATIONS) {
|
||||||
|
const result = await service.execute(invocation, operation, {});
|
||||||
|
expect(result.ok, operation).toBe(true);
|
||||||
|
}
|
||||||
|
expect(execute).toHaveBeenCalledTimes(CONTENT_MUTATION_OPERATIONS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps adapter failures without exposing storage or template payloads", async () => {
|
||||||
|
const service = createContentMutationService(
|
||||||
|
{
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error("C:\\private\\template.json: secret body");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => context([PERMS.SETTINGS_EDIT]),
|
||||||
|
);
|
||||||
|
const result = await service.execute(
|
||||||
|
{ correlationId: "redacted", expectedActorId: 42 },
|
||||||
|
"translation.cms.save",
|
||||||
|
{ data: { secret: "body" } },
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: "DEPENDENCY_UNAVAILABLE",
|
||||||
|
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(result)).not.toContain("secret body");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { satisfiesCapability } from "../../../foundation/capability-context";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingErrorCode,
|
||||||
|
type HousekeepingPartialCompletion,
|
||||||
|
type HousekeepingResult,
|
||||||
|
ok,
|
||||||
|
} from "../../../foundation/contracts";
|
||||||
|
|
||||||
|
export const CONTENT_MUTATION_OPERATIONS = [
|
||||||
|
"article.change",
|
||||||
|
"ad.change",
|
||||||
|
"banner.change",
|
||||||
|
"event-type.change",
|
||||||
|
"event.change",
|
||||||
|
"event-prize.change",
|
||||||
|
"event-winner.add",
|
||||||
|
"poll.change",
|
||||||
|
"poll-question.change",
|
||||||
|
"photo.delete",
|
||||||
|
"media.upload",
|
||||||
|
"media.delete",
|
||||||
|
"navigation.update",
|
||||||
|
"tag.change",
|
||||||
|
"prefix.change",
|
||||||
|
"prefix-blacklist.change",
|
||||||
|
"prefix-settings.update",
|
||||||
|
"help-question.change",
|
||||||
|
"writeable-box.change",
|
||||||
|
"email-template.change",
|
||||||
|
"theme.update",
|
||||||
|
"theme.apply-preset",
|
||||||
|
"theme.custom-change",
|
||||||
|
"theme.apply-custom",
|
||||||
|
"favicon.save",
|
||||||
|
"favicon.delete",
|
||||||
|
"logo.save",
|
||||||
|
"translation.cms.save",
|
||||||
|
"translation.client.save",
|
||||||
|
"translation.emulator.save",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ContentMutationOperation =
|
||||||
|
(typeof CONTENT_MUTATION_OPERATIONS)[number];
|
||||||
|
|
||||||
|
export interface ContentMutationSnapshot {
|
||||||
|
readonly before: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly after: Readonly<Record<string, unknown>> | null;
|
||||||
|
readonly output?: Readonly<Record<string, unknown>>;
|
||||||
|
readonly completion?: HousekeepingPartialCompletion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentMutationInvocation {
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly expectedActorId: number;
|
||||||
|
readonly legacy?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentMutationContext {
|
||||||
|
readonly capability: HousekeepingCapabilityContext;
|
||||||
|
readonly correlationId: string;
|
||||||
|
readonly legacy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentMutationAdapter {
|
||||||
|
execute(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
context: ContentMutationContext,
|
||||||
|
): Promise<ContentMutationSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentMutationService {
|
||||||
|
execute(
|
||||||
|
invocation: ContentMutationInvocation,
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<HousekeepingResult<ContentMutationSnapshot>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContentMutationFailure extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: HousekeepingErrorCode,
|
||||||
|
readonly messageKey: string,
|
||||||
|
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
|
||||||
|
) {
|
||||||
|
super(messageKey);
|
||||||
|
this.name = "ContentMutationFailure";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPERATION_PERMISSION = Object.freeze({
|
||||||
|
"article.change": PERMS.NEWS_EDIT,
|
||||||
|
"ad.change": PERMS.PAGES_EDIT,
|
||||||
|
"banner.change": PERMS.BANNERS_EDIT,
|
||||||
|
"event-type.change": PERMS.EVENTS_EDIT,
|
||||||
|
"event.change": PERMS.EVENTS_EDIT,
|
||||||
|
"event-prize.change": PERMS.EVENTS_EDIT,
|
||||||
|
"event-winner.add": PERMS.EVENTS_EDIT,
|
||||||
|
"poll.change": PERMS.POLLS_EDIT,
|
||||||
|
"poll-question.change": PERMS.POLLS_EDIT,
|
||||||
|
"photo.delete": PERMS.PAGES_EDIT,
|
||||||
|
"media.upload": PERMS.PAGES_EDIT,
|
||||||
|
"media.delete": PERMS.PAGES_EDIT,
|
||||||
|
"navigation.update": PERMS.SETTINGS_EDIT,
|
||||||
|
"tag.change": PERMS.PAGES_EDIT,
|
||||||
|
"prefix.change": PERMS.PREFIXES_EDIT,
|
||||||
|
"prefix-blacklist.change": PERMS.PREFIXES_EDIT,
|
||||||
|
"prefix-settings.update": PERMS.PREFIXES_EDIT,
|
||||||
|
"help-question.change": PERMS.PAGES_EDIT,
|
||||||
|
"writeable-box.change": PERMS.PAGES_EDIT,
|
||||||
|
"email-template.change": PERMS.PAGES_EDIT,
|
||||||
|
"theme.update": PERMS.SETTINGS_EDIT,
|
||||||
|
"theme.apply-preset": PERMS.SETTINGS_EDIT,
|
||||||
|
"theme.custom-change": PERMS.SETTINGS_EDIT,
|
||||||
|
"theme.apply-custom": PERMS.SETTINGS_EDIT,
|
||||||
|
"favicon.save": PERMS.SETTINGS_EDIT,
|
||||||
|
"favicon.delete": PERMS.SETTINGS_EDIT,
|
||||||
|
"logo.save": PERMS.SETTINGS_EDIT,
|
||||||
|
"translation.cms.save": PERMS.SETTINGS_EDIT,
|
||||||
|
"translation.client.save": PERMS.SETTINGS_EDIT,
|
||||||
|
"translation.emulator.save": PERMS.SETTINGS_EDIT,
|
||||||
|
} satisfies Record<ContentMutationOperation, string>);
|
||||||
|
|
||||||
|
export function contentMutationCapability(
|
||||||
|
operation: ContentMutationOperation,
|
||||||
|
) {
|
||||||
|
return anyCapability(OPERATION_PERMISSION[operation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentMutationService(
|
||||||
|
adapter: ContentMutationAdapter,
|
||||||
|
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
|
||||||
|
): ContentMutationService {
|
||||||
|
return {
|
||||||
|
async execute(invocation, operation, input) {
|
||||||
|
let capability: HousekeepingCapabilityContext;
|
||||||
|
try {
|
||||||
|
capability = await resolveCapabilityContext();
|
||||||
|
} catch {
|
||||||
|
return fail(
|
||||||
|
"UNAUTHENTICATED",
|
||||||
|
"errors.housekeeping.unauthenticated",
|
||||||
|
invocation.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
capability.actor.id !== invocation.expectedActorId ||
|
||||||
|
!satisfiesCapability(capability, contentMutationCapability(operation))
|
||||||
|
) {
|
||||||
|
return fail(
|
||||||
|
"FORBIDDEN",
|
||||||
|
"errors.housekeeping.forbidden",
|
||||||
|
invocation.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const snapshot = await adapter.execute(operation, input, {
|
||||||
|
capability,
|
||||||
|
correlationId: invocation.correlationId,
|
||||||
|
legacy: invocation.legacy === true,
|
||||||
|
});
|
||||||
|
return ok(snapshot, invocation.correlationId, snapshot.completion);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ContentMutationFailure) {
|
||||||
|
return fail(
|
||||||
|
error.code,
|
||||||
|
error.messageKey,
|
||||||
|
invocation.correlationId,
|
||||||
|
error.fieldErrors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
invocation.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentMutationInvocation(
|
||||||
|
actor: { readonly id: number },
|
||||||
|
correlationId: string,
|
||||||
|
): ContentMutationInvocation {
|
||||||
|
return { correlationId, expectedActorId: actor.id, legacy: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const productionAdapter: ContentMutationAdapter = {
|
||||||
|
async execute(operation, input, context) {
|
||||||
|
const { contentProductionMutationAdapter } = await import(
|
||||||
|
"./mutations-production"
|
||||||
|
);
|
||||||
|
return contentProductionMutationAdapter.execute(operation, input, context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const contentMutationService = createContentMutationService(
|
||||||
|
productionAdapter,
|
||||||
|
async () => {
|
||||||
|
const { getHousekeepingCapabilityContext } = await import(
|
||||||
|
"../../../foundation/server-capability-context"
|
||||||
|
);
|
||||||
|
return getHousekeepingCapabilityContext();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { HousekeepingCapabilityContext } from "../../foundation/contracts";
|
||||||
|
import { contentQuery } from "./queries/content-queries";
|
||||||
|
|
||||||
|
type ContentWidgetKind = "editorial" | "media" | "localization";
|
||||||
|
|
||||||
|
async function total(
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
routeId:
|
||||||
|
| "content.editorial.articles"
|
||||||
|
| "content.media.photos"
|
||||||
|
| "content.media.library"
|
||||||
|
| "content.localization.overview",
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await contentQuery.run(context, {
|
||||||
|
routeId,
|
||||||
|
list: { pageSize: 1, offset: 0 },
|
||||||
|
});
|
||||||
|
return result.ok ? result.data.total : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadContentWidget(
|
||||||
|
kind: ContentWidgetKind,
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<Readonly<Record<string, number>>> {
|
||||||
|
if (signal.aborted) throw new Error("aborted Content widget");
|
||||||
|
if (kind === "editorial") {
|
||||||
|
const articles = await total(context, "content.editorial.articles");
|
||||||
|
return { articles };
|
||||||
|
}
|
||||||
|
if (kind === "media") {
|
||||||
|
const [photos, library] = await Promise.all([
|
||||||
|
total(context, "content.media.photos"),
|
||||||
|
total(context, "content.media.library"),
|
||||||
|
]);
|
||||||
|
return { photos, library, items: photos + library };
|
||||||
|
}
|
||||||
|
const stores = await total(context, "content.localization.overview");
|
||||||
|
return { stores };
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { authorizeHousekeeping } from "../../foundation/authorization";
|
||||||
|
import {
|
||||||
|
anyCapability,
|
||||||
|
type CapabilityRequirement,
|
||||||
|
fail,
|
||||||
|
type HousekeepingCapabilityContext,
|
||||||
|
type HousekeepingWidgetDefinition,
|
||||||
|
ok,
|
||||||
|
} from "../../foundation/contracts";
|
||||||
|
|
||||||
|
export const CONTENT_WIDGET_IDS = [
|
||||||
|
"content.editorial-summary",
|
||||||
|
"content.media-summary",
|
||||||
|
"content.localization-summary",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ContentWidgetLoader = (
|
||||||
|
context: HousekeepingCapabilityContext,
|
||||||
|
signal: AbortSignal,
|
||||||
|
) => Promise<Readonly<Record<string, number>>>;
|
||||||
|
|
||||||
|
export interface ContentWidgetAdapters {
|
||||||
|
readonly editorial: ContentWidgetLoader;
|
||||||
|
readonly media: ContentWidgetLoader;
|
||||||
|
readonly localization: ContentWidgetLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWidget(
|
||||||
|
id: (typeof CONTENT_WIDGET_IDS)[number],
|
||||||
|
kind: "mandatory" | "optional",
|
||||||
|
capability: CapabilityRequirement,
|
||||||
|
load: ContentWidgetLoader,
|
||||||
|
): HousekeepingWidgetDefinition {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
owner: "content",
|
||||||
|
kind,
|
||||||
|
capability,
|
||||||
|
async load(context, signal) {
|
||||||
|
const authorization = authorizeHousekeeping(context, capability);
|
||||||
|
if (!authorization.ok) return authorization;
|
||||||
|
try {
|
||||||
|
return ok(await load(context, signal), authorization.correlationId);
|
||||||
|
} catch {
|
||||||
|
return fail(
|
||||||
|
"DEPENDENCY_UNAVAILABLE",
|
||||||
|
"errors.housekeeping.dependencyUnavailable",
|
||||||
|
authorization.correlationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createContentWidgets(
|
||||||
|
adapters: ContentWidgetAdapters,
|
||||||
|
): readonly HousekeepingWidgetDefinition[] {
|
||||||
|
return [
|
||||||
|
createWidget(
|
||||||
|
"content.editorial-summary",
|
||||||
|
"mandatory",
|
||||||
|
anyCapability(PERMS.NEWS_VIEW),
|
||||||
|
adapters.editorial,
|
||||||
|
),
|
||||||
|
createWidget(
|
||||||
|
"content.media-summary",
|
||||||
|
"optional",
|
||||||
|
anyCapability(PERMS.PAGES_VIEW, PERMS.BANNERS_VIEW),
|
||||||
|
adapters.media,
|
||||||
|
),
|
||||||
|
createWidget(
|
||||||
|
"content.localization-summary",
|
||||||
|
"optional",
|
||||||
|
anyCapability(PERMS.SETTINGS_VIEW),
|
||||||
|
adapters.localization,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTENT_WIDGETS = createContentWidgets({
|
||||||
|
async editorial(context, signal) {
|
||||||
|
const { loadContentWidget } = await import("./widgets-production");
|
||||||
|
return loadContentWidget("editorial", context, signal);
|
||||||
|
},
|
||||||
|
async media(context, signal) {
|
||||||
|
const { loadContentWidget } = await import("./widgets-production");
|
||||||
|
return loadContentWidget("media", context, signal);
|
||||||
|
},
|
||||||
|
async localization(context, signal) {
|
||||||
|
const { loadContentWidget } = await import("./widgets-production");
|
||||||
|
return loadContentWidget("localization", context, signal);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
|
||||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
||||||
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
||||||
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
||||||
@@ -40,6 +41,7 @@ export function registerHousekeepingCommands<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||||
|
...CONTENT_COMMANDS,
|
||||||
...USER_COMMANDS,
|
...USER_COMMANDS,
|
||||||
...COMMUNITY_COMMANDS,
|
...COMMUNITY_COMMANDS,
|
||||||
...SUPPORT_COMMANDS,
|
...SUPPORT_COMMANDS,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { join, posix } from "node:path";
|
|||||||
import { createElement, type ReactElement } from "react";
|
import { createElement, type ReactElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
|
||||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||||
@@ -208,6 +209,177 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
"src/features/housekeeping/domains/people/routes",
|
"src/features/housekeeping/domains/people/routes",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/commands/content-commands.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/content/services/mutations"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/inbox-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/inbox.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/content/inbox-production"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/manifest.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/inbox",
|
||||||
|
"src/features/housekeeping/domains/content/routes",
|
||||||
|
"src/features/housekeeping/domains/content/search",
|
||||||
|
"src/features/housekeeping/domains/content/widgets",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/brand.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form",
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame",
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form.tsx",
|
||||||
|
new Set(["src/actions/housekeeping-command"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
"src/features/housekeeping/domains/content/routes",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/editorial.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form",
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame",
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/engagement.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form",
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame",
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/help.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form",
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame",
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/localization.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form",
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame",
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/pages/media.tsx",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-command-form",
|
||||||
|
"src/features/housekeeping/domains/content/pages/content-page-frame",
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
"src/features/housekeeping/domains/content/routes",
|
||||||
|
"src/lib/db",
|
||||||
|
"drizzle-orm",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries-production",
|
||||||
|
"src/features/housekeeping/domains/content/routes",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/route-handlers.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/pages/brand",
|
||||||
|
"src/features/housekeeping/domains/content/pages/editorial",
|
||||||
|
"src/features/housekeeping/domains/content/pages/engagement",
|
||||||
|
"src/features/housekeeping/domains/content/pages/help",
|
||||||
|
"src/features/housekeeping/domains/content/pages/localization",
|
||||||
|
"src/features/housekeeping/domains/content/pages/media",
|
||||||
|
"src/features/housekeeping/domains/content/routes",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/search-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
"src/features/housekeeping/domains/content/search",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/search.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/content/search-production"]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/services/mutation-runtime-database.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations",
|
||||||
|
"src/lib/db",
|
||||||
|
"drizzle-orm",
|
||||||
|
"mysql2",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations",
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations-production",
|
||||||
|
"src/lib/db",
|
||||||
|
"drizzle-orm",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations",
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations-runtime",
|
||||||
|
"src/lib/db",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations-runtime.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/services/mutation-runtime-database",
|
||||||
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external",
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/services/mutations-production",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/widgets-production.ts",
|
||||||
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/queries/content-queries",
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/features/housekeeping/domains/content/widgets.ts",
|
||||||
|
new Set(["src/features/housekeeping/domains/content/widgets-production"]),
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/features/housekeeping/domains/system/commands/system-commands.ts",
|
"src/features/housekeeping/domains/system/commands/system-commands.ts",
|
||||||
new Set(["src/features/housekeeping/domains/system/services/mutations"]),
|
new Set(["src/features/housekeeping/domains/system/services/mutations"]),
|
||||||
@@ -316,6 +488,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
[
|
[
|
||||||
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
"src/features/housekeeping/foundation/commands/bootstrap.ts",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/commands/content-commands",
|
||||||
"src/features/housekeeping/domains/people/commands/community-commands",
|
"src/features/housekeeping/domains/people/commands/community-commands",
|
||||||
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
"src/features/housekeeping/domains/people/commands/moderation-commands",
|
||||||
"src/features/housekeeping/domains/people/commands/support-commands",
|
"src/features/housekeeping/domains/people/commands/support-commands",
|
||||||
@@ -326,6 +499,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
|||||||
[
|
[
|
||||||
"src/features/housekeeping/route-handlers.ts",
|
"src/features/housekeeping/route-handlers.ts",
|
||||||
new Set([
|
new Set([
|
||||||
|
"src/features/housekeeping/domains/content/route-handlers",
|
||||||
"src/features/housekeeping/domains/people/route-handlers",
|
"src/features/housekeeping/domains/people/route-handlers",
|
||||||
"src/features/housekeeping/domains/system/route-handlers",
|
"src/features/housekeeping/domains/system/route-handlers",
|
||||||
]),
|
]),
|
||||||
@@ -725,6 +899,12 @@ describe("housekeeping runtime import boundary", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("allows only approved domain vertical runtime edges", () => {
|
it("allows only approved domain vertical runtime edges", () => {
|
||||||
|
expect(
|
||||||
|
findHousekeepingImportBoundaryViolations(
|
||||||
|
'import { contentMutationService } from "../services/mutations";',
|
||||||
|
"src/features/housekeeping/domains/content/commands/content-commands.ts",
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
findHousekeepingImportBoundaryViolations(
|
findHousekeepingImportBoundaryViolations(
|
||||||
'import { peopleMutationService } from "../services/mutations";',
|
'import { peopleMutationService } from "../services/mutations";',
|
||||||
@@ -920,7 +1100,7 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates the real six-domain registry with People primary and System routes enabled", () => {
|
it("creates the real six-domain registry with People, Content, and System routes enabled", () => {
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
||||||
|
|
||||||
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
||||||
@@ -933,7 +1113,7 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
registry.domains
|
registry.domains
|
||||||
.filter((domain) => !["people", "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(
|
||||||
@@ -941,6 +1121,11 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
.find((domain) => domain.id === "people")
|
.find((domain) => domain.id === "people")
|
||||||
?.routes.map((route) => route.id),
|
?.routes.map((route) => route.id),
|
||||||
).toEqual(PEOPLE_ROUTE_IDS);
|
).toEqual(PEOPLE_ROUTE_IDS);
|
||||||
|
expect(
|
||||||
|
registry.domains
|
||||||
|
.find((domain) => domain.id === "content")
|
||||||
|
?.routes.map((route) => route.id),
|
||||||
|
).toEqual(CONTENT_ROUTE_IDS);
|
||||||
expect(
|
expect(
|
||||||
registry.domains
|
registry.domains
|
||||||
.find((domain) => domain.id === "system")
|
.find((domain) => domain.id === "system")
|
||||||
|
|||||||
@@ -27,6 +27,45 @@ const routeMocks = vi.hoisted(() => {
|
|||||||
"domains.hotel.description": "Localized Hotel description",
|
"domains.hotel.description": "Localized Hotel description",
|
||||||
"domains.system.title": "HK::system-title",
|
"domains.system.title": "HK::system-title",
|
||||||
"domains.system.description": "Localized System description",
|
"domains.system.description": "Localized System description",
|
||||||
|
"routes.content.editorial.articles": "HK::content-editorial-articles",
|
||||||
|
"routes.content.editorial.article-create":
|
||||||
|
"HK::content-editorial-article-create",
|
||||||
|
"routes.content.editorial.article-detail":
|
||||||
|
"HK::content-editorial-article-detail",
|
||||||
|
"routes.content.media.photos": "HK::content-media-photos",
|
||||||
|
"routes.content.media.library": "HK::content-media-library",
|
||||||
|
"routes.content.media.banners": "HK::content-media-banners",
|
||||||
|
"routes.content.media.ads": "HK::content-media-ads",
|
||||||
|
"routes.content.media.ad-create": "HK::content-media-ad-create",
|
||||||
|
"routes.content.media.ad-detail": "HK::content-media-ad-detail",
|
||||||
|
"routes.content.engagement.events": "HK::content-engagement-events",
|
||||||
|
"routes.content.engagement.event-create":
|
||||||
|
"HK::content-engagement-event-create",
|
||||||
|
"routes.content.engagement.event-types":
|
||||||
|
"HK::content-engagement-event-types",
|
||||||
|
"routes.content.engagement.event-detail":
|
||||||
|
"HK::content-engagement-event-detail",
|
||||||
|
"routes.content.engagement.polls": "HK::content-engagement-polls",
|
||||||
|
"routes.content.engagement.poll-create":
|
||||||
|
"HK::content-engagement-poll-create",
|
||||||
|
"routes.content.engagement.poll-detail":
|
||||||
|
"HK::content-engagement-poll-detail",
|
||||||
|
"routes.content.help.questions": "HK::content-help-questions",
|
||||||
|
"routes.content.help.question-create": "HK::content-help-question-create",
|
||||||
|
"routes.content.help.question-detail": "HK::content-help-question-detail",
|
||||||
|
"routes.content.editorial.tags": "HK::content-editorial-tags",
|
||||||
|
"routes.content.engagement.prefixes": "HK::content-engagement-prefixes",
|
||||||
|
"routes.content.editorial.writeable-boxes":
|
||||||
|
"HK::content-editorial-writeable-boxes",
|
||||||
|
"routes.content.help.email-templates": "HK::content-help-email-templates",
|
||||||
|
"routes.content.brand.theme": "HK::content-brand-theme",
|
||||||
|
"routes.content.brand.favicon": "HK::content-brand-favicon",
|
||||||
|
"routes.content.localization.overview":
|
||||||
|
"HK::content-localization-overview",
|
||||||
|
"routes.content.localization.client": "HK::content-localization-client",
|
||||||
|
"routes.content.localization.cms": "HK::content-localization-cms",
|
||||||
|
"routes.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",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PERMS } from "@/lib/permission-slugs";
|
import { PERMS } from "@/lib/permission-slugs";
|
||||||
|
import { CONTENT_INBOX_SOURCES } from "../domains/content/inbox";
|
||||||
|
import { CONTENT_ROUTES } from "../domains/content/routes";
|
||||||
|
import { CONTENT_SEARCH_PROVIDERS } from "../domains/content/search";
|
||||||
|
import { CONTENT_WIDGETS } from "../domains/content/widgets";
|
||||||
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
import { PEOPLE_INBOX_SOURCES } from "../domains/people/inbox";
|
||||||
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
import { PEOPLE_ROUTES } from "../domains/people/routes";
|
||||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||||
@@ -361,18 +365,32 @@ describe("housekeeping registry", () => {
|
|||||||
expect(actual.routes).toEqual(
|
expect(actual.routes).toEqual(
|
||||||
expected.id === "people"
|
expected.id === "people"
|
||||||
? PEOPLE_ROUTES
|
? PEOPLE_ROUTES
|
||||||
|
: expected.id === "content"
|
||||||
|
? CONTENT_ROUTES
|
||||||
: expected.id === "system"
|
: expected.id === "system"
|
||||||
? SYSTEM_ROUTES
|
? SYSTEM_ROUTES
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.searchProviders).toEqual(
|
expect(actual.searchProviders).toEqual(
|
||||||
expected.id === "people" ? PEOPLE_SEARCH_PROVIDERS : [],
|
expected.id === "people"
|
||||||
|
? PEOPLE_SEARCH_PROVIDERS
|
||||||
|
: expected.id === "content"
|
||||||
|
? CONTENT_SEARCH_PROVIDERS
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.inboxSources).toEqual(
|
expect(actual.inboxSources).toEqual(
|
||||||
expected.id === "people" ? PEOPLE_INBOX_SOURCES : [],
|
expected.id === "people"
|
||||||
|
? PEOPLE_INBOX_SOURCES
|
||||||
|
: expected.id === "content"
|
||||||
|
? CONTENT_INBOX_SOURCES
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.widgets).toEqual(
|
expect(actual.widgets).toEqual(
|
||||||
expected.id === "people" ? PEOPLE_WIDGETS : [],
|
expected.id === "people"
|
||||||
|
? PEOPLE_WIDGETS
|
||||||
|
: expected.id === "content"
|
||||||
|
? CONTENT_WIDGETS
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
||||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||||
@@ -19,6 +20,7 @@ describe("housekeeping route handlers", () => {
|
|||||||
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||||
expect(handlerIds).toEqual([
|
expect(handlerIds).toEqual([
|
||||||
...PEOPLE_ROUTE_IDS,
|
...PEOPLE_ROUTE_IDS,
|
||||||
|
...CONTENT_ROUTE_IDS,
|
||||||
...SYSTEM_ROUTE_IDS,
|
...SYSTEM_ROUTE_IDS,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
|
||||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||||
@@ -18,4 +19,8 @@ export interface HousekeepingRouteHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||||
Object.freeze([...PEOPLE_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
|
Object.freeze([
|
||||||
|
...PEOPLE_ROUTE_HANDLERS,
|
||||||
|
...CONTENT_ROUTE_HANDLERS,
|
||||||
|
...SYSTEM_ROUTE_HANDLERS,
|
||||||
|
]);
|
||||||
@@ -4,10 +4,16 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
describe("staff smoke contract", () => {
|
describe("staff smoke contract", () => {
|
||||||
it("gates photos delete with PAGES_EDIT", () => {
|
it("gates photos delete with PAGES_EDIT", () => {
|
||||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
expect(src).toContain("PERMS.PAGES_EDIT");
|
const runtime = readFileSync(
|
||||||
expect(src).toContain("deletePhoto");
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
expect(src).toContain("logStaffActivity");
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(wrapper).toContain("PERMS.PAGES_EDIT");
|
||||||
|
expect(wrapper).toContain("deletePhoto");
|
||||||
|
expect(wrapper).toContain("contentMutationService.execute");
|
||||||
|
expect(wrapper).toContain('"photo.delete"');
|
||||||
|
expect(runtime).toContain("logStaffActivity");
|
||||||
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
|
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
|
||||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||||
"AdminPageShell",
|
"AdminPageShell",
|
||||||
@@ -149,12 +155,18 @@ describe("staff smoke contract", () => {
|
|||||||
expect(service).toContain("rcon.setTradeLock");
|
expect(service).toContain("rcon.setTradeLock");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes photos via Drizzle with local file purge helper", () => {
|
it("keeps Drizzle photo deletion and local purge in the Content runtime", () => {
|
||||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
expect(src).toContain("CameraWeb");
|
const runtime = readFileSync(
|
||||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
expect(src).toContain("@/lib/admin/photo-files");
|
"utf8",
|
||||||
expect(src).toContain("@/lib/db");
|
);
|
||||||
|
expect(wrapper).toContain("contentMutationService.execute");
|
||||||
|
expect(wrapper).toContain('"photo.delete"');
|
||||||
|
expect(runtime).toContain("CameraWeb");
|
||||||
|
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||||
|
expect(runtime).toContain("@/lib/admin/photo-files");
|
||||||
|
expect(runtime).toContain("@/lib/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guards dual ticket queues on admin and mod", () => {
|
it("guards dual ticket queues on admin and mod", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user