feat(housekeeping): deliver content vertical
This commit is contained in:
1 parent
bdab924bdf
commit
fd68819d9b
67 files changed
+5742
-1916
No files matched your search
@@ -1,98 +1,62 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { ActionError } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createAd, deleteAd } from "./admin-ads";
|
||||
|
||||
const { insertValues, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, deleteWhere };
|
||||
});
|
||||
|
||||
const { execute } = vi.hoisted(() => ({
|
||||
execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" })),
|
||||
}));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
contentMutationService: { execute },
|
||||
createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteAds: { id: "id" },
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: vi.fn((_o: unknown, f: (...args: unknown[]) => unknown) => f),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class extends Error {},
|
||||
actionOk: vi.fn(() => "ok"),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler }));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({ ActionError: class ActionError extends Error {}, actionOk: () => "ok" }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (k: string) => data[k] ?? null,
|
||||
});
|
||||
const fakeForm = (data) => ({ get: (key) => data[key] ?? null });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff);
|
||||
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" }, output: { id: "1" } }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("createAd", () => {
|
||||
it("creates ad and redirects", async () => {
|
||||
await createAd(
|
||||
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
describe("Content advertisement legacy wrappers", () => {
|
||||
it("delegates creation and preserves redirect", async () => {
|
||||
await createAd(fakeForm({ image: "https://example.com/ad.png" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ expectedActorId: 1, legacy: true }), "ad.change", { action: "create", image: "https://example.com/ad.png" });
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
});
|
||||
|
||||
it("returns early when image empty", async () => {
|
||||
await createAd(fakeForm({ image: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
it("returns early when image is empty", async () => {
|
||||
await createAd(fakeForm({ image: "" }));
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs error on db failure", async () => {
|
||||
insertValues.mockRejectedValue(new Error("db"));
|
||||
await createAd(fakeForm({ image: "x" }) as unknown as FormData);
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
it("logs a redacted service failure", async () => {
|
||||
execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" });
|
||||
await createAd(fakeForm({ image: "x" }));
|
||||
expect(logger.error).toHaveBeenCalledWith("Action failed: createAd", expect.objectContaining({ error: "errors.housekeeping.dependencyUnavailable" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAd", () => {
|
||||
it("deletes ad and returns ok", async () => {
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
expect(
|
||||
await h({ data: { id: BigInt(99) }, session: { user: { id: "1" } } }),
|
||||
).toBe("ok");
|
||||
it("delegates deletion and preserves action result", async () => {
|
||||
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
|
||||
await expect(handler({ data: { id: 99n }, session: { user: { id: "1" } }, requestId: "delete" })).resolves.toBe("ok");
|
||||
expect(execute).toHaveBeenCalledWith(expect.objectContaining({ correlationId: "delete" }), "ad.change", { action: "delete", id: "99" });
|
||||
});
|
||||
|
||||
it("throws ActionError when not found", async () => {
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 0 }]);
|
||||
const h = deleteAd as unknown as (ctx: {
|
||||
data: { id: bigint };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
await expect(
|
||||
h({ data: { id: BigInt(999) }, session: { user: { id: "1" } } }),
|
||||
).rejects.toThrow(ActionError);
|
||||
it("preserves not-found ActionError", async () => {
|
||||
execute.mockResolvedValue({ ok: false, error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" }, correlationId: "legacy" });
|
||||
const handler = deleteAd as unknown as (ctx: unknown) => Promise<string>;
|
||||
await expect(handler({ data: { id: 999n }, session: { user: { id: "1" } }, requestId: "missing" })).rejects.toThrow(ActionError);
|
||||
});
|
||||
});
|
||||
+30
-79
@@ -1,48 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteAds } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
// table; it only stores an image URL rendered in the site layout/widgets.
|
||||
|
||||
export async function createAd(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const image = String(formData.get("image") ?? "").normalize("NFC").trim().slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const [result] = (await db.insert(WebsiteAds).values({
|
||||
image,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_create",
|
||||
description: `Created advertisement #${result.insertId} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: createAd", {
|
||||
action: "createAd",
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"ad.change",
|
||||
{ action: "create", image },
|
||||
);
|
||||
if (!result.ok) {
|
||||
logger.error("Action failed: createAd", { action: "createAd", error: result.error.messageKey });
|
||||
revalidatePath("/admin/ads");
|
||||
return;
|
||||
}
|
||||
@@ -52,66 +34,35 @@ export async function createAd(formData: FormData): Promise<void> {
|
||||
export async function updateAd(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/.test(raw)) return;
|
||||
const id = BigInt(raw);
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!/^\d+$/u.test(raw)) return;
|
||||
const image = String(formData.get("image") ?? "").normalize("NFC").trim().slice(0, 255);
|
||||
if (!image) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteAds)
|
||||
.set({ image, updatedAt: new Date() })
|
||||
.where(eq(WebsiteAds.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "ad_update",
|
||||
description: `Updated advertisement #${id} (${image})`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("Action failed: updateAd", {
|
||||
action: "updateAd",
|
||||
id: Number(id),
|
||||
error: err instanceof Error ? err.message : "DB error",
|
||||
});
|
||||
revalidatePath(`/admin/ads/${id}`);
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"ad.change",
|
||||
{ action: "update", id: raw, image },
|
||||
);
|
||||
if (!result.ok) {
|
||||
logger.error("Action failed: updateAd", { action: "updateAd", id: Number(raw), error: result.error.messageKey });
|
||||
revalidatePath("/admin/ads/" + raw);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/ads");
|
||||
}
|
||||
|
||||
const deleteAdInput = z.object({
|
||||
id: z
|
||||
.union([z.string(), z.number(), z.bigint()])
|
||||
.transform((v) => BigInt(String(v))),
|
||||
id: z.union([z.string(), z.number(), z.bigint()]).transform((value) => BigInt(String(value))),
|
||||
});
|
||||
|
||||
export const deleteAd = adminAction(
|
||||
{ permission: PERMS.PAGES_EDIT, schema: deleteAdInput },
|
||||
async (ctx) => {
|
||||
const id = ctx.data.id;
|
||||
try {
|
||||
const [result] = (await db
|
||||
.delete(WebsiteAds)
|
||||
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
|
||||
if (!result.affectedRows) {
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ActionError) throw err;
|
||||
throw new ActionError("Advertisement not found");
|
||||
}
|
||||
await logStaffActivity({
|
||||
staffId: Number(ctx.session.user.id),
|
||||
action: "ad_delete",
|
||||
description: `Deleted advertisement #${id}`,
|
||||
targetType: "website_ad",
|
||||
targetId: Number(id),
|
||||
});
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"ad.change",
|
||||
{ action: "delete", id: ctx.data.id.toString() },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Advertisement not found");
|
||||
revalidatePath("/admin/ads");
|
||||
return actionOk();
|
||||
},
|
||||
|
||||
@@ -1,120 +1,61 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
db,
|
||||
WebsiteArticleComments,
|
||||
WebsiteArticleReactions,
|
||||
WebsiteArticles,
|
||||
} from "@/lib/db";
|
||||
import { slugify } from "@/lib/format";
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
const base = slugify(title);
|
||||
let slug = base;
|
||||
let n = 2;
|
||||
for (;;) {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteArticles.id })
|
||||
.from(WebsiteArticles)
|
||||
.where(eq(WebsiteArticles.slug, slug))
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
slug = `${base}-${n++}`;
|
||||
}
|
||||
function articleInput(formData: FormData) {
|
||||
return {
|
||||
title: String(formData.get("title") ?? "").normalize("NFC").trim(),
|
||||
shortStory: String(formData.get("shortStory") ?? "").normalize("NFC").trim(),
|
||||
fullStory: String(formData.get("fullStory") ?? "").normalize("NFC").trim(),
|
||||
image: String(formData.get("image") ?? "").normalize("NFC").trim(),
|
||||
slug: String(formData.get("slug") ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createArticle(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const shortStory = String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const fullStory = String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const image = String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
await db.insert(WebsiteArticles).values({
|
||||
slug: rawSlug ? await uniqueSlug(rawSlug) : await uniqueSlug(title),
|
||||
title: title.slice(0, 255),
|
||||
shortStory: shortStory.slice(0, 255),
|
||||
fullStory,
|
||||
image: image.slice(0, 255),
|
||||
userId: staff.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
} catch {
|
||||
// Database error — re-render unchanged with error.
|
||||
redirect(
|
||||
"/admin/articles/new?error=Database error while creating article. Please try again.",
|
||||
);
|
||||
const input = articleInput(formData);
|
||||
if (!input.title) return;
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"article.change",
|
||||
{ action: "create", ...input },
|
||||
);
|
||||
if (!result.ok) {
|
||||
redirect("/admin/articles/new?error=Database error while creating article. Please try again.");
|
||||
}
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
|
||||
export async function updateArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
const rawSlug = String(formData.get("slug") ?? "").trim();
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteArticles)
|
||||
.set({
|
||||
title: String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
fullStory: String(formData.get("fullStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim(),
|
||||
image: String(formData.get("image") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteArticles.id, id));
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Update failed");
|
||||
}
|
||||
revalidatePath(`/admin/articles/${id}`);
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"article.change",
|
||||
{ action: "update", id, ...articleInput(formData) },
|
||||
);
|
||||
if (!result.ok) redirect("/admin/articles?error=Update failed");
|
||||
revalidatePath("/admin/articles/" + id);
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
|
||||
export async function deleteArticle(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = BigInt(String(formData.get("id")));
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(WebsiteArticleReactions)
|
||||
.where(eq(WebsiteArticleReactions.articleId, id));
|
||||
await tx
|
||||
.delete(WebsiteArticleComments)
|
||||
.where(eq(WebsiteArticleComments.articleId, id));
|
||||
await tx.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/articles?error=Delete failed");
|
||||
}
|
||||
const staff = await requirePermission(PERMS.NEWS_EDIT);
|
||||
const id = String(formData.get("id") ?? "");
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"article.change",
|
||||
{ action: "delete", id },
|
||||
);
|
||||
if (!result.ok) redirect("/admin/articles?error=Delete failed");
|
||||
redirect("/admin/articles");
|
||||
}
|
||||
@@ -1,76 +1,49 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, EmailTemplates } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!name || !subject || !body) return;
|
||||
function templateInput(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||
subject: String(formData.get("subject") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||
body: String(formData.get("body") ?? "").normalize("NFC"),
|
||||
variables: String(formData.get("variables") ?? "").normalize("NFC").trim(),
|
||||
isActive: formData.get("isActive") != null,
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(EmailTemplates).values({
|
||||
name,
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
});
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const input = templateInput(formData);
|
||||
if (!input.name || !input.subject || !input.body) return;
|
||||
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "create", ...input });
|
||||
if (!result.ok) throw new Error("Email template creation failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const raw = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!raw) return;
|
||||
let id: bigint;
|
||||
try {
|
||||
id = BigInt(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const subject = String(formData.get("subject") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
const body = String(formData.get("body") ?? "").normalize("NFC");
|
||||
const variablesRaw = String(formData.get("variables") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const isActive = formData.get("isActive") != null;
|
||||
if (!subject || !body) return;
|
||||
|
||||
await db
|
||||
.update(EmailTemplates)
|
||||
.set({
|
||||
subject,
|
||||
body,
|
||||
variables: variablesRaw || null,
|
||||
isActive,
|
||||
})
|
||||
.where(eq(EmailTemplates.id, id));
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC");
|
||||
if (!/^\d+$/u.test(id)) return;
|
||||
const input = templateInput(formData);
|
||||
if (!input.subject || !input.body) return;
|
||||
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "update", id, ...input });
|
||||
if (!result.ok) throw new Error("Email template update failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
|
||||
export async function deleteEmailTemplate(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
await db.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "email-template.change", { action: "delete", id });
|
||||
if (!result.ok) throw new Error("Email template deletion failed");
|
||||
revalidatePath("/admin/email-templates");
|
||||
}
|
||||
@@ -1,77 +1,41 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
createHelpQuestion,
|
||||
deleteHelpQuestion,
|
||||
updateHelpQuestion,
|
||||
} from "./admin-help";
|
||||
|
||||
const { insertValues, updateWhere, deleteWhere } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 5 }]);
|
||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
return { insertValues, updateWhere, deleteWhere };
|
||||
});
|
||||
import { createHelpQuestion, deleteHelpQuestion, updateHelpQuestion } from "./admin-help";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
contentMutationService: { execute },
|
||||
createContentMutationInvocation: (actor: { id: number }, correlationId: string) => ({ expectedActorId: actor.id, correlationId, legacy: true }),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
},
|
||||
WebsiteHelpCenterCategories: { id: "id" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string | null>) => ({
|
||||
get: (key: string) => (key in data ? data[key] : null),
|
||||
});
|
||||
const fakeForm = (data: Record<string, string | null>) => ({ get: (key: string) => key in data ? data[key] : null });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 5 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "5" } }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("createHelpQuestion", () => {
|
||||
it("creates a help question and redirects", async () => {
|
||||
await createHelpQuestion(
|
||||
fakeForm({
|
||||
name: "FAQ",
|
||||
content: "<p>Answer</p>",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(insertValues).toHaveBeenCalled();
|
||||
describe("Content help legacy wrappers", () => {
|
||||
it("delegates create and redirects", async () => {
|
||||
await createHelpQuestion(fakeForm({ name: "FAQ", content: "<p>Answer</p>" }) as FormData);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "create", name: "FAQ" }));
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateHelpQuestion", () => {
|
||||
it("updates and redirects", async () => {
|
||||
await updateHelpQuestion(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
content: "New",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
it("delegates update and redirects", async () => {
|
||||
await updateHelpQuestion(fakeForm({ id: "42", name: "Updated", content: "New" }) as FormData);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", expect.objectContaining({ action: "update", id: "42" }));
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteHelpQuestion", () => {
|
||||
it("deletes and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
it("delegates delete and redirects", async () => {
|
||||
await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "help-question.change", { action: "delete", id: "42" });
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
|
||||
});
|
||||
});
|
||||
+32
-104
@@ -1,63 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteHelpCenterCategories } from "@/lib/db";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for help-center FAQ entries (website_help_center_categories). Each entry
|
||||
// is a titled content block with an optional image and call-to-action button.
|
||||
|
||||
function parsePosition(value: FormDataEntryValue | null): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 1;
|
||||
function helpInput(formData: FormData) {
|
||||
return {
|
||||
name: sanitizeField(formData.get("name")),
|
||||
content: canonicalize(String(formData.get("content") ?? "")),
|
||||
position: Number(formData.get("position")) > 0 ? Math.floor(Number(formData.get("position"))) : 1,
|
||||
imageUrl: sanitizeField(formData.get("imageUrl")),
|
||||
buttonText: sanitizeField(formData.get("buttonText")),
|
||||
buttonUrl: sanitizeField(formData.get("buttonUrl")),
|
||||
buttonColor: sanitizeField(formData.get("buttonColor"), 16) || "#eeb425",
|
||||
buttonBorderColor: sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15",
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const name = sanitizeField(formData.get("name"));
|
||||
const content = canonicalize(String(formData.get("content") ?? ""));
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||
const buttonColor =
|
||||
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_create",
|
||||
description: `Created help-center entry #${result.insertId} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch {
|
||||
// Unique name collision or DB error — re-render unchanged with error.
|
||||
const input = helpInput(formData);
|
||||
if (!input.name || !input.content) return;
|
||||
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "create", ...input });
|
||||
if (!result.ok) {
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect(
|
||||
"/admin/help-questions/new?error=Unique name collision or database error. Please try again.",
|
||||
);
|
||||
redirect("/admin/help-questions/new?error=Unique name collision or database error. Please try again.");
|
||||
}
|
||||
revalidatePath("/admin/help-questions");
|
||||
redirect("/admin/help-questions");
|
||||
@@ -65,46 +40,13 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
|
||||
export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
const name = sanitizeField(formData.get("name"));
|
||||
const content = canonicalize(String(formData.get("content") ?? ""));
|
||||
if (!name || !content) return;
|
||||
|
||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||
const buttonColor =
|
||||
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteHelpCenterCategories)
|
||||
.set({
|
||||
name,
|
||||
content,
|
||||
position: parsePosition(formData.get("position")),
|
||||
imageUrl: imageUrl || null,
|
||||
buttonText: buttonText || null,
|
||||
buttonUrl: buttonUrl || null,
|
||||
buttonColor,
|
||||
buttonBorderColor,
|
||||
smallBox: formData.get("smallBox") != null,
|
||||
})
|
||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_update",
|
||||
description: `Updated help-center entry #${id} (${name})`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found, unique collision, or DB error — ignore.
|
||||
revalidatePath(`/admin/help-questions/${id}`);
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
const input = helpInput(formData);
|
||||
if (!input.name || !input.content) return;
|
||||
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "update", id, ...input });
|
||||
if (!result.ok) {
|
||||
revalidatePath("/admin/help-questions/" + id);
|
||||
return;
|
||||
}
|
||||
redirect("/admin/help-questions");
|
||||
@@ -112,22 +54,8 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
|
||||
export async function deleteHelpQuestion(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = formPositiveBigInt(formData, "id");
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteHelpCenterCategories)
|
||||
.where(eq(WebsiteHelpCenterCategories.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "help_delete",
|
||||
description: `Deleted help-center entry #${id}`,
|
||||
targetType: "help_center_category",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Not found or DB error — ignore.
|
||||
}
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "help-question.change", { action: "delete", id });
|
||||
redirect("/admin/help-questions");
|
||||
}
|
||||
@@ -1,59 +1,39 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { deleteMedia, uploadMedia, uploadMediaAndReturn } from "./admin-media";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/media-storage", () => {
|
||||
const root = path.join("/tmp", "nexst-test-media");
|
||||
return {
|
||||
MEDIA_ROOT: root,
|
||||
resolveMediaPath: vi.fn((name: string) => path.join(root, name)),
|
||||
};
|
||||
});
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||
execute.mockResolvedValue({ ok: true, data: { before: null, after: { name: "photo.png" }, output: { url: "/api/media/photo.png" } }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("uploadMedia", () => {
|
||||
it("returns error when no file provided", async () => {
|
||||
const result = await uploadMedia(new FormData());
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("No file provided");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadMediaAndReturn", () => {
|
||||
it("returns empty string when no file", async () => {
|
||||
describe("Content media legacy wrappers", () => {
|
||||
it("retains no-file validation", async () => {
|
||||
expect(await uploadMedia(new FormData())).toEqual({ ok: false, error: "No file provided" });
|
||||
expect(await uploadMediaAndReturn(new FormData())).toBe("");
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMedia", () => {
|
||||
it("deletes media file and revalidates", async () => {
|
||||
it("delegates a valid upload and preserves both result shapes", async () => {
|
||||
const file = new File(["bytes"], "photo.png", { type: "image/png" });
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
expect(await uploadMedia(form)).toEqual({ ok: true });
|
||||
expect(await uploadMediaAndReturn(form)).toBe("/api/media/photo.png");
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.upload", { file });
|
||||
});
|
||||
it("delegates deletion and preserves revalidation", async () => {
|
||||
await deleteMedia("photo.png");
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "media.delete", { filename: "photo.png" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/api/media");
|
||||
});
|
||||
|
||||
it("skips deletion when path is outside media root", async () => {
|
||||
vi.mocked(resolveMediaPath).mockReturnValue("/etc/passwd");
|
||||
await deleteMedia("../../../etc/passwd");
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/media");
|
||||
});
|
||||
});
|
||||
+44
-60
@@ -1,83 +1,67 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
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 { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
|
||||
export async function uploadMedia(
|
||||
formData: FormData,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { ok: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE)
|
||||
return { ok: false, error: "File too large (max 5MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
|
||||
};
|
||||
function mediaFile(formData: FormData): File | null {
|
||||
const value = formData.get("file");
|
||||
return value && typeof value === "object" ? (value as File) : null;
|
||||
}
|
||||
|
||||
const baseDir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) throw new Error("Invalid path");
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
function validateMediaFile(file: File | null): string | null {
|
||||
if (!file || file.size === 0) return "No file provided";
|
||||
if (file.size > MAX_SIZE) return "File too large (max 5MB)";
|
||||
if (!ALLOWED.includes(file.type)) return "Invalid file type. Allowed: PNG, JPEG, GIF, WebP";
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function uploadMedia(formData: FormData): Promise<{ ok: boolean; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const file = mediaFile(formData);
|
||||
const error = validateMediaFile(file);
|
||||
if (error) return { ok: false, error };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"media.upload",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) throw new Error("Media upload failed");
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteMedia(name: string): Promise<void> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
const baseDir = MEDIA_ROOT;
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return;
|
||||
try {
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"media.delete",
|
||||
{ filename: name },
|
||||
);
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
}
|
||||
|
||||
export async function uploadMediaAndReturn(
|
||||
formData: FormData,
|
||||
): Promise<string> {
|
||||
await requirePermission(PERMS.PAGES_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return "";
|
||||
if (file.size > MAX_SIZE) return "";
|
||||
if (!ALLOWED.includes(file.type)) return "";
|
||||
|
||||
const baseDir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const bytes = await file.arrayBuffer();
|
||||
const filePath = resolveMediaPath(name);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) return "";
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, Buffer.from(bytes));
|
||||
|
||||
export async function uploadMediaAndReturn(formData: FormData): Promise<string> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const file = mediaFile(formData);
|
||||
if (validateMediaFile(file)) return "";
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"media.upload",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return "";
|
||||
revalidatePath("/api/media");
|
||||
revalidatePath("/admin/media");
|
||||
return `/api/media/${name}`;
|
||||
return typeof result.data.output?.url === "string" ? result.data.output.url : "";
|
||||
}
|
||||
@@ -2,14 +2,9 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { actionOk, adminAction } from "@/lib/foundation/action";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const schema = z.object({
|
||||
groupOrder: z.array(z.string()),
|
||||
@@ -26,16 +21,12 @@ export const saveAdminNavConfig = adminAction(
|
||||
rateLimitMax: 30,
|
||||
},
|
||||
async (ctx) => {
|
||||
const config: AdminNavConfig = {
|
||||
groupOrder: ctx.data.groupOrder,
|
||||
hiddenGroups: ctx.data.hiddenGroups,
|
||||
hiddenItems: ctx.data.hiddenItems,
|
||||
itemOrder: ctx.data.itemOrder,
|
||||
};
|
||||
await siteSettings.update(
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
serializeAdminNavConfig(config),
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"navigation.update",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new Error("Navigation update failed");
|
||||
revalidatePath("/admin", "layout");
|
||||
revalidatePath("/admin/menu");
|
||||
return actionOk({ saved: true });
|
||||
|
||||
@@ -2,71 +2,29 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { deletePhoto } from "./admin-photos";
|
||||
|
||||
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
|
||||
const limit = vi.fn();
|
||||
const whereSelect = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where: whereSelect }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
const whereDelete = vi.fn();
|
||||
const deleteFn = vi.fn(() => ({ where: whereDelete }));
|
||||
return { select, deleteFn, limit, whereDelete, whereSelect, from };
|
||||
});
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/admin/photo-files", () => ({
|
||||
tryRemoveLocalPhotoFile: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: (...args) => select(...args),
|
||||
delete: (...args) => deleteFn(...args),
|
||||
},
|
||||
CameraWeb: { id: "id", url: "url" },
|
||||
}));
|
||||
|
||||
const fakeForm = (data) => ({
|
||||
get: (key) => data[key] ?? null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
|
||||
whereDelete.mockResolvedValue(undefined);
|
||||
vi.mocked(requirePermission).mockResolvedValue({
|
||||
id: 1,
|
||||
rank: 7,
|
||||
username: "admin",
|
||||
});
|
||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||
execute.mockResolvedValue({ ok: true, data: { before: { id: 42 }, after: null }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("deletePhoto", () => {
|
||||
it("deletes a photo and revalidates", async () => {
|
||||
await deletePhoto(fakeForm({ id: "42" }));
|
||||
expect(select).toHaveBeenCalled();
|
||||
expect(deleteFn).toHaveBeenCalled();
|
||||
expect(tryRemoveLocalPhotoFile).toHaveBeenCalledWith("/uploads/cam/42.png");
|
||||
expect(logStaffActivity).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "photo_delete",
|
||||
targetId: 42,
|
||||
}),
|
||||
);
|
||||
it("delegates deletion and preserves both revalidations", async () => {
|
||||
await deletePhoto({ get: (key) => key === "id" ? "42" : null });
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "photo.delete", { id: 42 });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/photos");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/photos");
|
||||
});
|
||||
|
||||
it("returns early when id is not positive", async () => {
|
||||
await deletePhoto(fakeForm({ id: "0" }));
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
await deletePhoto({ get: () => "0" });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import { CameraWeb, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
export async function deletePhoto(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = Number(formData.get("id"));
|
||||
if (!(id > 0)) return;
|
||||
|
||||
const [row] = await db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.where(eq(CameraWeb.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (row) {
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
await tryRemoveLocalPhotoFile(row.url);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "photo_delete",
|
||||
description: `Deleted camera photo #${id}`,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
}
|
||||
|
||||
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "photo.delete", { id });
|
||||
revalidatePath("/admin/photos");
|
||||
revalidatePath("/photos");
|
||||
}
|
||||
+31
-109
@@ -2,133 +2,55 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { createTag, deleteTag, updateTag } from "./admin-tags";
|
||||
|
||||
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
|
||||
() => {
|
||||
const insertValues = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const updateWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const deleteWhere = vi.fn().mockResolvedValue([{ affectedRows: 1 }]);
|
||||
const transaction = vi.fn(async (fn) =>
|
||||
fn({
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
}),
|
||||
);
|
||||
return { insertValues, updateWhere, deleteWhere, transaction };
|
||||
},
|
||||
);
|
||||
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute }, createContentMutationInvocation: (actor, correlationId) => ({ expectedActorId: actor.id, correlationId, legacy: true }) }));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { PAGES_EDIT: "pages.edit" } }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
transaction,
|
||||
},
|
||||
Tags: { id: "id", name: "name", backgroundColor: "backgroundColor" },
|
||||
Taggables: { tagId: "tagId" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
const staff = { id: 1, rank: 7, username: "admin" };
|
||||
const fakeForm = (data: Record<string, string>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
});
|
||||
|
||||
const form = (data) => ({ get: (key) => data[key] ?? null });
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||
transaction.mockImplementation(async (fn) =>
|
||||
fn({
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
}),
|
||||
);
|
||||
vi.mocked(requirePermission).mockResolvedValue({ id: 1, rank: 7, username: "admin" });
|
||||
execute.mockResolvedValue({ ok: true, data: { before: null, after: { id: "1" } }, correlationId: "legacy" });
|
||||
});
|
||||
|
||||
describe("createTag", () => {
|
||||
it("creates a tag and revalidates", async () => {
|
||||
await createTag(
|
||||
fakeForm({
|
||||
name: "News",
|
||||
backgroundColor: "#ff0000",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "News" }),
|
||||
);
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
describe("Content tag legacy wrappers", () => {
|
||||
it("delegates create with normalized values", async () => {
|
||||
await createTag(form({ name: "News", backgroundColor: "#ff0000" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "create", name: "News", backgroundColor: "#ff0000" });
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when name is empty", async () => {
|
||||
await createTag(fakeForm({ name: "" }) as unknown as FormData);
|
||||
expect(insertValues).not.toHaveBeenCalled();
|
||||
it("uses the legacy default color", async () => {
|
||||
await createTag(form({ name: "Test" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ backgroundColor: "#888888" }));
|
||||
});
|
||||
|
||||
it("uses default color when not provided", async () => {
|
||||
await createTag(fakeForm({ name: "Test" }) as unknown as FormData);
|
||||
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ backgroundColor: "#888888" }),
|
||||
);
|
||||
it("returns early for an empty name", async () => {
|
||||
await createTag(form({ name: "" }));
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles db error gracefully", async () => {
|
||||
insertValues.mockRejectedValue(new Error("DB error"));
|
||||
|
||||
await expect(
|
||||
createTag(fakeForm({ name: "News" }) as unknown as FormData),
|
||||
).resolves.toBeUndefined();
|
||||
it("revalidates after a fail-soft dependency result", async () => {
|
||||
execute.mockResolvedValue({ ok: false, error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "errors.housekeeping.dependencyUnavailable" }, correlationId: "legacy" });
|
||||
await createTag(form({ name: "News" }));
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTag", () => {
|
||||
it("updates a tag and revalidates", async () => {
|
||||
await updateTag(
|
||||
fakeForm({
|
||||
id: "42",
|
||||
name: "Updated",
|
||||
backgroundColor: "#00ff00",
|
||||
}) as unknown as FormData,
|
||||
);
|
||||
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
it("delegates update", async () => {
|
||||
await updateTag(form({ id: "42", name: "Updated", backgroundColor: "#00ff00" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", expect.objectContaining({ action: "update", id: "42" }));
|
||||
});
|
||||
|
||||
it("returns early when id is invalid", async () => {
|
||||
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData);
|
||||
expect(updateWhere).not.toHaveBeenCalled();
|
||||
it("rejects invalid update id or name", async () => {
|
||||
await updateTag(form({ id: "", name: "Test" }));
|
||||
await updateTag(form({ id: "42", name: "" }));
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when name is empty after update", async () => {
|
||||
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData);
|
||||
expect(updateWhere).not.toHaveBeenCalled();
|
||||
it("delegates delete", async () => {
|
||||
await deleteTag(form({ id: "42" }));
|
||||
expect(execute).toHaveBeenCalledWith(expect.anything(), "tag.change", { action: "delete", id: "42" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTag", () => {
|
||||
it("deletes a tag and its taggables", async () => {
|
||||
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData);
|
||||
|
||||
expect(transaction).toHaveBeenCalled();
|
||||
expect(deleteWhere).toHaveBeenCalled();
|
||||
expect(logStaffActivity).toHaveBeenCalled();
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
|
||||
});
|
||||
|
||||
it("returns early when id is invalid", async () => {
|
||||
await deleteTag(fakeForm({ id: "" }) as unknown as FormData);
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
it("rejects invalid delete id", async () => {
|
||||
await deleteTag(form({ id: "" }));
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+21
-91
@@ -1,113 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
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 { db, Taggables, Tags } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a FormData field into a positive BigInt id, or null when invalid. */
|
||||
function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
if (typeof raw !== "string" || raw.trim() === "") return null;
|
||||
try {
|
||||
const id = BigInt(raw.trim());
|
||||
return id > 0n ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
function tagInput(formData: FormData) {
|
||||
return {
|
||||
name: String(formData.get("name") ?? "").trim().slice(0, 255),
|
||||
backgroundColor: String(formData.get("backgroundColor") ?? "").trim().slice(0, 10) || "#888888",
|
||||
};
|
||||
}
|
||||
|
||||
function str(raw: FormDataEntryValue | null): string {
|
||||
return typeof raw === "string" ? raw : "";
|
||||
}
|
||||
|
||||
/** Normalise a hex-ish colour into the 10-char background_color column. */
|
||||
function normaliseColor(raw: string): string {
|
||||
const v = raw.trim().slice(0, 10);
|
||||
return v || "#888888";
|
||||
}
|
||||
|
||||
// ── Tags CRUD (tags + taggables, AtomCMS article tags/categories) ──────────
|
||||
|
||||
export async function createTag(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
if (!name) return;
|
||||
|
||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const [result] = (await db.insert(Tags).values({
|
||||
name,
|
||||
backgroundColor,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_create",
|
||||
description: `Created tag "${name}" (#${result.insertId})`,
|
||||
targetType: "tag",
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch {
|
||||
// Fail soft — DB unavailable or duplicate.
|
||||
}
|
||||
const input = tagInput(formData);
|
||||
if (!input.name) return;
|
||||
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "create", ...input });
|
||||
revalidatePath("/admin/tags");
|
||||
}
|
||||
|
||||
export async function updateTag(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
const name = str(formData.get("name")).trim().slice(0, 255);
|
||||
const backgroundColor = normaliseColor(str(formData.get("backgroundColor")));
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(Tags)
|
||||
.set({ name, backgroundColor, updatedAt: new Date() })
|
||||
.where(eq(Tags.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_update",
|
||||
description: `Updated tag #${id} → "${name}"`,
|
||||
targetType: "tag",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Row may be gone; ignore.
|
||||
}
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
const input = tagInput(formData);
|
||||
if (!input.name) return;
|
||||
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "update", id, ...input });
|
||||
revalidatePath("/admin/tags");
|
||||
}
|
||||
|
||||
export async function deleteTag(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = parseId(formData.get("id"));
|
||||
if (id === null) return;
|
||||
|
||||
try {
|
||||
// Remove the tag and any taggable links pointing at it.
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(Taggables).where(eq(Taggables.tagId, id));
|
||||
await tx.delete(Tags).where(eq(Tags.id, id));
|
||||
});
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "tag_delete",
|
||||
description: `Deleted tag #${id}`,
|
||||
targetType: "tag",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
// Already deleted; ignore.
|
||||
}
|
||||
const id = String(formData.get("id") ?? "").trim();
|
||||
if (!/^[1-9]\d*$/u.test(id)) return;
|
||||
await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "tag.change", { action: "delete", id });
|
||||
revalidatePath("/admin/tags");
|
||||
}
|
||||
+32
-180
@@ -2,214 +2,66 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||
import {
|
||||
deleteCustomThemeStore,
|
||||
getCustomTheme,
|
||||
snapshotCurrentTheme,
|
||||
upsertCustomTheme,
|
||||
} from "@/lib/theme-custom-store";
|
||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
// Extra colour settings beyond the preset palette (buttons + links + gradients).
|
||||
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"];
|
||||
const CUSTOM_CSS_MAX = 20000;
|
||||
function formValues(formData: FormData): Record<string, string> {
|
||||
return Object.fromEntries(Array.from(formData.entries(), ([key, value]) => [key, typeof value === "string" ? value : value.name]));
|
||||
}
|
||||
|
||||
async function writeSetting(key: string, value: string): Promise<void> {
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key, value, comment: "Theme (housekeeping)" })
|
||||
.onDuplicateKeyUpdate({ set: { value } });
|
||||
async function executeTheme(operation: "theme.update" | "theme.apply-preset" | "theme.custom-change" | "theme.apply-custom", input: Record<string, unknown>) {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
return contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), operation, input);
|
||||
}
|
||||
|
||||
export async function saveTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
|
||||
try {
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
const bag: Record<string, string> = {};
|
||||
for (const key of THEME_COLOR_KEYS) {
|
||||
const dbKey = settingKey(key, mode);
|
||||
const raw = String(formData.get(dbKey) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
|
||||
}
|
||||
const fixed = ensureReadableThemeColors(bag);
|
||||
for (const [key, value] of Object.entries(fixed)) {
|
||||
await writeSetting(
|
||||
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
const ADMIN_KEYS = [
|
||||
"admin_canvas",
|
||||
"admin_surface",
|
||||
"admin_text",
|
||||
"admin_text_muted",
|
||||
"admin_border",
|
||||
"admin_sidebar_bg",
|
||||
] as const;
|
||||
const adminBag: Record<string, string> = {};
|
||||
for (const key of ADMIN_KEYS) {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
|
||||
}
|
||||
const adminFixed = ensureReadableThemeColors(adminBag);
|
||||
for (const [key, value] of Object.entries(adminFixed)) {
|
||||
await writeSetting(key, value);
|
||||
}
|
||||
|
||||
const radius = String(formData.get("border_radius") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(radius)) await writeSetting("border_radius", radius);
|
||||
|
||||
// Typography
|
||||
const font = String(formData.get("font_family") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (font in FONTS) await writeSetting("font_family", font);
|
||||
for (const key of HEADING_KEYS) {
|
||||
const v = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (/^\d{1,3}$/.test(v)) await writeSetting(key, v);
|
||||
}
|
||||
|
||||
// Raw custom CSS (staff-trusted; length-capped, ThemeVars injects it as-is).
|
||||
if (formData.has("custom_css")) {
|
||||
const cssRaw = String(formData.get("custom_css") ?? "")
|
||||
.normalize("NFC")
|
||||
.slice(0, CUSTOM_CSS_MAX);
|
||||
await writeSetting("custom_css", cssRaw);
|
||||
}
|
||||
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_update",
|
||||
description: "Updated theme settings",
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore — page re-renders current state
|
||||
}
|
||||
const result = await executeTheme("theme.update", { values: formValues(formData) });
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
redirect("/admin/theme?saved=1");
|
||||
}
|
||||
|
||||
export async function applyPreset(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const name = String(formData.get("preset") ?? "").normalize("NFC");
|
||||
// eslint-disable-next-line security/detect-object-injection -- guarded by null check below
|
||||
const preset = PRESETS[name];
|
||||
if (!preset) redirect("/admin/theme");
|
||||
|
||||
try {
|
||||
for (const [key, value] of presetSettings(preset))
|
||||
await writeSetting(key, value);
|
||||
await writeSetting("theme_preset", name);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied theme preset "${name}"`,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect(`/admin/theme?preset=${encodeURIComponent(name)}`);
|
||||
const result = await executeTheme("theme.apply-preset", { preset: name });
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
redirect(result.ok ? "/admin/theme?preset=" + encodeURIComponent(name) : "/admin/theme");
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC").trim();
|
||||
if (!name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
await upsertCustomTheme(name, snapshot);
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Saved custom theme "${name}"`,
|
||||
});
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await executeTheme("theme.custom-change", { action: "create", name });
|
||||
if (result.ok) revalidatePath("/admin/theme");
|
||||
redirect("/admin/theme?savedTheme=1");
|
||||
}
|
||||
|
||||
export async function applyCustomTheme(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
const theme = await getCustomTheme(id);
|
||||
if (!theme) redirect("/admin/theme");
|
||||
try {
|
||||
for (const [key, value] of Object.entries(theme.settings)) {
|
||||
if (value) await writeSetting(key, value);
|
||||
}
|
||||
await writeSetting("theme_preset", theme.name);
|
||||
siteSettings.reload();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "theme_preset",
|
||||
description: `Applied custom theme "${theme.name}"`,
|
||||
});
|
||||
revalidatePath("/", "layout");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
redirect(`/admin/theme?theme=${encodeURIComponent(theme.name)}`);
|
||||
const result = await executeTheme("theme.apply-custom", { id });
|
||||
if (result.ok) revalidatePath("/", "layout");
|
||||
const name = result.ok && typeof result.data.output?.name === "string" ? result.data.output.name : "";
|
||||
redirect(result.ok ? "/admin/theme?theme=" + encodeURIComponent(name) : "/admin/theme");
|
||||
}
|
||||
|
||||
export async function renameCustomTheme(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const name = String(formData.get("name") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
const name = String(formData.get("name") ?? "").normalize("NFC").trim();
|
||||
if (!id || !name) redirect("/admin/theme");
|
||||
const snapshot = await snapshotCurrentTheme();
|
||||
try {
|
||||
await upsertCustomTheme(name, snapshot, id);
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await executeTheme("theme.custom-change", { action: "rename", id, name });
|
||||
if (result.ok) revalidatePath("/admin/theme");
|
||||
redirect("/admin/theme?renamed=1");
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(formData: FormData): Promise<void> {
|
||||
await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const id = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
if (!id) redirect("/admin/theme");
|
||||
try {
|
||||
await deleteCustomThemeStore(id);
|
||||
revalidatePath("/admin/theme");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await executeTheme("theme.custom-change", { action: "delete", id });
|
||||
if (result.ok) revalidatePath("/admin/theme");
|
||||
redirect("/admin/theme?deletedTheme=1");
|
||||
}
|
||||
@@ -1,177 +1,49 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
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 { db, WebsiteWriteableBoxes } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// Writeable boxes (website_writeable_boxes). CMS-owned table backing the
|
||||
// content panels rendered on the public home page. Active boxes (is_active)
|
||||
// are the ones shown publicly, ordered by `position`.
|
||||
|
||||
/** Parse a non-negative Int form value, falling back to 0. */
|
||||
function reqInt(formData: FormData, key: string): number {
|
||||
const raw = String(formData.get(key) ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (raw === "") return 0;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return Math.floor(n);
|
||||
function boxInput(formData: FormData) {
|
||||
const position = Number(formData.get("position"));
|
||||
return {
|
||||
title: String(formData.get("title") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||
icon: String(formData.get("icon") ?? "").normalize("NFC").trim().slice(0, 255),
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: Number.isFinite(position) && position >= 0 ? Math.floor(position) : 0,
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the BigInt `id` form value, returning null when blank/invalid. */
|
||||
function parseId(formData: FormData): bigint | null {
|
||||
const raw = String(formData.get("id") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return BigInt(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function revalidate(): void {
|
||||
function refreshBoxes(): void {
|
||||
revalidatePath("/admin/writeable-boxes");
|
||||
// Active boxes render on the public home page (root layout).
|
||||
revalidatePath("/", "layout");
|
||||
}
|
||||
|
||||
async function executeBox(formData: FormData, action: "create" | "update" | "delete" | "toggle"): Promise<boolean> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
const id = String(formData.get("id") ?? "").normalize("NFC").trim();
|
||||
if (action !== "create" && !/^\d+$/u.test(id)) return false;
|
||||
const input = boxInput(formData);
|
||||
if ((action === "create" || action === "update") && !input.title) return false;
|
||||
const result = await contentMutationService.execute(createContentMutationInvocation(staff, createCorrelationId()), "writeable-box.change", { action, ...(id ? { id } : {}), ...input, next: formData.get("next") });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export async function createBox(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
const [result] = (await db.insert(WebsiteWriteableBoxes).values({
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_create",
|
||||
description: `Created writeable box "${title}" (#${result.insertId})`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(result.insertId),
|
||||
});
|
||||
} catch {
|
||||
// DB unavailable — swallow and re-render.
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
if (await executeBox(formData, "create")) refreshBoxes();
|
||||
}
|
||||
|
||||
export async function updateBox(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
const title = String(formData.get("title") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!title) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteWriteableBoxes)
|
||||
.set({
|
||||
title,
|
||||
icon:
|
||||
String(formData.get("icon") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255) || null,
|
||||
content: String(formData.get("content") ?? "").normalize("NFC"),
|
||||
position: reqInt(formData, "position"),
|
||||
isActive:
|
||||
String(formData.get("isActive") ?? "").normalize("NFC") === "1",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_update",
|
||||
description: `Updated writeable box #${id} ("${title}")`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
if (await executeBox(formData, "update")) refreshBoxes();
|
||||
}
|
||||
|
||||
export async function deleteBox(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteWriteableBoxes)
|
||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_delete",
|
||||
description: `Deleted writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
if (await executeBox(formData, "delete")) refreshBoxes();
|
||||
}
|
||||
|
||||
export async function toggleBox(formData: FormData): Promise<void> {
|
||||
const staff = await requirePermission(PERMS.PAGES_EDIT);
|
||||
|
||||
const id = parseId(formData);
|
||||
if (id == null) return;
|
||||
|
||||
// `next` carries the desired state ("1" to activate, anything else to hide).
|
||||
const next = String(formData.get("next") ?? "").normalize("NFC") === "1";
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(WebsiteWriteableBoxes)
|
||||
.set({ isActive: next, updatedAt: new Date() })
|
||||
.where(eq(WebsiteWriteableBoxes.id, id));
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "writeable_box_toggle",
|
||||
description: `${next ? "Activated" : "Hid"} writeable box #${id}`,
|
||||
targetType: "writeable_box",
|
||||
targetId: Number(id),
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
revalidate();
|
||||
if (await executeBox(formData, "toggle")) refreshBoxes();
|
||||
}
|
||||
+18
-41
@@ -1,13 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { z } from "zod";
|
||||
import { db, WebsiteBanner } from "@/lib/db";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
|
||||
const bannerSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
@@ -21,46 +18,31 @@ const bannerSchema = z.object({
|
||||
endDate: z.string().max(50).nullable().optional(),
|
||||
});
|
||||
|
||||
async function runBanner(ctx: { data: Record<string, unknown>; requestId: unknown; session: { user: { id: number } } }, action: "create" | "update" | "delete") {
|
||||
return contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"banner.change",
|
||||
{ action, ...ctx.data },
|
||||
);
|
||||
}
|
||||
|
||||
export const createBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
|
||||
async (ctx) => {
|
||||
const [result] = (await db
|
||||
.insert(WebsiteBanner)
|
||||
.values(ctx.data)) as unknown as [ResultSetHeader];
|
||||
const id = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_create",
|
||||
target: "WebsiteBanner",
|
||||
targetId: id,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
return actionOk({ id });
|
||||
const result = await runBanner(ctx, "create");
|
||||
if (!result.ok) throw new ActionError("Banner creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
const updateBannerInput = bannerSchema
|
||||
.partial()
|
||||
.extend({ id: z.coerce.number().int().positive() });
|
||||
const updateBannerInput = bannerSchema.partial().extend({ id: z.coerce.number().int().positive() });
|
||||
|
||||
export const updateBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: updateBannerInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteBanner.id })
|
||||
.from(WebsiteBanner)
|
||||
.where(eq(WebsiteBanner.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Banner not found");
|
||||
await db.update(WebsiteBanner).set(data).where(eq(WebsiteBanner.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_update",
|
||||
target: "WebsiteBanner",
|
||||
targetId: id,
|
||||
});
|
||||
return actionOk({ id });
|
||||
const result = await runBanner(ctx, "update");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -69,13 +51,8 @@ const deleteBannerInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteBanner = adminAction(
|
||||
{ permission: PERMS.BANNERS_EDIT, schema: deleteBannerInput },
|
||||
async (ctx) => {
|
||||
await db.delete(WebsiteBanner).where(eq(WebsiteBanner.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "banner_delete",
|
||||
target: "WebsiteBanner",
|
||||
targetId: ctx.data.id,
|
||||
});
|
||||
const result = await runBanner(ctx, "delete");
|
||||
if (!result.ok) throw new ActionError("Banner not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
// @ts-nocheck
|
||||
import { readFileSync } from "node:fs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { createAd } from "./admin-ads";
|
||||
import { createArticle } from "./admin-articles";
|
||||
import { uploadMedia } from "./admin-media";
|
||||
import { saveFavicon } from "./save-favicon";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({
|
||||
execute: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
correlationId: "legacy",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
contentMutationService: { execute },
|
||||
createContentMutationInvocation: (actor, correlationId) => ({
|
||||
expectedActorId: actor.id,
|
||||
correlationId,
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: (_options: unknown, handler: unknown) => handler,
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({
|
||||
ActionError: class ActionError extends Error {},
|
||||
actionOk: (data: unknown = {}) => ({ ok: true, data }),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: {
|
||||
NEWS_EDIT: "news.edit",
|
||||
PAGES_EDIT: "pages.edit",
|
||||
SETTINGS_EDIT: "settings.edit",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({ limit: vi.fn(async () => []) })),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(async () => [{ insertId: 1 }]),
|
||||
})),
|
||||
},
|
||||
WebsiteArticles: { id: "id", slug: "slug" },
|
||||
WebsiteAds: { id: "id" },
|
||||
WebsiteSetting: { key: "key" },
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: vi.fn(), reload: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/media-storage", () => ({
|
||||
MEDIA_ROOT: "C:\\media",
|
||||
resolveMediaPath: vi.fn((name: string) => `C:\\media\\${name}`),
|
||||
}));
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
|
||||
const staff = { id: 42, rank: 7, username: "operator" };
|
||||
const form = (data: Record<string, FormDataEntryValue>) => ({
|
||||
get: (key: string) => data[key] ?? null,
|
||||
has: (key: string) => key in data,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requirePermission).mockResolvedValue(staff as never);
|
||||
execute.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
correlationId: "legacy",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content legacy wrappers", () => {
|
||||
it("delegates article creation and preserves redirect ordering", async () => {
|
||||
await createArticle(
|
||||
form({
|
||||
title: "Launch",
|
||||
shortStory: "Summary",
|
||||
fullStory: "Body",
|
||||
image: "/image.png",
|
||||
}) as FormData,
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
||||
"article.change",
|
||||
expect.objectContaining({ action: "create", title: "Launch" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/articles");
|
||||
});
|
||||
|
||||
it("delegates ad creation and keeps the legacy void/redirect contract", async () => {
|
||||
expect(
|
||||
await createAd(form({ image: "https://example.test/ad.png" }) as FormData),
|
||||
).toBeUndefined();
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expectedActorId: 42, legacy: true }),
|
||||
"ad.change",
|
||||
expect.objectContaining({ action: "create" }),
|
||||
);
|
||||
expect(redirect).toHaveBeenCalledWith("/admin/ads");
|
||||
});
|
||||
|
||||
it("delegates media and favicon uploads while retaining public result shapes", async () => {
|
||||
const file = new File(["bytes"], "image.png", { type: "image/png" });
|
||||
const media = await uploadMedia(form({ file }) as FormData);
|
||||
const favicon = await saveFavicon(form({ file }) as FormData);
|
||||
expect(media).toEqual({ ok: true });
|
||||
expect(favicon).toEqual({ success: true, url: "/api/media/x" });
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"media.upload",
|
||||
expect.objectContaining({ file }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"favicon.save",
|
||||
expect.objectContaining({ file }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
||||
for (const path of [
|
||||
"src/actions/admin-ads.ts",
|
||||
"src/actions/admin-articles.ts",
|
||||
"src/actions/admin-email-templates.ts",
|
||||
"src/actions/admin-help.ts",
|
||||
"src/actions/admin-media.ts",
|
||||
"src/actions/admin-nav-menu.ts",
|
||||
"src/actions/admin-photos.ts",
|
||||
"src/actions/admin-tags.ts",
|
||||
"src/actions/admin-theme.ts",
|
||||
"src/actions/admin-writeable-boxes.ts",
|
||||
"src/actions/banners.ts",
|
||||
"src/actions/events.ts",
|
||||
"src/actions/polls.ts",
|
||||
"src/actions/prefixes.ts",
|
||||
"src/actions/save-favicon.ts",
|
||||
"src/actions/save-logo.ts",
|
||||
"src/actions/translations.ts",
|
||||
"src/actions/emulator.ts",
|
||||
]) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"contentMutationService",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,17 @@
|
||||
// @ts-nocheck
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const { insertValues } = vi.hoisted(() => {
|
||||
const insertValues = vi.fn(() => ({
|
||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ affectedRows: 1 }]),
|
||||
}));
|
||||
return { insertValues };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { SETTINGS_EDIT: "settings.edit" },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
},
|
||||
EmulatorSettings: { key: "key", value: "value" },
|
||||
}));
|
||||
vi.mock("@/lib/safe-action", () => ({
|
||||
adminAction: vi.fn(
|
||||
(_opts: unknown, fn: (...args: unknown[]) => unknown) => fn,
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: vi.fn(() => "ok") }));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateConfig: vi.fn() } }));
|
||||
const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => ({ ok: true, data: { before: null, after: { keys: ["key1", "key2"] } }, correlationId: "emulator" })) }));
|
||||
vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({ contentMutationService: { execute } }));
|
||||
vi.mock("@/lib/permissions", () => ({ PERMS: { SETTINGS_EDIT: "settings.edit" } }));
|
||||
vi.mock("@/lib/safe-action", () => ({ adminAction: (_options, handler) => handler }));
|
||||
vi.mock("@/lib/safe-action-shared", () => ({ actionOk: () => "ok" }));
|
||||
|
||||
describe("saveEmulatorSettings", () => {
|
||||
it("saves settings and calls rcon update", async () => {
|
||||
const handler = (await import("./emulator").then(
|
||||
(m) => m.saveEmulatorSettings,
|
||||
)) as unknown as (ctx: {
|
||||
data: { settings: Record<string, string> };
|
||||
session: { user: { id: string } };
|
||||
}) => Promise<string>;
|
||||
|
||||
const result = await handler({
|
||||
data: { settings: { key1: "val1", key2: "val2" } },
|
||||
session: { user: { id: "1" } },
|
||||
});
|
||||
|
||||
expect(insertValues).toHaveBeenCalledTimes(2);
|
||||
expect(rcon.updateConfig).toHaveBeenCalled();
|
||||
it("delegates the third translation store and preserves result shape", async () => {
|
||||
const handler = (await import("./emulator")).saveEmulatorSettings as unknown as (ctx: unknown) => Promise<string>;
|
||||
const result = await handler({ data: { settings: { key1: "val1", key2: "val2" } }, session: { user: { id: "1" } }, requestId: "emulator" });
|
||||
expect(execute).toHaveBeenCalledWith({ correlationId: "emulator", expectedActorId: 1, legacy: true }, "translation.emulator.save", { settings: { key1: "val1", key2: "val2" } });
|
||||
expect(result).toBe("ok");
|
||||
});
|
||||
});
|
||||
+8
-24
@@ -1,38 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { db, EmulatorSettings } from "@/lib/db";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
const saveEmulatorSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
const saveEmulatorSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) });
|
||||
|
||||
export const saveEmulatorSettings = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveEmulatorSettingsSchema },
|
||||
async (ctx) => {
|
||||
const entries = Object.entries(ctx.data.settings);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
await db
|
||||
.insert(EmulatorSettings)
|
||||
.values({ key, value: String(value) })
|
||||
.onDuplicateKeyUpdate({ set: { value: String(value) } });
|
||||
}
|
||||
|
||||
await rcon.updateConfig();
|
||||
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "emulator_settings_update",
|
||||
target: "EmulatorSettings",
|
||||
after: ctx.data.settings,
|
||||
});
|
||||
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"translation.emulator.save",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
+61
-125
@@ -3,18 +3,16 @@
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import {
|
||||
db,
|
||||
WebsiteEvent,
|
||||
WebsiteEventPrize,
|
||||
WebsiteEventRegistration,
|
||||
WebsiteEventType,
|
||||
WebsiteEventWinner,
|
||||
} from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
createEventSchema,
|
||||
eventPrizeSchema,
|
||||
@@ -29,16 +27,13 @@ import {
|
||||
export const createEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventTypeSchema },
|
||||
async (ctx) => {
|
||||
const [result] = await db.insert(WebsiteEventType).values(ctx.data);
|
||||
const eventTypeId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_create",
|
||||
target: "WebsiteEventType",
|
||||
targetId: eventTypeId,
|
||||
after: { name: ctx.data.name },
|
||||
});
|
||||
return actionOk({ id: eventTypeId });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event-type.change",
|
||||
{ action: "create", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event type creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -49,27 +44,13 @@ const updateEventTypeInput = eventTypeSchema.partial().extend({
|
||||
export const updateEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventTypeInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
||||
.from(WebsiteEventType)
|
||||
.where(eq(WebsiteEventType.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event type not found");
|
||||
|
||||
await db
|
||||
.update(WebsiteEventType)
|
||||
.set(data)
|
||||
.where(eq(WebsiteEventType.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_update",
|
||||
target: "WebsiteEventType",
|
||||
targetId: id,
|
||||
before: { name: existing.name },
|
||||
after: data,
|
||||
});
|
||||
return actionOk({ id });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event-type.change",
|
||||
{ action: "update", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event type not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -80,23 +61,12 @@ const deleteEventTypeInput = z.object({
|
||||
export const deleteEventType = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventTypeInput },
|
||||
async (ctx) => {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEventType.id, name: WebsiteEventType.name })
|
||||
.from(WebsiteEventType)
|
||||
.where(eq(WebsiteEventType.id, ctx.data.id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event type not found");
|
||||
|
||||
await db
|
||||
.delete(WebsiteEventType)
|
||||
.where(eq(WebsiteEventType.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_type_delete",
|
||||
target: "WebsiteEventType",
|
||||
targetId: ctx.data.id,
|
||||
before: { name: existing.name },
|
||||
});
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event-type.change",
|
||||
{ action: "delete", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event type not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -106,21 +76,13 @@ export const deleteEventType = adminAction(
|
||||
export const createEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: createEventSchema },
|
||||
async (ctx) => {
|
||||
const now = new Date();
|
||||
const [result] = await db.insert(WebsiteEvent).values({
|
||||
...ctx.data,
|
||||
hostUserId: Number(ctx.session.user.id),
|
||||
updatedAt: now,
|
||||
});
|
||||
const eventId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_create",
|
||||
target: "WebsiteEvent",
|
||||
targetId: eventId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
return actionOk({ id: eventId });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event.change",
|
||||
{ action: "create", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -131,31 +93,13 @@ const updateEventInput = updateEventSchema.extend({
|
||||
export const updateEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: updateEventInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: WebsiteEvent.id,
|
||||
title: WebsiteEvent.title,
|
||||
status: WebsiteEvent.status,
|
||||
})
|
||||
.from(WebsiteEvent)
|
||||
.where(eq(WebsiteEvent.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
|
||||
await db
|
||||
.update(WebsiteEvent)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(WebsiteEvent.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_update",
|
||||
target: "WebsiteEvent",
|
||||
targetId: id,
|
||||
before: { title: existing.title, status: existing.status },
|
||||
after: data,
|
||||
});
|
||||
return actionOk({ id });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event.change",
|
||||
{ action: "update", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -166,21 +110,12 @@ const deleteEventInput = z.object({
|
||||
export const deleteEvent = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deleteEventInput },
|
||||
async (ctx) => {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsiteEvent.id, title: WebsiteEvent.title })
|
||||
.from(WebsiteEvent)
|
||||
.where(eq(WebsiteEvent.id, ctx.data.id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Event not found");
|
||||
|
||||
await db.delete(WebsiteEvent).where(eq(WebsiteEvent.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_delete",
|
||||
target: "WebsiteEvent",
|
||||
targetId: ctx.data.id,
|
||||
before: { title: existing.title },
|
||||
});
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event.change",
|
||||
{ action: "delete", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -190,8 +125,13 @@ export const deleteEvent = adminAction(
|
||||
export const addEventPrize = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventPrizeSchema },
|
||||
async (ctx) => {
|
||||
const [result] = await db.insert(WebsiteEventPrize).values(ctx.data);
|
||||
return actionOk({ id: Number(result.insertId) });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event-prize.change",
|
||||
{ action: "create", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event prize creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -200,9 +140,12 @@ const deletePrizeInput = z.object({ id: z.coerce.number().int().positive() });
|
||||
export const deleteEventPrize = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: deletePrizeInput },
|
||||
async (ctx) => {
|
||||
await db
|
||||
.delete(WebsiteEventPrize)
|
||||
.where(eq(WebsiteEventPrize.id, ctx.data.id));
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event-prize.change",
|
||||
{ action: "delete", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event prize deletion failed");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -212,20 +155,13 @@ export const deleteEventPrize = adminAction(
|
||||
export const addEventWinner = adminAction(
|
||||
{ permission: PERMS.EVENTS_EDIT, schema: eventWinnerSchema },
|
||||
async (ctx) => {
|
||||
const [result] = await db.insert(WebsiteEventWinner).values(ctx.data);
|
||||
const winnerId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "event_winner_add",
|
||||
target: "WebsiteEventWinner",
|
||||
targetId: winnerId,
|
||||
after: {
|
||||
eventId: ctx.data.eventId,
|
||||
userId: ctx.data.userId,
|
||||
position: ctx.data.position,
|
||||
},
|
||||
});
|
||||
return actionOk({ id: winnerId });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"event-winner.add",
|
||||
ctx.data,
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Event winner creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+41
-66
@@ -3,6 +3,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import {
|
||||
db,
|
||||
WebsitePoll,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction, authAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
createPollSchema,
|
||||
pollQuestionSchema,
|
||||
@@ -25,20 +25,13 @@ import {
|
||||
export const createPoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: createPollSchema },
|
||||
async (ctx) => {
|
||||
const now = new Date();
|
||||
const [result] = await db.insert(WebsitePoll).values({
|
||||
...ctx.data,
|
||||
updatedAt: now,
|
||||
});
|
||||
const pollId = Number(result.insertId);
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "poll_create",
|
||||
target: "WebsitePoll",
|
||||
targetId: pollId,
|
||||
after: { title: ctx.data.title },
|
||||
});
|
||||
return actionOk({ id: pollId });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"poll.change",
|
||||
{ action: "create", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Poll creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -49,31 +42,13 @@ const updatePollInput = updatePollSchema.extend({
|
||||
export const updatePoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: updatePollInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: WebsitePoll.id,
|
||||
title: WebsitePoll.title,
|
||||
status: WebsitePoll.status,
|
||||
})
|
||||
.from(WebsitePoll)
|
||||
.where(eq(WebsitePoll.id, id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Poll not found");
|
||||
|
||||
await db
|
||||
.update(WebsitePoll)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(WebsitePoll.id, id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "poll_update",
|
||||
target: "WebsitePoll",
|
||||
targetId: id,
|
||||
before: { title: existing.title, status: existing.status },
|
||||
after: data,
|
||||
});
|
||||
return actionOk({ id });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"poll.change",
|
||||
{ action: "update", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Poll not found");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,21 +59,12 @@ const deletePollInput = z.object({
|
||||
export const deletePoll = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: deletePollInput },
|
||||
async (ctx) => {
|
||||
const [existing] = await db
|
||||
.select({ id: WebsitePoll.id, title: WebsitePoll.title })
|
||||
.from(WebsitePoll)
|
||||
.where(eq(WebsitePoll.id, ctx.data.id))
|
||||
.limit(1);
|
||||
if (!existing) throw new ActionError("Poll not found");
|
||||
|
||||
await db.delete(WebsitePoll).where(eq(WebsitePoll.id, ctx.data.id));
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "poll_delete",
|
||||
target: "WebsitePoll",
|
||||
targetId: ctx.data.id,
|
||||
before: { title: existing.title },
|
||||
});
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"poll.change",
|
||||
{ action: "delete", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Poll not found");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
@@ -108,8 +74,13 @@ export const deletePoll = adminAction(
|
||||
export const addPollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: pollQuestionSchema },
|
||||
async (ctx) => {
|
||||
const [result] = await db.insert(WebsitePollQuestion).values(ctx.data);
|
||||
return actionOk({ id: Number(result.insertId) });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"poll-question.change",
|
||||
{ action: "create", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Poll question creation failed");
|
||||
return actionOk({ id: Number(result.data.output?.id) });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -120,12 +91,13 @@ const updateQuestionInput = pollQuestionSchema.partial().extend({
|
||||
export const updatePollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: updateQuestionInput },
|
||||
async (ctx) => {
|
||||
const { id, ...data } = ctx.data;
|
||||
await db
|
||||
.update(WebsitePollQuestion)
|
||||
.set(data)
|
||||
.where(eq(WebsitePollQuestion.id, id));
|
||||
return actionOk({ id });
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"poll-question.change",
|
||||
{ action: "update", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Poll question update failed");
|
||||
return actionOk({ id: ctx.data.id });
|
||||
},
|
||||
);
|
||||
|
||||
@@ -136,9 +108,12 @@ const deleteQuestionInput = z.object({
|
||||
export const deletePollQuestion = adminAction(
|
||||
{ permission: PERMS.POLLS_EDIT, schema: deleteQuestionInput },
|
||||
async (ctx) => {
|
||||
await db
|
||||
.delete(WebsitePollQuestion)
|
||||
.where(eq(WebsitePollQuestion.id, ctx.data.id));
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"poll-question.change",
|
||||
{ action: "delete", ...ctx.data },
|
||||
);
|
||||
if (!result.ok) throw new ActionError("Poll question deletion failed");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
+21
-127
@@ -1,17 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
// Models custom_prefixes / custom_prefix_blacklist / custom_prefix_settings
|
||||
// are not represented in src/db/schema.ts yet — we use parameterized raw SQL.
|
||||
|
||||
// ── Create prefix ───────────────────────────────────────────────────
|
||||
|
||||
const createPrefixSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
@@ -20,30 +14,6 @@ const createPrefixSchema = z.object({
|
||||
effect: z.string().optional(),
|
||||
active: z.coerce.number().int().min(0).max(1).default(1),
|
||||
});
|
||||
|
||||
export const createPrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema },
|
||||
async (ctx) => {
|
||||
const { username, text, color, icon, effect, active } = ctx.data;
|
||||
|
||||
const [user] = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (!user) throw new ActionError("User not found");
|
||||
|
||||
await db.execute(sql`
|
||||
INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active)
|
||||
VALUES (${user.id}, ${text}, ${color}, ${icon || ""}, ${effect || ""}, ${active})
|
||||
`);
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Update prefix ───────────────────────────────────────────────────
|
||||
|
||||
const updatePrefixSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
text: z.string().min(1),
|
||||
@@ -52,101 +22,25 @@ const updatePrefixSchema = z.object({
|
||||
effect: z.string().optional(),
|
||||
active: z.coerce.number().int().min(0).max(1).optional(),
|
||||
});
|
||||
const deletePrefixSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const addBlacklistWordSchema = z.object({ word: z.string().min(1).max(100) });
|
||||
const removeBlacklistWordSchema = z.object({ id: z.coerce.number().int().positive() });
|
||||
const updatePrefixSettingsSchema = z.object({ settings: z.record(z.string(), z.string()) });
|
||||
|
||||
export const updatePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema },
|
||||
async (ctx) => {
|
||||
const { id, text, color, icon, effect, active } = ctx.data;
|
||||
async function run(ctx: { data: Record<string, unknown>; requestId: unknown; session: { user: { id: number } } }, operation: "prefix.change" | "prefix-blacklist.change" | "prefix-settings.update", input: Record<string, unknown>) {
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
operation,
|
||||
input,
|
||||
);
|
||||
if (!result.ok && result.error.code === "NOT_FOUND") throw new ActionError("User not found");
|
||||
if (!result.ok) throw new Error(result.error.messageKey);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE custom_prefixes
|
||||
SET text = ${text}, color = ${color}, icon = ${icon || ""}, effect = ${effect || ""}, active = ${active ?? 1}
|
||||
WHERE id = ${id}
|
||||
`);
|
||||
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Delete prefix ───────────────────────────────────────────────────
|
||||
|
||||
const deletePrefixSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const deletePrefix = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema },
|
||||
async (ctx) => {
|
||||
await db.execute(
|
||||
sql`DELETE FROM custom_prefixes WHERE id = ${ctx.data.id}`,
|
||||
);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Add blacklist word ──────────────────────────────────────────────
|
||||
|
||||
const addBlacklistWordSchema = z.object({
|
||||
word: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export const addBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema },
|
||||
async (ctx) => {
|
||||
await db.execute(sql`
|
||||
INSERT INTO custom_prefix_blacklist (word) VALUES (${ctx.data.word.trim()})
|
||||
`);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Remove blacklist word ───────────────────────────────────────────
|
||||
|
||||
const removeBlacklistWordSchema = z.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
});
|
||||
|
||||
export const removeBlacklistWord = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema },
|
||||
async (ctx) => {
|
||||
await db.execute(
|
||||
sql`DELETE FROM custom_prefix_blacklist WHERE id = ${ctx.data.id}`,
|
||||
);
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Update prefix settings ──────────────────────────────────────────
|
||||
|
||||
const SETTINGS_WHITELIST = new Set([
|
||||
"enabled",
|
||||
"max_length",
|
||||
"min_rank",
|
||||
"min_rank_to_buy",
|
||||
"allow_colors",
|
||||
"allow_bold",
|
||||
"allow_italic",
|
||||
"default_color",
|
||||
"price_credits",
|
||||
"price_points",
|
||||
"points_type",
|
||||
]);
|
||||
|
||||
const updatePrefixSettingsSchema = z.object({
|
||||
settings: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const updatePrefixSettings = adminAction(
|
||||
{ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema },
|
||||
async (ctx) => {
|
||||
for (const [key, value] of Object.entries(ctx.data.settings)) {
|
||||
if (!SETTINGS_WHITELIST.has(key)) continue;
|
||||
await db.execute(sql`
|
||||
INSERT INTO custom_prefix_settings (\`key\`, \`value\`)
|
||||
VALUES (${key}, ${value})
|
||||
ON DUPLICATE KEY UPDATE \`value\` = ${value}
|
||||
`);
|
||||
}
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
export const createPrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: createPrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "create", ...ctx.data }));
|
||||
export const updatePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "update", ...ctx.data }));
|
||||
export const deletePrefix = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: deletePrefixSchema }, (ctx) => run(ctx, "prefix.change", { action: "delete", ...ctx.data }));
|
||||
export const addBlacklistWord = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: addBlacklistWordSchema }, (ctx) => run(ctx, "prefix-blacklist.change", { action: "add", ...ctx.data }));
|
||||
export const removeBlacklistWord = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: removeBlacklistWordSchema }, (ctx) => run(ctx, "prefix-blacklist.change", { action: "remove", ...ctx.data }));
|
||||
export const updatePrefixSettings = adminAction({ permission: PERMS.PREFIXES_EDIT, schema: updatePrefixSettingsSchema }, (ctx) => run(ctx, "prefix-settings.update", ctx.data));
|
||||
+37
-127
@@ -1,136 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const FAVICON_DIR = resolveMediaPath("favicon");
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
const ALLOWED = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/x-icon",
|
||||
"image/svg+xml",
|
||||
];
|
||||
const MAX_SIZE = 2 * 1024 * 1024;
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/svg+xml"];
|
||||
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0)
|
||||
return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE)
|
||||
return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||
};
|
||||
|
||||
const mimeExt: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/x-icon": "ico",
|
||||
"image/svg+xml": "svg",
|
||||
};
|
||||
const ext = mimeExt[file.type] ?? "png";
|
||||
const filename = `favicon-${Date.now()}.${ext}`;
|
||||
const baseDir = FAVICON_DIR;
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/favicon/${filename}`;
|
||||
|
||||
// Remove old favicon file if it exists
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore if file doesn't exist */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: "cms_favicon", value: url, comment: "Favicon URL" })
|
||||
.onDuplicateKeyUpdate({ set: { value: url } });
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
export async function saveFavicon(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0) return { success: false, error: "No file provided" };
|
||||
if (file.size > MAX_SIZE) return { success: false, error: "File too large (max 2MB)" };
|
||||
if (!ALLOWED.includes(file.type)) return { success: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG" };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"favicon.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
siteRevalidate();
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
}
|
||||
|
||||
export async function deleteFavicon(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const oldUrl = await siteSettings.get("cms_favicon");
|
||||
if (oldUrl?.startsWith("/api/media/favicon/")) {
|
||||
const baseDir = FAVICON_DIR;
|
||||
const oldName = oldUrl.replace("/api/media/favicon/", "");
|
||||
if (!oldName.includes("..") && !oldName.includes("/")) {
|
||||
const oldPath = path.resolve(baseDir, oldName);
|
||||
if (oldPath.startsWith(baseDir + path.sep)) {
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await unlink(oldPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export async function deleteFavicon(): Promise<{ success: boolean; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"favicon.delete",
|
||||
{},
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteSetting)
|
||||
.where(eq(WebsiteSetting.key, "cms_favicon"));
|
||||
} catch {
|
||||
/* ignore missing row */
|
||||
}
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
function siteRevalidate(): void {
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/admin/favicon");
|
||||
}
|
||||
+19
-54
@@ -1,59 +1,24 @@
|
||||
"use server";
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import {
|
||||
contentMutationService,
|
||||
createContentMutationInvocation,
|
||||
} from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MEDIA_DIR = resolveMediaPath("logo");
|
||||
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
|
||||
const ext =
|
||||
file.type === "image/png"
|
||||
? "png"
|
||||
: file.type === "image/gif"
|
||||
? "gif"
|
||||
: file.type === "image/jpeg"
|
||||
? "jpg"
|
||||
: file.type === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = MEDIA_DIR;
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
await db
|
||||
.insert(WebsiteSetting)
|
||||
.values({ key: "cms_logo", value: url, comment: "Logo (generator)" })
|
||||
.onDuplicateKeyUpdate({ set: { value: url } });
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
export async function saveLogo(formData: FormData): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
const staff = await requirePermission(PERMS.SETTINGS_EDIT);
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
const result = await contentMutationService.execute(
|
||||
createContentMutationInvocation(staff, createCorrelationId()),
|
||||
"logo.save",
|
||||
{ file },
|
||||
);
|
||||
if (!result.ok) return { success: false, error: result.error.messageKey };
|
||||
revalidatePath("/", "layout");
|
||||
return { success: true, ...(typeof result.data.output?.url === "string" ? { url: result.data.output.url } : {}) };
|
||||
}
|
||||
@@ -2,14 +2,20 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
|
||||
describe("admin-photos drizzle contract", () => {
|
||||
const src = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
describe("admin-photos Content service contract", () => {
|
||||
const wrapper = readFileSync("src/actions/admin-photos.ts", "utf8");
|
||||
const runtime = readFileSync(
|
||||
"src/features/housekeeping/domains/content/services/mutation-runtime-external.ts",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("deletes via Drizzle CameraWeb and attempts local file purge", () => {
|
||||
expect(src).toContain("@/lib/db");
|
||||
expect(src).toContain("CameraWeb");
|
||||
expect(src).toContain("tryRemoveLocalPhotoFile");
|
||||
expect(src).toContain('revalidatePath("/photos")');
|
||||
it("delegates while the runtime deletes CameraWeb and purges local files", () => {
|
||||
expect(wrapper).toContain("contentMutationService.execute");
|
||||
expect(wrapper).toContain('"photo.delete"');
|
||||
expect(wrapper).toContain('revalidatePath("/photos")');
|
||||
expect(runtime).toContain("@/lib/db");
|
||||
expect(runtime).toContain("CameraWeb");
|
||||
expect(runtime).toContain("tryRemoveLocalPhotoFile");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+21
-99
@@ -1,124 +1,46 @@
|
||||
"use server";
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CLIENT_TRANSLATION_FILES,
|
||||
getClientTranslationFile,
|
||||
} from "@/lib/client-translation-files";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
import { contentMutationService } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { CLIENT_TRANSLATION_FILES } from "@/lib/client-translation-files";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
|
||||
const saveTranslationsSchema = z.object({
|
||||
locale: z.enum([
|
||||
"en",
|
||||
"it",
|
||||
"nl",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"pt",
|
||||
"pl",
|
||||
"sv",
|
||||
"tr",
|
||||
"ro",
|
||||
"hu",
|
||||
"cs",
|
||||
"sk",
|
||||
"da",
|
||||
"no",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sr",
|
||||
"uk",
|
||||
"ru",
|
||||
]),
|
||||
locale: z.enum(["en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro", "hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru"]),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
const saveClientTranslationsSchema = z.object({
|
||||
fileId: z.enum(CLIENT_TRANSLATION_FILES.map((file) => file.id) as [string, ...string[]]),
|
||||
data: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const saveTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"src",
|
||||
"messages",
|
||||
`${ctx.data.locale}.json`,
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"translation.cms.save",
|
||||
ctx.data,
|
||||
);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify(ctx.data.data, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
if (!result.ok) throw new ActionError("Translation save failed");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
const saveClientTranslationsSchema = z.object({
|
||||
fileId: z.enum(
|
||||
CLIENT_TRANSLATION_FILES.map((f) => f.id) as [string, ...string[]],
|
||||
),
|
||||
data: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export const saveClientTranslations = adminAction(
|
||||
{ permission: PERMS.SETTINGS_EDIT, schema: saveClientTranslationsSchema },
|
||||
async (ctx) => {
|
||||
const file = getClientTranslationFile(ctx.data.fileId);
|
||||
if (!file) throw new ActionError("Unknown file");
|
||||
if (file.readOnly) throw new ActionError("File is read-only");
|
||||
|
||||
// file.relPath comes from CLIENT_TRANSLATION_FILES (closed enum) but
|
||||
// Turbopack's static tracer can't prove that — without the hint it
|
||||
// pulls the entire project into the NFT list.
|
||||
const absPath = path.join(
|
||||
/*turbopackIgnore: true*/ process.cwd(),
|
||||
file.relPath,
|
||||
const result = await contentMutationService.execute(
|
||||
{ correlationId: String(ctx.requestId), expectedActorId: Number(ctx.session.user.id), legacy: true },
|
||||
"translation.client.save",
|
||||
ctx.data,
|
||||
);
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
|
||||
if (file.format === "json") {
|
||||
// Plain JSON — no comments to preserve, just round-trip.
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
JSON.stringify(ctx.data.data, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return actionOk({ commentsLost: false, unpatchedKeys: [] as string[] });
|
||||
}
|
||||
|
||||
// JSON5: surgical line-level patch keeps headers and section comments
|
||||
// intact. Falls back to a full re-serialization (which DOES drop comments)
|
||||
// only when an edited key cannot be located via the patch contract.
|
||||
const original: Record<string, string> = {};
|
||||
const parsed = JSONC.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
original[k] = v == null ? "" : String(v);
|
||||
}
|
||||
}
|
||||
|
||||
const { content, unpatchedKeys } = patchJson5(raw, original, ctx.data.data);
|
||||
|
||||
if (unpatchedKeys.length === 0) {
|
||||
await fs.writeFile(absPath, content, "utf-8");
|
||||
return actionOk({ commentsLost: false, unpatchedKeys });
|
||||
}
|
||||
|
||||
// At least one key could not be patched surgically (e.g. unusual
|
||||
// formatting or a brand-new key). Fall back to a full re-serialization
|
||||
// and warn the caller that comments were lost.
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
JSON.stringify(ctx.data.data, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
return actionOk({ commentsLost: true, unpatchedKeys });
|
||||
if (!result.ok) throw new ActionError("Translation save failed");
|
||||
return actionOk({
|
||||
commentsLost: result.data.output?.commentsLost === true,
|
||||
unpatchedKeys: Array.isArray(result.data.output?.unpatchedKeys) ? result.data.output.unpatchedKeys : [],
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user