feat(housekeeping): deliver content vertical
This commit is contained in:
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
+30
-79
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
@@ -1,77 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
createHelpQuestion,
|
||||
deleteHelpQuestion,
|
||||
updateHelpQuestion,
|
||||
} from "./admin-help";
|
||||
|
||||
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 };
|
||||
});
|
||||
import { createHelpQuestion, deleteHelpQuestion, updateHelpQuestion } from "./admin-help";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
contentMutationService: { execute },
|
||||
createContentMutationInvocation: (actor: { id: number }, correlationId: string) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
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/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
const fakeForm = (data: Record<string, string | null>) => ({ get: (key: string) => key in data ? data[key] : null });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "5" } }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("createHelpQuestion", () => {
|
||||
it("creates a help question and redirects", async () => {
|
||||
await createHelpQuestion(
|
||||
fakeForm({
|
||||
name: "FAQ",
|
||||
content: "<p>Answer</p>",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
describe("Content help legacy wrappers", () => {
|
||||
it("delegates create and redirects", async () => {
|
||||
await createHelpQuestion(fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "create", name: "FAQ" }));
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateHelpQuestion", () => {
|
||||
it("updates and redirects", async () => {
|
||||
await updateHelpQuestion(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
content: "New",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
it("delegates update and redirects", async () => {
|
||||
await updateHelpQuestion(fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "update", id: "42" }));
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteHelpQuestion", () => {
|
||||
it("deletes and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
it("delegates delete and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", { action: "delete", id: "42" });
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
+32
-104
File diff suppressed because it is too large.
Load diff
@@ -1,59 +1,39 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
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/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() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
|
||||
beforeEach(() => {
|
||||
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", () => {
|
||||
it("returns error when no file provided", async () => {
|
||||
const result = await uploadMedia(new FormData());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("No file provided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadMediaAndReturn", () => {
|
||||
it("returns empty string when no file", async () => {
|
||||
describe("Content media legacy wrappers", () => {
|
||||
it("retains no-file validation", async () => {
|
||||
expect(await uploadMedia(new FormData())).toEqual({ ok: false, error: "No file provided" });
|
||||
expect(await 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 });
|
||||
});
|
||||
|
||||
describe("deleteMedia", () => {
|
||||
it("deletes media file and revalidates", async () => {
|
||||
it("delegates deletion and preserves revalidation", async () => {
|
||||
await deleteMedia("photo.png");
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", { filename: "photo.png" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/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();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
|
||||
});
|
||||
});
|
||||
+44
-60
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,9 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
@@ -26,16 +21,12 @@ export const saveAdminNavConfig = adminAction(
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const config: AdminNavConfig = {
|
||||
groupOrder: ctx.data.groupOrder,
|
||||
hiddenGroups: ctx.data.hiddenGroups,
|
||||
hiddenItems: ctx.data.hiddenItems,
|
||||
itemOrder: ctx.data.itemOrder,
|
||||
};
|
||||
await siteSettings.update(
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
serializeAdminNavConfig(config),
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"navigation.update",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new Error("Navigation update failed");
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
|
||||
@@ -2,71 +2,29 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
||||
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
||||
const limit = vi.fn();
|
||||
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 };
|
||||
});
|
||||
|
||||
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/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("@/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(() => {
|
||||
vi.clearAllMocks();
|
||||
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
||||
whereDelete.mockResolvedValue(undefined);
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("deletePhoto", () => {
|
||||
it("deletes a photo and revalidates", async () => {
|
||||
await deletePhoto(fakeForm({ id: "42" }));
|
||||
expect(select).toHaveBeenCalled();
|
||||
expect(deleteFn).toHaveBeenCalled();
|
||||
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
||||
expect(logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "photo_delete",
|
||||
targetId: 42,
|
||||
}),
|
||||
);
|
||||
it("delegates deletion and preserves both revalidations", async () => {
|
||||
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await deletePhoto(fakeForm({ id: "0" }));
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
await deletePhoto({ get: () => "0" });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
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 { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "photo.delete", { id });
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/photos");
|
||||
}
|
||||
+31
-109
File diff suppressed because it is too large.
Load diff
+21
-91
File diff suppressed because it is too large.
Load diff
+32
-180
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+18
-41
@@ -1,13 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
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 { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
|
||||
const bannerSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
@@ -21,46 +18,31 @@ const bannerSchema = z.object({
|
||||
endDate: z.string().max(50).nullable().optional(),
|
||||
});
|
||||
|
||||
async function runBanner(ctx: { data: Record<string, unknown>; requestId: unknown; session: { user: { id: number } } }, action: "create" | "update" | "delete") {
|
||||
return contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"banner.change",
|
||||
{ action, ...ctx.data },
|
||||
);
|
||||
}
|
||||
|
||||
export const createBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||
async (ctx) => {
|
||||
const [result] = (await db
|
||||
.insert(WebsiteBanner)
|
||||
.values(ctx.data)) as unknown as [ResultSetHeader];
|
||||
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 result = await runBanner(ctx, "create");
|
||||
if (!result.ok) throw new ActionError("Banner creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
const updateBannerInput = bannerSchema
|
||||
.partial()
|
||||
.extend({ id: z.coerce.number().int().positive() });
|
||||
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const updateBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteBanner.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 });
|
||||
const result = await runBanner(ctx, "update");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -69,13 +51,8 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||
async (ctx) => {
|
||||
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_delete",
|
||||
target: "WebsiteBanner",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
const result = await runBanner(ctx, "delete");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,48 +1,17 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const { insertValues } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn(() => ({
|
||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
||||
}));
|
||||
return { insertValues };
|
||||
});
|
||||
|
||||
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() } }));
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { keys: ["key1", "key2"] } }, correlationId: "emulator" })) }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute } }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" } }));
|
||||
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler }));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" }));
|
||||
|
||||
describe("saveEmulatorSettings", () => {
|
||||
it("saves settings and calls rcon update", async () => {
|
||||
const handler = (await import("./emulator").then(
|
||||
(m) => m.saveEmulatorSettings,
|
||||
)) as unknown as (ctx: {
|
||||
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();
|
||||
it("delegates the third translation store and preserves result shape", async () => {
|
||||
const handler = (await import("./emulator")).saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>;
|
||||
const result = await handler({ data: { settings: { key1: "val1", key2: "val2" } }, session: { user: { id: "1" } }, requestId: "emulator" });
|
||||
expect(execute).toHaveBeenCalledWith({ correlationId: "emulator", expectedActorId: 1, legacy: true }, "translation.emulator.save", { settings: { key1: "val1", key2: "val2" } });
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
});
|
||||
+8
-24
@@ -1,38 +1,22 @@
|
||||
"use server";
|
||||
|
||||
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 { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const saveEmulatorSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
const saveEmulatorSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) });
|
||||
|
||||
export const saveEmulatorSettings = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||
async (ctx) => {
|
||||
const entries = Object.entries(ctx.data.settings);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
await db
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value: String(value) })
|
||||
.onDuplicateKeyUpdate({ set: { value: String(value) } });
|
||||
}
|
||||
|
||||
await rcon.updateConfig();
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "emulator_settings_update",
|
||||
target: "EmulatorSettings",
|
||||
after: ctx.data.settings,
|
||||
});
|
||||
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"translation.emulator.save",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+61
-125
File diff suppressed because it is too large.
Load diff
+41
-66
File diff suppressed because it is too large.
Load diff
+20
-126
File diff suppressed because it is too large.
Load diff
+35
-125
File diff suppressed because it is too large.
Load diff
+16
-51
@@ -1,59 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
contentMutationService,
|
||||
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 }> {
|
||||
try {
|
||||
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
|
||||
const ext =
|
||||
file.type === "image/png"
|
||||
? "png"
|
||||
: 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();
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"logo.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
}
|
||||
@@ -2,14 +2,20 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
|
||||
describe("admin-photos drizzle contract", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
describe("admin-photos Content service contract", () => {
|
||||
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", () => {
|
||||
expect(src).toContain("@/lib/db");
|
||||
expect(src).toContain("CameraWeb");
|
||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(src).toContain('revalidatePath("/photos")');
|
||||
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
||||
expect(wrapper).toContain("contentMutationService.execute");
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||
expect(runtime).toContain("@/lib/db");
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+21
-99
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,
|
||||
type HousekeepingDomainManifest,
|
||||
} 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 = {
|
||||
id: "content",
|
||||
@@ -26,8 +30,8 @@ export const contentManifest = {
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
),
|
||||
routes: [],
|
||||
searchProviders: [],
|
||||
inboxSources: [],
|
||||
widgets: [],
|
||||
routes: CONTENT_ROUTES,
|
||||
searchProviders: CONTENT_SEARCH_PROVIDERS,
|
||||
inboxSources: CONTENT_INBOX_SOURCES,
|
||||
widgets: CONTENT_WIDGETS,
|
||||
} 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 { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands";
|
||||
import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands";
|
||||
import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands";
|
||||
import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands";
|
||||
@@ -40,6 +41,7 @@ export function registerHousekeepingCommands<
|
||||
}
|
||||
|
||||
const currentHousekeepingCommands = defineHousekeepingCommands(
|
||||
...CONTENT_COMMANDS,
|
||||
...USER_COMMANDS,
|
||||
...COMMUNITY_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.system.title": "HK::system-title",
|
||||
"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.permission-detail":
|
||||
"HK::system-access-permission-detail",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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_ROUTES } from "../domains/people/routes";
|
||||
import { PEOPLE_SEARCH_PROVIDERS } from "../domains/people/search";
|
||||
@@ -361,18 +365,32 @@ describe("housekeeping registry", () => {
|
||||
expect(actual.routes).toEqual(
|
||||
expected.id === "people"
|
||||
? PEOPLE_ROUTES
|
||||
: expected.id === "content"
|
||||
? CONTENT_ROUTES
|
||||
: expected.id === "system"
|
||||
? SYSTEM_ROUTES
|
||||
: [],
|
||||
);
|
||||
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(
|
||||
expected.id === "people" ? PEOPLE_INBOX_SOURCES : [],
|
||||
expected.id === "people"
|
||||
? PEOPLE_INBOX_SOURCES
|
||||
: expected.id === "content"
|
||||
? CONTENT_INBOX_SOURCES
|
||||
: [],
|
||||
);
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTENT_ROUTE_IDS } from "./domains/content/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "./domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "./domains/system/routes";
|
||||
import { createHousekeepingRegistry } from "./foundation/registry";
|
||||
@@ -19,6 +20,7 @@ describe("housekeeping route handlers", () => {
|
||||
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
|
||||
expect(handlerIds).toEqual([
|
||||
...PEOPLE_ROUTE_IDS,
|
||||
...CONTENT_ROUTE_IDS,
|
||||
...SYSTEM_ROUTE_IDS,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
|
||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
@@ -18,4 +19,8 @@ export interface 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", () => {
|
||||
it("gates photos delete with PAGES_EDIT", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
expect(src).toContain("PERMS.PAGES_EDIT");
|
||||
expect(src).toContain("deletePhoto");
|
||||
expect(src).toContain("logStaffActivity");
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"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(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||
"AdminPageShell",
|
||||
@@ -149,12 +155,18 @@ describe("staff smoke contract", () => {
|
||||
expect(service).toContain("rcon.setTradeLock");
|
||||
});
|
||||
|
||||
it("deletes photos via Drizzle with local file purge helper", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
expect(src).toContain("CameraWeb");
|
||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(src).toContain("@/lib/admin/photo-files");
|
||||
expect(src).toContain("@/lib/db");
|
||||
it("keeps Drizzle photo deletion and local purge in the Content runtime", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
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", () => {
|
||||
|
||||
Reference in new issue
Block a user