feat(housekeeping): deliver content vertical
This commit is contained in:
1 parent
bdab924bdf
commit
fd68819d9b
67 files changed
+5742
-1916
No files matched your search
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 { redirect } from "next/navigation";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import {
|
import { createHelpQuestion, deleteHelpQuestion, updateHelpQuestion } from "./admin-help";
|
||||||
createHelpQuestion,
|
|
||||||
deleteHelpQuestion,
|
|
||||||
updateHelpQuestion,
|
|
||||||
} from "./admin-help";
|
|
||||||
|
|
||||||
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
|
|
||||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
|
|
||||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
return { insertValues, updateWhere, deleteWhere };
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||||
|
contentMutationService: { execute },
|
||||||
|
createContentMutationInvocation: (actor: { id: number }, correlationId: string) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
|
||||||
|
}));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
},
|
|
||||||
WebsiteHelpCenterCategories: { id: "id" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
const fakeForm = (data: Record<string, string | null>) => ({
|
const fakeForm = (data: Record<string, string | null>) => ({ get: (key: string) => key in data ? data[key] : null });
|
||||||
get: (key: string) => (key in data ? data[key] : null),
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||||
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "5" } }, correlationId: "legacy" });
|
||||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createHelpQuestion", () => {
|
describe("Content help legacy wrappers", () => {
|
||||||
it("creates a help question and redirects", async () => {
|
it("delegates create and redirects", async () => {
|
||||||
await createHelpQuestion(
|
await createHelpQuestion(fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData);
|
||||||
fakeForm({
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "create", name: "FAQ" }));
|
||||||
name: "FAQ",
|
|
||||||
content: "<p>Answer</p>",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(insertValues).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
it("delegates update and redirects", async () => {
|
||||||
|
await updateHelpQuestion(fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData);
|
||||||
describe("updateHelpQuestion", () => {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "update", id: "42" }));
|
||||||
it("updates and redirects", async () => {
|
|
||||||
await updateHelpQuestion(
|
|
||||||
fakeForm({
|
|
||||||
id: "42",
|
|
||||||
name: "Updated",
|
|
||||||
content: "New",
|
|
||||||
}) as unknown as FormData,
|
|
||||||
);
|
|
||||||
expect(updateWhere).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
it("delegates delete and redirects", async () => {
|
||||||
|
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||||
describe("deleteHelpQuestion", () => {
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", { action: "delete", id: "42" });
|
||||||
it("deletes and redirects", async () => {
|
|
||||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
|
||||||
expect(deleteWhere).toHaveBeenCalled();
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+32
-104
File diff suppressed because it is too large.
Load diff
@@ -1,59 +1,39 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
|
||||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||||
|
|
||||||
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/media-storage", () => {
|
|
||||||
const root = path.join("/tmp", "nexst-test-media");
|
|
||||||
return {
|
|
||||||
MEDIA_ROOT: root,
|
|
||||||
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
vi.mock("node:fs/promises", () => ({
|
|
||||||
mkdir: vi.fn(),
|
|
||||||
writeFile: vi.fn(),
|
|
||||||
unlink: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
const staff = { id: 1, rank: 7, username: "admin" };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||||
|
execute.mockResolvedValue({ ok: true, data: { before: null, after: { name: "photo.png" }, output: { url: "/api/media/photo.png" } }, correlationId: "legacy" });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("uploadMedia", () => {
|
describe("Content media legacy wrappers", () => {
|
||||||
it("returns error when no file provided", async () => {
|
it("retains no-file validation", async () => {
|
||||||
const result = await uploadMedia(new FormData());
|
expect(await uploadMedia(new FormData())).toEqual({ ok: false, error: "No file provided" });
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.error).toBe("No file provided");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("uploadMediaAndReturn", () => {
|
|
||||||
it("returns empty string when no file", async () => {
|
|
||||||
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||||
|
expect(execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
it("delegates a valid upload and preserves both result shapes", async () => {
|
||||||
|
const file = new File(["bytes"], "photo.png", { type: "image/png" });
|
||||||
describe("deleteMedia", () => {
|
const form = new FormData();
|
||||||
it("deletes media file and revalidates", async () => {
|
form.set("file", file);
|
||||||
|
expect(await uploadMedia(form)).toEqual({ ok: true });
|
||||||
|
expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png");
|
||||||
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.upload", { file });
|
||||||
|
});
|
||||||
|
it("delegates deletion and preserves revalidation", async () => {
|
||||||
await deleteMedia("photo.png");
|
await deleteMedia("photo.png");
|
||||||
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", { filename: "photo.png" });
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||||
});
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
|
||||||
|
|
||||||
it("skips deletion when path is outside media root", async () => {
|
|
||||||
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
|
||||||
await deleteMedia("../../../etc/passwd");
|
|
||||||
const { unlink } = await import("node:fs/promises");
|
|
||||||
expect(unlink).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+44
-60
File diff suppressed because it is too large.
Load diff
@@ -2,14 +2,9 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
type AdminNavConfig,
|
|
||||||
serializeAdminNavConfig,
|
|
||||||
} from "@/lib/admin-nav-config";
|
|
||||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
groupOrder: z.array(z.string()),
|
groupOrder: z.array(z.string()),
|
||||||
@@ -26,16 +21,12 @@ export const saveAdminNavConfig = adminAction(
|
|||||||
rateLimitMax: 30,
|
rateLimitMax: 30,
|
||||||
},
|
},
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const config: AdminNavConfig = {
|
const result = await contentMutationService.execute(
|
||||||
groupOrder: ctx.data.groupOrder,
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
hiddenGroups: ctx.data.hiddenGroups,
|
"navigation.update",
|
||||||
hiddenItems: ctx.data.hiddenItems,
|
ctx.data,
|
||||||
itemOrder: ctx.data.itemOrder,
|
|
||||||
};
|
|
||||||
await siteSettings.update(
|
|
||||||
ADMIN_NAV_CONFIG_KEY,
|
|
||||||
serializeAdminNavConfig(config),
|
|
||||||
);
|
);
|
||||||
|
if (!result.ok) throw new Error("Navigation update failed");
|
||||||
revalidatePath("/admin", "layout");
|
revalidatePath("/admin", "layout");
|
||||||
revalidatePath("/admin/menu");
|
revalidatePath("/admin/menu");
|
||||||
return actionOk({ saved: true });
|
return actionOk({ saved: true });
|
||||||
|
|||||||
@@ -2,71 +2,29 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
import { deletePhoto } from "./admin-photos";
|
import { deletePhoto } from "./admin-photos";
|
||||||
|
|
||||||
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||||
const limit = vi.fn();
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||||
const whereSelect = vi.fn(() => ({ limit }));
|
|
||||||
const from = vi.fn(() => ({ where: whereSelect }));
|
|
||||||
const select = vi.fn(() => ({ from }));
|
|
||||||
const whereDelete = vi.fn();
|
|
||||||
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
|
||||||
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||||
vi.mock("@/lib/admin/photo-files", () => ({
|
|
||||||
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/services/staff-activity", () => ({
|
|
||||||
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
select: (...args) => select(...args),
|
|
||||||
delete: (...args) => deleteFn(...args),
|
|
||||||
},
|
|
||||||
CameraWeb: { id: "id", url: "url" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fakeForm = (data) => ({
|
|
||||||
get: (key) => data[key] ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||||
whereDelete.mockResolvedValue(undefined);
|
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
||||||
vi.mocked(requirePermission).mockResolvedValue({
|
|
||||||
id: 1,
|
|
||||||
rank: 7,
|
|
||||||
username: "admin",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deletePhoto", () => {
|
describe("deletePhoto", () => {
|
||||||
it("deletes a photo and revalidates", async () => {
|
it("delegates deletion and preserves both revalidations", async () => {
|
||||||
await deletePhoto(fakeForm({ id: "42" }));
|
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
||||||
expect(select).toHaveBeenCalled();
|
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
||||||
expect(deleteFn).toHaveBeenCalled();
|
|
||||||
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
|
||||||
expect(logStaffActivity).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
action: "photo_delete",
|
|
||||||
targetId: 42,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns early when id is not positive", async () => {
|
it("returns early when id is not positive", async () => {
|
||||||
await deletePhoto(fakeForm({ id: "0" }));
|
await deletePhoto({ get: () => "0" });
|
||||||
expect(select).not.toHaveBeenCalled();
|
expect(execute).not.toHaveBeenCalled();
|
||||||
expect(deleteFn).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,36 +1,19 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
contentMutationService,
|
||||||
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
import { requirePermission } from "@/lib/admin/guard";
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
|
||||||
import { CameraWeb, db } from "@/lib/db";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
|
||||||
|
|
||||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||||
const id = Number(formData.get("id"));
|
const id = Number(formData.get("id"));
|
||||||
if (!(id > 0)) return;
|
if (!(id > 0)) return;
|
||||||
|
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "photo.delete", { id });
|
||||||
const [row] = await db
|
|
||||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
|
||||||
.from(CameraWeb)
|
|
||||||
.where(eq(CameraWeb.id, id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
|
||||||
await tryRemoveLocalPhotoFile(row.url);
|
|
||||||
await logStaffActivity({
|
|
||||||
staffId: staff.id,
|
|
||||||
action: "photo_delete",
|
|
||||||
description: `Deleted camera photo #${id}`,
|
|
||||||
targetType: "camera_web",
|
|
||||||
targetId: id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/admin/photos");
|
revalidatePath("/admin/photos");
|
||||||
revalidatePath("/photos");
|
revalidatePath("/photos");
|
||||||
}
|
}
|
||||||
+31
-109
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";
|
"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
@@ -1,48 +1,17 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const { insertValues } = vi.hoisted(() => {
|
const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { keys: ["key1", "key2"] } }, correlationId: "emulator" })) }));
|
||||||
const insertValues = vi.fn(() => ({
|
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute } }));
|
||||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" } }));
|
||||||
}));
|
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler }));
|
||||||
return { insertValues };
|
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" }));
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@/lib/permissions", () => ({
|
|
||||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/db", () => ({
|
|
||||||
db: {
|
|
||||||
insert: vi.fn(() => ({ values: insertValues })),
|
|
||||||
},
|
|
||||||
EmulatorSettings: { key: "key", value: "value" },
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action", () => ({
|
|
||||||
adminAction: vi.fn(
|
|
||||||
(_opts: unknown, fn: (...args: unknown[]) => unknown) => fn,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
|
||||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
|
||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
|
|
||||||
|
|
||||||
describe("saveEmulatorSettings", () => {
|
describe("saveEmulatorSettings", () => {
|
||||||
it("saves settings and calls rcon update", async () => {
|
it("delegates the third translation store and preserves result shape", async () => {
|
||||||
const handler = (await import("./emulator").then(
|
const handler = (await import("./emulator")).saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>;
|
||||||
(m) => m.saveEmulatorSettings,
|
const result = await handler({ data: { settings: { key1: "val1", key2: "val2" } }, session: { user: { id: "1" } }, requestId: "emulator" });
|
||||||
)) as unknown as (ctx: {
|
expect(execute).toHaveBeenCalledWith({ correlationId: "emulator", expectedActorId: 1, legacy: true }, "translation.emulator.save", { settings: { key1: "val1", key2: "val2" } });
|
||||||
data: { settings: Record<string, string> };
|
|
||||||
session: { user: { id: string } };
|
|
||||||
}) => Promise<string>;
|
|
||||||
|
|
||||||
const result = await handler({
|
|
||||||
data: { settings: { key1: "val1", key2: "val2" } },
|
|
||||||
session: { user: { id: "1" } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(insertValues).toHaveBeenCalledTimes(2);
|
|
||||||
expect(rcon.updateConfig).toHaveBeenCalled();
|
|
||||||
expect(result).toBe("ok");
|
expect(result).toBe("ok");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+8
-24
@@ -1,38 +1,22 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, EmulatorSettings } from "@/lib/db";
|
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { adminAction } from "@/lib/safe-action";
|
import { adminAction } from "@/lib/safe-action";
|
||||||
import { actionOk } from "@/lib/safe-action-shared";
|
import { actionOk } from "@/lib/safe-action-shared";
|
||||||
import { logAudit } from "@/lib/services/audit";
|
|
||||||
import { rcon } from "@/lib/services/rcon";
|
|
||||||
|
|
||||||
const saveEmulatorSettingsSchema = z.object({
|
const saveEmulatorSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) });
|
||||||
settings: z.record(z.string(), z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const saveEmulatorSettings = adminAction(
|
export const saveEmulatorSettings = adminAction(
|
||||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const entries = Object.entries(ctx.data.settings);
|
const result = await contentMutationService.execute(
|
||||||
|
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||||
for (const [key, value] of entries) {
|
"translation.emulator.save",
|
||||||
await db
|
ctx.data,
|
||||||
.insert(EmulatorSettings)
|
);
|
||||||
.values({ key, value: String(value) })
|
if (!result.ok) throw new Error(result.error.messageKey);
|
||||||
.onDuplicateKeyUpdate({ set: { value: String(value) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
await rcon.updateConfig();
|
|
||||||
|
|
||||||
logAudit({
|
|
||||||
userId: ctx.session.user.id,
|
|
||||||
action: "emulator_settings_update",
|
|
||||||
target: "EmulatorSettings",
|
|
||||||
after: ctx.data.settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
return actionOk();
|
return actionOk();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
+61
-125
File diff suppressed because it is too large.
Load diff
+41
-66
File diff suppressed because it is too large.
Load diff
+21
-127
File diff suppressed because it is too large.
Load diff
+37
-127
File diff suppressed because it is too large.
Load diff
+19
-54
@@ -1,59 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { db, WebsiteSetting } from "@/lib/db";
|
import {
|
||||||
import { resolveMediaPath } from "@/lib/media-storage";
|
contentMutationService,
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
createContentMutationInvocation,
|
||||||
|
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||||
|
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const MEDIA_DIR = resolveMediaPath("logo");
|
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||||
|
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||||
export async function saveLogo(
|
const file = formData.get("file") as File | null;
|
||||||
formData: FormData,
|
if (!file) return { success: false, error: "No file provided" };
|
||||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
const result = await contentMutationService.execute(
|
||||||
try {
|
createContentMutationInvocation(staff, createCorrelationId()),
|
||||||
const file = formData.get("file") as File | null;
|
"logo.save",
|
||||||
if (!file) return { success: false, error: "No file provided" };
|
{ file },
|
||||||
|
);
|
||||||
const ext =
|
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||||
file.type === "image/png"
|
revalidatePath("/", "layout");
|
||||||
? "png"
|
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||||
: file.type === "image/gif"
|
|
||||||
? "gif"
|
|
||||||
: file.type === "image/jpeg"
|
|
||||||
? "jpg"
|
|
||||||
: file.type === "image/webp"
|
|
||||||
? "webp"
|
|
||||||
: "png";
|
|
||||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
||||||
const baseDir = MEDIA_DIR;
|
|
||||||
const filePath = path.resolve(baseDir, filename);
|
|
||||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
|
||||||
return { success: false, error: "Invalid path" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await mkdir(baseDir, { recursive: true });
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
||||||
await writeFile(filePath, buffer);
|
|
||||||
|
|
||||||
const url = `/api/media/logo/${filename}`;
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(WebsiteSetting)
|
|
||||||
.values({ key: "cms_logo", value: url, comment: "Logo (generator)" })
|
|
||||||
.onDuplicateKeyUpdate({ set: { value: url } });
|
|
||||||
|
|
||||||
siteSettings.reload();
|
|
||||||
revalidatePath("/", "layout");
|
|
||||||
|
|
||||||
return { success: true, url };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: e instanceof Error ? e.message : "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,20 @@ import { readFileSync } from "node:fs";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||||
|
|
||||||
describe("admin-photos drizzle contract", () => {
|
describe("admin-photos Content service contract", () => {
|
||||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
|
const runtime = readFileSync(
|
||||||
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
it("deletes via Drizzle CameraWeb and attempts local file purge", () => {
|
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
||||||
expect(src).toContain("@/lib/db");
|
expect(wrapper).toContain("contentMutationService.execute");
|
||||||
expect(src).toContain("CameraWeb");
|
expect(wrapper).toContain('"photo.delete"');
|
||||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||||
expect(src).toContain('revalidatePath("/photos")');
|
expect(runtime).toContain("@/lib/db");
|
||||||
|
expect(runtime).toContain("CameraWeb");
|
||||||
|
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+21
-99
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,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { CONTENT_ROUTE_HANDLERS } from "./domains/content/route-handlers";
|
||||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||||
@@ -18,4 +19,8 @@ export interface HousekeepingRouteHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
|
||||||
Object.freeze([...PEOPLE_ROUTE_HANDLERS, ...SYSTEM_ROUTE_HANDLERS]);
|
Object.freeze([
|
||||||
|
...PEOPLE_ROUTE_HANDLERS,
|
||||||
|
...CONTENT_ROUTE_HANDLERS,
|
||||||
|
...SYSTEM_ROUTE_HANDLERS,
|
||||||
|
]);
|
||||||
@@ -4,10 +4,16 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
describe("staff smoke contract", () => {
|
describe("staff smoke contract", () => {
|
||||||
it("gates photos delete with PAGES_EDIT", () => {
|
it("gates photos delete with PAGES_EDIT", () => {
|
||||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
expect(src).toContain("PERMS.PAGES_EDIT");
|
const runtime = readFileSync(
|
||||||
expect(src).toContain("deletePhoto");
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
expect(src).toContain("logStaffActivity");
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(wrapper).toContain("PERMS.PAGES_EDIT");
|
||||||
|
expect(wrapper).toContain("deletePhoto");
|
||||||
|
expect(wrapper).toContain("contentMutationService.execute");
|
||||||
|
expect(wrapper).toContain('"photo.delete"');
|
||||||
|
expect(runtime).toContain("logStaffActivity");
|
||||||
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
|
expect(existsSync("src/app/admin/photos/page.tsx")).toBe(true);
|
||||||
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
expect(readFileSync("src/app/admin/photos/page.tsx", "utf8")).toContain(
|
||||||
"AdminPageShell",
|
"AdminPageShell",
|
||||||
@@ -149,12 +155,18 @@ describe("staff smoke contract", () => {
|
|||||||
expect(service).toContain("rcon.setTradeLock");
|
expect(service).toContain("rcon.setTradeLock");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes photos via Drizzle with local file purge helper", () => {
|
it("keeps Drizzle photo deletion and local purge in the Content runtime", () => {
|
||||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||||
expect(src).toContain("CameraWeb");
|
const runtime = readFileSync(
|
||||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||||
expect(src).toContain("@/lib/admin/photo-files");
|
"utf8",
|
||||||
expect(src).toContain("@/lib/db");
|
);
|
||||||
|
expect(wrapper).toContain("contentMutationService.execute");
|
||||||
|
expect(wrapper).toContain('"photo.delete"');
|
||||||
|
expect(runtime).toContain("CameraWeb");
|
||||||
|
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||||
|
expect(runtime).toContain("@/lib/admin/photo-files");
|
||||||
|
expect(runtime).toContain("@/lib/db");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("guards dual ticket queues on admin and mod", () => {
|
it("guards dual ticket queues on admin and mod", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user