feat(housekeeping): deliver content vertical

This commit is contained in:
Simo committed 2026-08-30 01:54:43 +02:00
1 parent bdab924bdf
commit fd68819d9b
67 files changed
+5734 -1908

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -54
View File
@@ -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 () => {
describe("deleteHelpQuestion", () => { await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
it("deletes and redirects", async () => { expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", { action: "delete", id: "42" });
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
View File
File diff suppressed because it is too large. Load diff
+18 -38
View File
@@ -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" });
const form = new FormData();
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 () => {
describe("deleteMedia", () => {
it("deletes media file and revalidates", 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();
}); });
}); });
File diff suppressed because it is too large. Load diff
+6 -15
View File
@@ -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 });
+9 -51
View File
@@ -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();
}); });
}); });
+6 -23
View File
@@ -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");
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -41
View File
@@ -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();
}, },
); );
File diff suppressed because it is too large. Load diff
+9 -40
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+41 -66
View File
File diff suppressed because it is too large. Load diff
+20 -126
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+16 -51
View File
@@ -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(
formData: FormData,
): Promise<{ success: boolean; url?: string; error?: string }> {
try {
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!file) return { success: false, error: "No file provided" }; if (!file) return { success: false, error: "No file provided" };
const result = await contentMutationService.execute(
const ext = createContentMutationInvocation(staff, createCorrelationId()),
file.type === "image/png" "logo.save",
? "png" { file },
: file.type === "image/gif" );
? "gif" if (!result.ok) return { success: false, error: result.error.messageKey };
: 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"); revalidatePath("/", "layout");
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
return { success: true, url };
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Unknown error",
};
}
} }
+13 -7
View File
@@ -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");
}); });
}); });
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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} />;
}
File diff suppressed because it is too large. Load diff
@@ -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),
};
}
File diff suppressed because it is too large. Load diff
@@ -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} />;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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) }),
),
);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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();
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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",
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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,
File diff suppressed because it is too large. Load diff
@@ -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,
]); ]);
}); });
+6 -1
View File
@@ -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,
]);
+22 -10
View File
@@ -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", () => {