feat(housekeeping): deliver content vertical

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

No files matched your search

@@ -0,0 +1,607 @@
import "server-only";
import { eq, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import {
db,
EmailTemplates,
Taggables,
Tags,
User,
WebsiteAds,
WebsiteArticleComments,
WebsiteArticleReactions,
WebsiteArticles,
WebsiteBanner,
WebsiteEvent,
WebsiteEventPrize,
WebsiteEventType,
WebsiteEventWinner,
WebsiteHelpCenterCategories,
WebsitePoll,
WebsitePollQuestion,
WebsiteWriteableBoxes,
} from "@/lib/db";
import { slugify } from "@/lib/format";
import { canonicalize } from "@/lib/foundation/security";
import { logStaffActivity } from "@/lib/services/staff-activity";
import {
createEventSchema,
eventPrizeSchema,
eventTypeSchema,
eventWinnerSchema,
updateEventSchema,
} from "@/lib/validators/event";
import {
createPollSchema,
pollQuestionSchema,
updatePollSchema,
} from "@/lib/validators/poll";
import {
type ContentMutationContext,
ContentMutationFailure,
type ContentMutationOperation,
type ContentMutationSnapshot,
} from "./mutations";
type ContentDatabase = typeof db;
function database(transaction: unknown): ContentDatabase {
return (transaction ?? db) as ContentDatabase;
}
function record(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw validation();
}
return value as Record<string, unknown>;
}
function validation(): ContentMutationFailure {
return new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
function notFound(): ContentMutationFailure {
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
}
function text(value: unknown, maximum: number, required = false): string {
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
if (required && !normalized) throw validation();
return normalized;
}
function rawText(value: unknown, maximum = 100_000): string {
return String(value ?? "").normalize("NFC").slice(0, maximum);
}
function positiveBigInt(value: unknown): bigint {
const normalized = typeof value === "bigint" ? value.toString() : String(value ?? "").trim();
if (!/^[1-9]\d*$/u.test(normalized)) throw validation();
return BigInt(normalized);
}
function positiveInteger(value: unknown): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw validation();
return parsed;
}
function nonNegativeInteger(value: unknown, fallback = 0): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
}
function insertedId(result: unknown): string {
const value = (result as ResultSetHeader | undefined)?.insertId;
return positiveBigInt(value).toString();
}
async function activity(
context: ContentMutationContext,
action: string,
description: string,
targetType?: string,
targetId?: number,
): Promise<void> {
await logStaffActivity({
staffId: context.capability.actor.id,
action,
description,
...(targetType ? { targetType } : {}),
...(targetId === undefined ? {} : { targetId }),
});
}
async function uniqueSlug(
databaseConnection: ContentDatabase,
value: string,
): Promise<string> {
const base = slugify(value) || "article";
let candidate = base;
let suffix = 2;
for (;;) {
const [existing] = await databaseConnection
.select({ id: WebsiteArticles.id })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.slug, candidate))
.limit(1);
if (!existing) return candidate;
candidate = base + "-" + suffix;
suffix += 1;
}
}
async function articleChange(
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const title = text(data.title, 255, true);
const now = new Date();
const rawSlug = text(data.slug, 255);
const slug = await uniqueSlug(connection, rawSlug || title);
const [result] = (await connection.insert(WebsiteArticles).values({
slug,
title,
shortStory: text(data.shortStory ?? data.summary, 255),
fullStory: rawText(data.fullStory ?? data.content),
image: text(data.image, 255),
userId: context.capability.actor.id,
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, title, slug }, output: { id } };
}
const id = positiveBigInt(data.id);
const [existing] = await connection
.select({ id: WebsiteArticles.id, title: WebsiteArticles.title, slug: WebsiteArticles.slug })
.from(WebsiteArticles)
.where(eq(WebsiteArticles.id, id))
.limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteArticleReactions).where(eq(WebsiteArticleReactions.articleId, id));
await connection.delete(WebsiteArticleComments).where(eq(WebsiteArticleComments.articleId, id));
await connection.delete(WebsiteArticles).where(eq(WebsiteArticles.id, id));
return { before: { id: id.toString(), title: existing.title, slug: existing.slug }, after: null };
}
if (action !== "update") throw validation();
const title = text(data.title, 255, true);
const rawSlug = text(data.slug, 255);
const slug = rawSlug ? await uniqueSlug(connection, rawSlug) : existing.slug;
await connection.update(WebsiteArticles).set({
title,
slug,
shortStory: text(data.shortStory ?? data.summary, 255),
fullStory: rawText(data.fullStory ?? data.content),
image: text(data.image, 255),
updatedAt: new Date(),
}).where(eq(WebsiteArticles.id, id));
return {
before: { id: id.toString(), title: existing.title, slug: existing.slug },
after: { id: id.toString(), title, slug },
output: { id: id.toString() },
};
}
async function adChange(
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const image = text(data.image, 255, true);
const now = new Date();
const [result] = (await connection.insert(WebsiteAds).values({ image, createdAt: now, updatedAt: now })) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "ad_create", "Created advertisement #" + id + " (" + image + ")", "website_ad", Number(id));
return { before: null, after: { id, image }, output: { id } };
}
const id = positiveBigInt(data.id);
const [existing] = await connection.select({ id: WebsiteAds.id, image: WebsiteAds.image }).from(WebsiteAds).where(eq(WebsiteAds.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
await activity(context, "ad_delete", "Deleted advertisement #" + id, "website_ad", Number(id));
return { before: { id: id.toString(), image: existing.image }, after: null };
}
if (action !== "update") throw validation();
const image = text(data.image, 255, true);
await connection.update(WebsiteAds).set({ image, updatedAt: new Date() }).where(eq(WebsiteAds.id, id));
await activity(context, "ad_update", "Updated advertisement #" + id + " (" + image + ")", "website_ad", Number(id));
return { before: { id: id.toString(), image: existing.image }, after: { id: id.toString(), image }, output: { id: id.toString() } };
}
function bannerValues(data: Record<string, unknown>) {
return {
title: text(data.title, 255, true),
subtitle: text(data.subtitle, 500),
image: text(data.image, 500),
link: text(data.link, 500),
color: text(data.color, 20),
isActive: Math.min(1, nonNegativeInteger(data.isActive, 1)),
sortOrder: nonNegativeInteger(data.sortOrder),
startDate: data.startDate ? text(data.startDate, 50) : null,
endDate: data.endDate ? text(data.endDate, 50) : null,
};
}
async function bannerChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const values = bannerValues(data);
const [result] = (await connection.insert(WebsiteBanner).values(values)) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, title: values.title }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsiteBanner.id, title: WebsiteBanner.title }).from(WebsiteBanner).where(eq(WebsiteBanner.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteBanner).where(eq(WebsiteBanner.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const values = bannerValues({ ...data, title: data.title ?? existing.title });
await connection.update(WebsiteBanner).set(values).where(eq(WebsiteBanner.id, id));
return { before: existing, after: { id, title: values.title }, output: { id: String(id) } };
}
async function eventTypeChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = eventTypeSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsiteEventType).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, name: parsed.data.name }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsiteEventType.id, name: WebsiteEventType.name }).from(WebsiteEventType).where(eq(WebsiteEventType.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteEventType).where(eq(WebsiteEventType.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const parsed = eventTypeSchema.partial().safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsiteEventType).set(values).where(eq(WebsiteEventType.id, id));
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
}
async function eventChange(
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = createEventSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsiteEvent).values({ ...parsed.data, hostUserId: context.capability.actor.id, updatedAt: new Date() });
const id = insertedId(result);
return { before: null, after: { id, title: parsed.data.title, status: parsed.data.status }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsiteEvent.id, title: WebsiteEvent.title, status: WebsiteEvent.status }).from(WebsiteEvent).where(eq(WebsiteEvent.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsiteEvent).where(eq(WebsiteEvent.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const parsed = updateEventSchema.safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsiteEvent).set({ ...values, updatedAt: new Date() }).where(eq(WebsiteEvent.id, id));
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
}
async function eventPrizeChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveInteger(data.id);
await connection.delete(WebsiteEventPrize).where(eq(WebsiteEventPrize.id, id));
return { before: { id }, after: null };
}
if (action !== "create") throw validation();
const parsed = eventPrizeSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsiteEventPrize).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, eventId: parsed.data.eventId, position: parsed.data.position }, output: { id } };
}
async function eventWinnerAdd(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const parsed = eventWinnerSchema.safeParse(record(input));
if (!parsed.success) throw validation();
const [result] = await database(transaction).insert(WebsiteEventWinner).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, eventId: parsed.data.eventId, userId: parsed.data.userId, position: parsed.data.position }, output: { id } };
}
async function pollChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = createPollSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsitePoll).values({ ...parsed.data, updatedAt: new Date() });
const id = insertedId(result);
return { before: null, after: { id, title: parsed.data.title, status: parsed.data.status }, output: { id } };
}
const id = positiveInteger(data.id);
const [existing] = await connection.select({ id: WebsitePoll.id, title: WebsitePoll.title, status: WebsitePoll.status }).from(WebsitePoll).where(eq(WebsitePoll.id, id)).limit(1);
if (!existing) throw notFound();
if (action === "delete") {
await connection.delete(WebsitePoll).where(eq(WebsitePoll.id, id));
return { before: existing, after: null };
}
if (action !== "update") throw validation();
const parsed = updatePollSchema.safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsitePoll).set({ ...values, updatedAt: new Date() }).where(eq(WebsitePoll.id, id));
return { before: existing, after: { id, ...values }, output: { id: String(id) } };
}
async function pollQuestionChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const parsed = pollQuestionSchema.safeParse(data);
if (!parsed.success) throw validation();
const [result] = await connection.insert(WebsitePollQuestion).values(parsed.data);
const id = insertedId(result);
return { before: null, after: { id, pollId: parsed.data.pollId, question: parsed.data.question }, output: { id } };
}
const id = positiveInteger(data.id);
if (action === "delete") {
await connection.delete(WebsitePollQuestion).where(eq(WebsitePollQuestion.id, id));
return { before: { id }, after: null };
}
if (action !== "update") throw validation();
const parsed = pollQuestionSchema.partial().safeParse(data);
if (!parsed.success) throw validation();
const { action: _action, id: _id, ...values } = parsed.data as Record<string, unknown>;
await connection.update(WebsitePollQuestion).set(values).where(eq(WebsitePollQuestion.id, id));
return { before: { id }, after: { id, ...values }, output: { id: String(id) } };
}
async function tagChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const name = text(data.name, 255, true);
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
const now = new Date();
const [result] = (await connection.insert(Tags).values({ name, backgroundColor, createdAt: now, updatedAt: now })) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "tag_create", "Created tag " + name + " (#" + id + ")", "tag", Number(id));
return { before: null, after: { id, name, backgroundColor }, output: { id } };
}
const id = positiveBigInt(data.id);
if (action === "delete") {
await connection.delete(Taggables).where(eq(Taggables.tagId, id));
await connection.delete(Tags).where(eq(Tags.id, id));
await activity(context, "tag_delete", "Deleted tag #" + id, "tag", Number(id));
return { before: { id: id.toString() }, after: null };
}
if (action !== "update") throw validation();
const name = text(data.name, 255, true);
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
await connection.update(Tags).set({ name, backgroundColor, updatedAt: new Date() }).where(eq(Tags.id, id));
await activity(context, "tag_update", "Updated tag #" + id + " to " + name, "tag", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), name, backgroundColor }, output: { id: id.toString() } };
}
async function prefixChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "create") {
const username = text(data.username, 255, true);
const [user] = await connection.select({ id: User.id }).from(User).where(eq(User.username, username)).limit(1);
if (!user) throw notFound();
await connection.execute(sql`INSERT INTO custom_prefixes (user_id, text, color, icon, effect, active) VALUES (${user.id}, ${text(data.text, 255, true)}, ${text(data.color, 32, true)}, ${text(data.icon, 255)}, ${text(data.effect, 255)}, ${Math.min(1, nonNegativeInteger(data.active, 1))})`);
return { before: null, after: { username, text: text(data.text, 255, true) } };
}
const id = positiveInteger(data.id);
if (action === "delete") {
await connection.execute(sql`DELETE FROM custom_prefixes WHERE id = ${id}`);
return { before: { id }, after: null };
}
if (action !== "update") throw validation();
const prefixText = text(data.text, 255, true);
const color = text(data.color, 32, true);
const icon = text(data.icon, 255);
const effect = text(data.effect, 255);
const active = Math.min(1, nonNegativeInteger(data.active, 1));
await connection.execute(sql`UPDATE custom_prefixes SET text = ${prefixText}, color = ${color}, icon = ${icon}, effect = ${effect}, active = ${active} WHERE id = ${id}`);
return { before: { id }, after: { id, text: prefixText, color, icon, effect, active }, output: { id: String(id) } };
}
async function prefixBlacklistChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "add") {
const word = text(data.word, 100, true);
await connection.execute(sql`INSERT INTO custom_prefix_blacklist (word) VALUES (${word})`);
return { before: null, after: { word } };
}
if (action !== "remove" && action !== "delete") throw validation();
const id = positiveInteger(data.id);
await connection.execute(sql`DELETE FROM custom_prefix_blacklist WHERE id = ${id}`);
return { before: { id }, after: null };
}
const PREFIX_SETTINGS = new Set(["enabled", "max_length", "min_rank", "min_rank_to_buy", "allow_colors", "allow_bold", "allow_italic", "default_color", "price_credits", "price_points", "points_type"]);
async function prefixSettingsUpdate(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const settings = record(record(input).settings ?? input);
const accepted: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(settings)) {
if (!PREFIX_SETTINGS.has(key)) continue;
const value = text(rawValue, 255);
accepted[key] = value;
await database(transaction).execute(sql`INSERT INTO custom_prefix_settings (\`key\`, \`value\`) VALUES (${key}, ${value}) ON DUPLICATE KEY UPDATE \`value\` = ${value}`);
}
return { before: null, after: { keys: Object.keys(accepted).sort() } };
}
async function helpQuestionChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveBigInt(data.id);
await connection.delete(WebsiteHelpCenterCategories).where(eq(WebsiteHelpCenterCategories.id, id));
await activity(context, "help_delete", "Deleted help-center entry #" + id, "help_center_category", Number(id));
return { before: { id: id.toString() }, after: null };
}
const name = text(data.name, 255, true);
const content = canonicalize(rawText(data.content ?? data.answer, 100_000));
if (!content) throw validation();
const values = {
name,
content,
position: Math.max(1, positiveInteger(data.position ?? 1)),
imageUrl: text(data.imageUrl, 255) || null,
buttonText: text(data.buttonText, 255) || null,
buttonUrl: text(data.buttonUrl, 255) || null,
buttonColor: text(data.buttonColor, 16) || "#eeb425",
buttonBorderColor: text(data.buttonBorderColor, 16) || "#facc15",
smallBox: Boolean(data.smallBox),
};
if (action === "create") {
const [result] = (await connection.insert(WebsiteHelpCenterCategories).values(values)) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "help_create", "Created help-center entry #" + id + " (" + name + ")", "help_center_category", Number(id));
return { before: null, after: { id, name }, output: { id } };
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
await connection.update(WebsiteHelpCenterCategories).set(values).where(eq(WebsiteHelpCenterCategories.id, id));
await activity(context, "help_update", "Updated help-center entry #" + id + " (" + name + ")", "help_center_category", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), name }, output: { id: id.toString() } };
}
async function writeableBoxChange(input: unknown, context: ContentMutationContext, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveBigInt(data.id);
await connection.delete(WebsiteWriteableBoxes).where(eq(WebsiteWriteableBoxes.id, id));
await activity(context, "writeable_box_delete", "Deleted writeable box #" + id, "writeable_box", Number(id));
return { before: { id: id.toString() }, after: null };
}
if (action === "toggle") {
const id = positiveBigInt(data.id);
const isActive = data.next === "1" || data.next === 1 || data.next === true;
await connection.update(WebsiteWriteableBoxes).set({ isActive, updatedAt: new Date() }).where(eq(WebsiteWriteableBoxes.id, id));
await activity(context, "writeable_box_toggle", (isActive ? "Activated" : "Hid") + " writeable box #" + id, "writeable_box", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), isActive }, output: { id: id.toString() } };
}
const title = text(data.title, 255, true);
const values = {
title,
icon: text(data.icon, 255) || null,
content: rawText(data.content, 100_000),
position: nonNegativeInteger(data.position),
isActive: data.isActive === "1" || data.isActive === 1 || data.isActive === true,
updatedAt: new Date(),
};
if (action === "create") {
const now = new Date();
const [result] = (await connection.insert(WebsiteWriteableBoxes).values({ ...values, createdAt: now })) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(context, "writeable_box_create", "Created writeable box " + title + " (#" + id + ")", "writeable_box", Number(id));
return { before: null, after: { id, title }, output: { id } };
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
await connection.update(WebsiteWriteableBoxes).set(values).where(eq(WebsiteWriteableBoxes.id, id));
await activity(context, "writeable_box_update", "Updated writeable box #" + id + " (" + title + ")", "writeable_box", Number(id));
return { before: { id: id.toString() }, after: { id: id.toString(), title }, output: { id: id.toString() } };
}
async function emailTemplateChange(input: unknown, transaction: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const connection = database(transaction);
if (action === "delete") {
const id = positiveBigInt(data.id);
await connection.delete(EmailTemplates).where(eq(EmailTemplates.id, id));
return { before: { id: id.toString() }, after: null };
}
const subject = text(data.subject, 255, true);
const body = rawText(data.body, 500_000);
if (!body) throw validation();
const values = { subject, body, variables: text(data.variables, 20_000) || null, isActive: Boolean(data.isActive) };
if (action === "create") {
const name = text(data.name, 255, true);
const [result] = (await connection.insert(EmailTemplates).values({ name, ...values })) as unknown as [ResultSetHeader];
const id = insertedId(result);
return { before: null, after: { id, name, subject, isActive: values.isActive }, output: { id } };
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
await connection.update(EmailTemplates).set(values).where(eq(EmailTemplates.id, id));
return { before: { id: id.toString() }, after: { id: id.toString(), subject, isActive: values.isActive }, output: { id: id.toString() } };
}
const DATABASE_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext, transaction: unknown) => Promise<ContentMutationSnapshot>>> = {
"article.change": articleChange,
"ad.change": adChange,
"banner.change": (input, _context, transaction) => bannerChange(input, transaction),
"event-type.change": (input, _context, transaction) => eventTypeChange(input, transaction),
"event.change": eventChange,
"event-prize.change": (input, _context, transaction) => eventPrizeChange(input, transaction),
"event-winner.add": (input, _context, transaction) => eventWinnerAdd(input, transaction),
"poll.change": (input, _context, transaction) => pollChange(input, transaction),
"poll-question.change": (input, _context, transaction) => pollQuestionChange(input, transaction),
"tag.change": tagChange,
"prefix.change": (input, _context, transaction) => prefixChange(input, transaction),
"prefix-blacklist.change": (input, _context, transaction) => prefixBlacklistChange(input, transaction),
"prefix-settings.update": (input, _context, transaction) => prefixSettingsUpdate(input, transaction),
"help-question.change": helpQuestionChange,
"writeable-box.change": writeableBoxChange,
"email-template.change": (input, _context, transaction) => emailTemplateChange(input, transaction),
};
export async function executeContentDatabaseMutation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction: unknown,
): Promise<ContentMutationSnapshot | null> {
const handler = DATABASE_HANDLERS[operation];
return handler ? handler(input, context, transaction) : null;
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type { ContentMutationFailure } from "./mutations";
const fsMocks = vi.hoisted(() => ({
mkdir: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock("node:fs/promises", () => fsMocks);
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
vi.mock("@/lib/db", () => ({
CameraWeb: {},
EmulatorSettings: {},
WebsiteSetting: {},
db: {},
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: { updateConfig: vi.fn() },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: {
get: vi.fn(),
reload: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn(),
}));
vi.mock("@/lib/admin/photo-files", () => ({
tryRemoveLocalPhotoFile: vi.fn(),
}));
const capability = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
const context = {
capability,
correlationId: "external-runtime",
legacy: false,
};
describe("Content external mutation runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
const file = new File(["image"], "photo.PNG", { type: "image/png" });
const snapshot = await executeContentExternalMutation(
"media.upload",
{ file },
context,
);
expect(snapshot).toMatchObject({
before: null,
after: { size: file.size, type: "image/png" },
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
});
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
expect(fsMocks.writeFile).toHaveBeenCalledWith(
expect.stringContaining("storage"),
expect.any(Buffer),
);
});
it("propagates a real storage failure before optimistic success", async () => {
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
const file = new File(["image"], "photo.png", { type: "image/png" });
await expect(
executeContentExternalMutation("media.upload", { file }, context),
).rejects.toThrow("disk offline");
});
it("maps normalized media traversal to validation instead of dependency failure", async () => {
await expect(
executeContentExternalMutation(
"media.delete",
{ filename: "../private.txt" },
context,
),
).rejects.toMatchObject({
code: "VALIDATION",
messageKey: "errors.housekeeping.validation",
} satisfies Partial<ContentMutationFailure>);
expect(fsMocks.unlink).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,463 @@
import "server-only";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { eq } from "drizzle-orm";
import * as JSONC from "jsonc-parser";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import {
ADMIN_NAV_CONFIG_KEY,
type AdminNavConfig,
serializeAdminNavConfig,
} from "@/lib/admin-nav-config";
import { getClientTranslationFile } from "@/lib/client-translation-files";
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
import { patchJson5 } from "@/lib/json5-patch";
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
import { rcon } from "@/lib/services/rcon";
import { siteSettings } from "@/lib/services/site-settings";
import { logStaffActivity } from "@/lib/services/staff-activity";
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
import {
deleteCustomThemeStore,
getCustomTheme,
snapshotCurrentTheme,
upsertCustomTheme,
} from "@/lib/theme-custom-store";
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
import { presetSettings, settingKey } from "@/lib/theme-settings";
import {
type ContentMutationContext,
ContentMutationFailure,
type ContentMutationOperation,
type ContentMutationSnapshot,
} from "./mutations";
import { ContentCommittedExternalFailure } from "./mutations-production";
const MEDIA_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const FAVICON_TYPES = [
...MEDIA_TYPES,
"image/x-icon",
"image/svg+xml",
];
const CMS_LOCALES = new Set([
"en", "it", "nl", "de", "fr", "es", "pt", "pl", "sv", "tr", "ro",
"hu", "cs", "sk", "da", "no", "el", "bg", "hr", "sr", "uk", "ru",
]);
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
const ADMIN_COLOR_KEYS = [
"admin_canvas",
"admin_surface",
"admin_text",
"admin_text_muted",
"admin_border",
"admin_sidebar_bg",
] as const;
const HEADING_KEYS = ["size_heading_h1", "size_heading_h2", "size_heading_h3"] as const;
function validation(): ContentMutationFailure {
return new ContentMutationFailure("VALIDATION", "errors.housekeeping.validation");
}
function notFound(): ContentMutationFailure {
return new ContentMutationFailure("NOT_FOUND", "errors.housekeeping.notFound");
}
function record(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw validation();
return value as Record<string, unknown>;
}
function text(value: unknown, maximum: number, required = false): string {
const normalized = String(value ?? "").normalize("NFC").trim().slice(0, maximum);
if (required && !normalized) throw validation();
return normalized;
}
function jsonRecord(value: unknown): Record<string, unknown> {
if (typeof value === "string") {
try {
return record(JSON.parse(value));
} catch {
throw validation();
}
}
return record(value);
}
function fileValue(value: unknown): File {
if (
typeof value !== "object" ||
value === null ||
typeof (value as File).arrayBuffer !== "function" ||
typeof (value as File).name !== "string" ||
typeof (value as File).type !== "string" ||
typeof (value as File).size !== "number"
) {
throw validation();
}
return value as File;
}
async function writeWebsiteSetting(
key: string,
value: string,
comment: string,
): Promise<void> {
await db
.insert(WebsiteSetting)
.values({ key, value, comment })
.onDuplicateKeyUpdate({ set: { value } });
}
async function mediaUpload(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file);
if (file.size <= 0 || file.size > 5 * 1024 * 1024 || !MEDIA_TYPES.includes(file.type)) throw validation();
await mkdir(MEDIA_ROOT, { recursive: true });
const extension = text(file.name.split(".").pop() ?? "png", 10, true).toLowerCase();
const name = Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
const filePath = resolveMediaPath(name);
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
return {
before: null,
after: { name, size: file.size, type: file.type },
output: { name, url: "/api/media/" + name },
};
}
async function mediaDelete(input: unknown): Promise<ContentMutationSnapshot> {
const name = text(record(input).filename ?? record(input).name, 255, true);
let filePath: string;
try {
filePath = resolveMediaPath(name);
} catch {
throw validation();
}
if (!filePath.startsWith(MEDIA_ROOT + path.sep)) throw validation();
try {
await unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
return { before: { name }, after: null };
}
async function photoDelete(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const id = Number(record(input).id);
if (!Number.isSafeInteger(id) || id <= 0) throw validation();
const [row] = await db.select({ id: CameraWeb.id, url: CameraWeb.url }).from(CameraWeb).where(eq(CameraWeb.id, id)).limit(1);
if (!row) throw notFound();
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
await tryRemoveLocalPhotoFile(row.url);
await logStaffActivity({
staffId: context.capability.actor.id,
action: "photo_delete",
description: "Deleted camera photo #" + id,
targetType: "camera_web",
targetId: id,
});
return { before: { id, url: row.url }, after: null };
}
async function navigationUpdate(input: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const raw = data.items ?? data.config ?? data;
const config = jsonRecord(raw) as AdminNavConfig;
const value = serializeAdminNavConfig(config);
const beforeValue = await siteSettings.get(ADMIN_NAV_CONFIG_KEY, null);
await siteSettings.update(ADMIN_NAV_CONFIG_KEY, value);
return {
before: { configured: Boolean(beforeValue) },
after: {
configured: true,
groups: config.groupOrder?.length ?? 0,
hiddenGroups: config.hiddenGroups?.length ?? 0,
hiddenItems: config.hiddenItems?.length ?? 0,
},
output: { saved: true },
};
}
async function themeUpdate(
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot> {
const data = record(input);
const source = jsonRecord(data.values ?? data);
const changed: string[] = [];
await db.transaction(async (transaction) => {
for (const mode of ["light", "dark"] as const) {
const bag: Record<string, string> = {};
for (const key of THEME_COLOR_KEYS) {
const databaseKey = settingKey(key, mode);
const raw = text(source[databaseKey], 255);
if (raw && COLOR_RE.test(raw)) bag[key] = raw;
}
for (const [key, value] of Object.entries(ensureReadableThemeColors(bag))) {
const databaseKey = settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode);
await transaction.insert(WebsiteSetting).values({ key: databaseKey, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push(databaseKey);
}
}
const adminBag: Record<string, string> = {};
for (const key of ADMIN_COLOR_KEYS) {
const raw = text(source[key], 255);
if (raw && COLOR_RE.test(raw)) adminBag[key] = raw;
}
for (const [key, value] of Object.entries(ensureReadableThemeColors(adminBag))) {
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push(key);
}
const radius = text(source.border_radius, 3);
if (/^\d{1,3}$/u.test(radius)) {
await transaction.insert(WebsiteSetting).values({ key: "border_radius", value: radius, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: radius } });
changed.push("border_radius");
}
const font = text(source.font_family, 100);
if (font in FONTS) {
await transaction.insert(WebsiteSetting).values({ key: "font_family", value: font, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: font } });
changed.push("font_family");
}
for (const key of HEADING_KEYS) {
const value = text(source[key], 3);
if (!/^\d{1,3}$/u.test(value)) continue;
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push(key);
}
if (Object.hasOwn(source, "custom_css")) {
const value = String(source.custom_css ?? "").normalize("NFC").slice(0, 20_000);
await transaction.insert(WebsiteSetting).values({ key: "custom_css", value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
changed.push("custom_css");
}
});
const snapshot: ContentMutationSnapshot = { before: null, after: { changedKeys: changed.sort() } };
try {
siteSettings.reload();
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_update", description: "Updated theme settings" });
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeApplyPreset(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const name = text(record(input).preset, 100, true);
const preset = PRESETS[name];
if (!preset) throw validation();
await db.transaction(async (transaction) => {
for (const [key, value] of presetSettings(preset)) {
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
}
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: name } });
});
const snapshot: ContentMutationSnapshot = { before: null, after: { preset: name }, output: { name } };
try {
siteSettings.reload();
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied theme preset " + name });
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function themeCustomChange(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const data = record(input);
const action = text(data.action, 16, true);
const id = text(data.id, 100);
if (action === "delete") {
if (!id) throw validation();
const existing = await getCustomTheme(id);
if (!existing) throw notFound();
await deleteCustomThemeStore(id);
return { before: { id, name: existing.name }, after: null };
}
if (action !== "create" && action !== "update" && action !== "rename") throw validation();
const name = text(data.name, 100, true);
const settings = data.values ? Object.fromEntries(Object.entries(jsonRecord(data.values)).map(([key, value]) => [key, String(value)])) : await snapshotCurrentTheme();
const theme = await upsertCustomTheme(name, settings, id || undefined);
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Saved custom theme " + theme.name });
return { before: id ? { id } : null, after: { id: theme.id, name: theme.name }, output: { id: theme.id, name: theme.name } };
}
async function themeApplyCustom(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const id = text(record(input).id, 100, true);
const theme = await getCustomTheme(id);
if (!theme) throw notFound();
await db.transaction(async (transaction) => {
for (const [key, value] of Object.entries(theme.settings)) {
if (!value) continue;
await transaction.insert(WebsiteSetting).values({ key, value, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value } });
}
await transaction.insert(WebsiteSetting).values({ key: "theme_preset", value: theme.name, comment: "Theme (housekeeping)" }).onDuplicateKeyUpdate({ set: { value: theme.name } });
});
const snapshot: ContentMutationSnapshot = { before: null, after: { id, name: theme.name }, output: { name: theme.name } };
try {
siteSettings.reload();
await logStaffActivity({ staffId: context.capability.actor.id, action: "theme_preset", description: "Applied custom theme " + theme.name });
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
function faviconExtension(type: string): string {
return ({ "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/x-icon": "ico", "image/svg+xml": "svg" } as Record<string, string>)[type] ?? "png";
}
async function removeStoredAsset(url: string | null | undefined, directory: string, prefix: string): Promise<void> {
if (!url?.startsWith(prefix)) return;
const name = url.slice(prefix.length);
if (!name || name.includes("..") || name.includes("/") || name.includes("\\")) return;
const filePath = path.resolve(directory, name);
if (!filePath.startsWith(directory + path.sep)) return;
try {
await unlink(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
async function faviconSave(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file);
if (file.size <= 0 || file.size > 2 * 1024 * 1024 || !FAVICON_TYPES.includes(file.type)) throw validation();
const directory = resolveMediaPath("favicon");
const filename = "favicon-" + Date.now() + "." + faviconExtension(file.type);
const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_favicon");
await mkdir(directory, { recursive: true });
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
await removeStoredAsset(oldUrl, directory, "/api/media/favicon/");
const url = "/api/media/favicon/" + filename;
await writeWebsiteSetting("cms_favicon", url, "Favicon URL");
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
try {
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function faviconDelete(): Promise<ContentMutationSnapshot> {
const oldUrl = await siteSettings.get("cms_favicon");
await removeStoredAsset(oldUrl, resolveMediaPath("favicon"), "/api/media/favicon/");
await db.delete(WebsiteSetting).where(eq(WebsiteSetting.key, "cms_favicon"));
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: null };
try {
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function logoSave(input: unknown): Promise<ContentMutationSnapshot> {
const file = fileValue(record(input).file);
const extension = file.type === "image/png" ? "png" : file.type === "image/gif" ? "gif" : file.type === "image/jpeg" ? "jpg" : file.type === "image/webp" ? "webp" : "png";
const directory = resolveMediaPath("logo");
const filename = "logo-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8) + "." + extension;
const filePath = path.resolve(directory, filename);
if (!filePath.startsWith(directory + path.sep)) throw validation();
const oldUrl = await siteSettings.get("cms_logo");
await mkdir(directory, { recursive: true });
await writeFile(filePath, Buffer.from(await file.arrayBuffer()));
const url = "/api/media/logo/" + filename;
await writeWebsiteSetting("cms_logo", url, "Logo (generator)");
const snapshot: ContentMutationSnapshot = { before: { value: oldUrl ?? null }, after: { value: url }, output: { url } };
try {
siteSettings.reload();
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
async function cmsTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const locale = text(data.locale, 16, true);
if (!CMS_LOCALES.has(locale)) throw validation();
const translations = jsonRecord(data.data);
const filePath = path.join(process.cwd(), "src", "messages", locale + ".json");
await writeFile(filePath, JSON.stringify(translations, null, 2), "utf-8");
return { before: null, after: { locale, keyCount: Object.keys(translations).length } };
}
async function clientTranslationSave(input: unknown): Promise<ContentMutationSnapshot> {
const data = record(input);
const fileId = text(data.fileId, 100, true);
const translations = Object.fromEntries(Object.entries(jsonRecord(data.data)).map(([key, value]) => [key, String(value)]));
const file = getClientTranslationFile(fileId);
if (!file || file.readOnly) throw validation();
const absolutePath = path.join(process.cwd(), file.relPath);
const raw = await readFile(absolutePath, "utf-8");
if (file.format === "json") {
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
return { before: null, after: { fileId, keyCount: Object.keys(translations).length }, output: { commentsLost: false, unpatchedKeys: [] } };
}
const original: Record<string, string> = {};
const parsed = JSONC.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
for (const [key, value] of Object.entries(parsed)) original[key] = value == null ? "" : String(value);
}
const patched = patchJson5(raw, original, translations);
if (patched.unpatchedKeys.length === 0) {
await writeFile(absolutePath, patched.content, "utf-8");
} else {
await writeFile(absolutePath, JSON.stringify(translations, null, 4), "utf-8");
}
return {
before: null,
after: { fileId, keyCount: Object.keys(translations).length },
output: { commentsLost: patched.unpatchedKeys.length > 0, unpatchedKeys: patched.unpatchedKeys },
};
}
async function emulatorTranslationSave(input: unknown, context: ContentMutationContext): Promise<ContentMutationSnapshot> {
const data = record(input);
const source = data.settings ? jsonRecord(data.settings) : data.key ? { [text(data.key, 255, true)]: text(data.value, 20_000) } : jsonRecord(data);
const entries = Object.entries(source).map(([key, value]) => [text(key, 255, true), String(value)] as const);
await db.transaction(async (transaction) => {
for (const [key, value] of entries) {
await transaction.insert(EmulatorSettings).values({ key, value }).onDuplicateKeyUpdate({ set: { value } });
}
});
const snapshot: ContentMutationSnapshot = { before: null, after: { keys: entries.map(([key]) => key).sort() } };
try {
const delivered = await rcon.updateConfig();
if (!context.legacy && !delivered) throw new Error("emulator configuration sync failed");
} catch {
throw new ContentCommittedExternalFailure(snapshot);
}
return snapshot;
}
const EXTERNAL_HANDLERS: Partial<Record<ContentMutationOperation, (input: unknown, context: ContentMutationContext) => Promise<ContentMutationSnapshot>>> = {
"media.upload": (input) => mediaUpload(input),
"media.delete": (input) => mediaDelete(input),
"photo.delete": photoDelete,
"navigation.update": (input) => navigationUpdate(input),
"theme.update": themeUpdate,
"theme.apply-preset": themeApplyPreset,
"theme.custom-change": themeCustomChange,
"theme.apply-custom": themeApplyCustom,
"favicon.save": (input) => faviconSave(input),
"favicon.delete": () => faviconDelete(),
"logo.save": (input) => logoSave(input),
"translation.cms.save": (input) => cmsTranslationSave(input),
"translation.client.save": (input) => clientTranslationSave(input),
"translation.emulator.save": emulatorTranslationSave,
};
export async function executeContentExternalMutation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot | null> {
const handler = EXTERNAL_HANDLERS[operation];
return handler ? handler(input, context) : null;
}
@@ -0,0 +1,169 @@
import { describe, expect, it, vi } from "vitest";
import type { AuditEntry } from "@/lib/services/audit";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { CONTENT_MUTATION_OPERATIONS } from "./mutations";
import {
CONTENT_DATABASE_OPERATIONS,
CONTENT_EXTERNAL_OPERATIONS,
CONTENT_MIXED_OPERATIONS,
ContentCommittedExternalFailure,
createContentProductionMutationAdapter,
} from "./mutations-production";
const capability: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
};
const mutationContext = {
capability,
correlationId: "production-matrix",
legacy: false,
};
function dependencies() {
const transactionToken = { transaction: true };
const writeAudit = vi.fn(
async (_entry: AuditEntry, _transaction?: unknown) => undefined,
);
const executeOperation = vi.fn(async (operation: string) => ({
before: { operation, state: "before" },
after: { operation, state: "after" },
output: { id: "18446744073709551615" },
}));
const transaction = vi.fn(async (run) => run(transactionToken));
return {
transactionToken,
writeAudit,
executeOperation,
transaction,
adapter: createContentProductionMutationAdapter({
transaction,
writeAudit,
executeOperation,
}),
};
}
describe("Content production mutation adapter", () => {
it("classifies every operation exactly once", () => {
const classified = [
...CONTENT_DATABASE_OPERATIONS,
...CONTENT_EXTERNAL_OPERATIONS,
...CONTENT_MIXED_OPERATIONS,
];
expect([...classified].sort()).toEqual(
[...CONTENT_MUTATION_OPERATIONS].sort(),
);
expect(new Set(classified).size).toBe(classified.length);
});
it("executes every production operation and serializes identifiers", async () => {
const deps = dependencies();
for (const operation of CONTENT_MUTATION_OPERATIONS) {
const snapshot = await deps.adapter.execute(
operation,
{ action: "update" },
mutationContext,
);
expect(snapshot.output?.id, operation).toBe("18446744073709551615");
}
expect(deps.executeOperation).toHaveBeenCalledTimes(
CONTENT_MUTATION_OPERATIONS.length,
);
});
it("commits database mutation and canonical success audit in one transaction", async () => {
const deps = dependencies();
await deps.adapter.execute(
"article.change",
{ action: "update", id: "18446744073709551615" },
mutationContext,
);
expect(deps.transaction).toHaveBeenCalledTimes(1);
expect(deps.executeOperation).toHaveBeenCalledWith(
"article.change",
expect.anything(),
mutationContext,
deps.transactionToken,
);
expect(deps.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: "content.article.change",
outcome: "success",
domain: "content",
correlationId: "production-matrix",
}),
deps.transactionToken,
);
});
it("persists correlated intent and truthful outcome around external storage writes", async () => {
const deps = dependencies();
await deps.adapter.execute(
"translation.cms.save",
{ locale: "en", data: { welcome: "Hello" } },
mutationContext,
);
expect(deps.transaction).not.toHaveBeenCalled();
expect(deps.writeAudit.mock.calls.map(([entry]) => entry.outcome)).toEqual([
"intent",
"success",
]);
expect(
deps.writeAudit.mock.calls.every(
([entry]) => entry.correlationId === "production-matrix",
),
).toBe(true);
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain("Hello");
});
it("returns typed partial when database committed but the external effect failed", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(
new ContentCommittedExternalFailure({
before: { value: "/old.ico" },
after: { value: "/new.ico" },
}),
);
const snapshot = await deps.adapter.execute(
"favicon.save",
{ file: { name: "favicon.ico" } },
mutationContext,
);
expect(snapshot).toMatchObject({
before: { value: "/old.ico" },
after: { value: "/new.ico" },
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
});
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "partial",
});
});
it("records failure without optimistic after-state when external storage fails before commit", async () => {
const deps = dependencies();
deps.executeOperation.mockRejectedValueOnce(new Error("disk offline"));
await expect(
deps.adapter.execute(
"media.upload",
{ file: { name: "image.png" } },
mutationContext,
),
).rejects.toThrow("disk offline");
expect(deps.writeAudit.mock.calls.at(-1)?.[0]).toMatchObject({
outcome: "failure",
before: undefined,
after: undefined,
});
});
});
@@ -0,0 +1,212 @@
import type { AuditEntry } from "@/lib/services/audit";
import type {
ContentMutationAdapter,
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
export const CONTENT_DATABASE_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_EXTERNAL_OPERATIONS = [
"media.upload",
"media.delete",
"translation.cms.save",
"translation.client.save",
] as const satisfies readonly ContentMutationOperation[];
export const CONTENT_MIXED_OPERATIONS = [
"photo.delete",
"navigation.update",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.emulator.save",
] as const satisfies readonly ContentMutationOperation[];
type TransactionToken = unknown;
export interface ContentProductionMutationDependencies {
transaction<T>(run: (transaction: TransactionToken) => Promise<T>): Promise<T>;
writeAudit(entry: AuditEntry, transaction?: TransactionToken): Promise<void>;
executeOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: TransactionToken,
): Promise<ContentMutationSnapshot>;
}
export class ContentCommittedExternalFailure extends Error {
constructor(readonly snapshot: ContentMutationSnapshot) {
super("Content database change committed but external effect failed");
this.name = "ContentCommittedExternalFailure";
}
}
function auditEntry(
operation: ContentMutationOperation,
context: ContentMutationContext,
outcome: NonNullable<AuditEntry["outcome"]>,
snapshot?: ContentMutationSnapshot,
): AuditEntry {
return {
userId: context.capability.actor.id,
action: "content." + operation,
target: "Content",
correlationId: context.correlationId,
domain: "content",
outcome,
before:
snapshot?.before === null || snapshot?.before === undefined
? undefined
: { ...snapshot.before },
after:
snapshot?.after === null || snapshot?.after === undefined
? undefined
: { ...snapshot.after },
};
}
function includesOperation(
operations: readonly ContentMutationOperation[],
operation: ContentMutationOperation,
): boolean {
return operations.includes(operation);
}
async function writeOutcomeOrMarkUnavailable(
dependencies: ContentProductionMutationDependencies,
operation: ContentMutationOperation,
context: ContentMutationContext,
snapshot: ContentMutationSnapshot,
outcome: "success" | "partial",
): Promise<ContentMutationSnapshot> {
try {
await dependencies.writeAudit(
auditEntry(operation, context, outcome, snapshot),
);
return snapshot;
} catch {
const completion = {
status: "partial" as const,
external: outcome === "partial" ? ("failed" as const) : ("completed" as const),
audit: "unavailable" as const,
};
return { ...snapshot, completion };
}
}
export function createContentProductionMutationAdapter(
dependencies: ContentProductionMutationDependencies,
): ContentMutationAdapter {
return {
async execute(operation, input, context) {
if (includesOperation(CONTENT_DATABASE_OPERATIONS, operation)) {
return dependencies.transaction(async (transaction) => {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
transaction,
);
await dependencies.writeAudit(
auditEntry(operation, context, "success", snapshot),
transaction,
);
return snapshot;
});
}
await dependencies.writeAudit(auditEntry(operation, context, "intent"));
try {
const snapshot = await dependencies.executeOperation(
operation,
input,
context,
);
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"success",
);
} catch (error) {
if (
includesOperation(CONTENT_MIXED_OPERATIONS, operation) &&
error instanceof ContentCommittedExternalFailure
) {
const snapshot: ContentMutationSnapshot = {
...error.snapshot,
completion: {
status: "partial",
external: "failed",
audit: "persisted",
},
};
return writeOutcomeOrMarkUnavailable(
dependencies,
operation,
context,
snapshot,
"partial",
);
}
try {
await dependencies.writeAudit(
auditEntry(operation, context, "failure"),
);
} catch {
// Preserve the original dependency failure; the missing outcome is observable
// through the durable correlated intent.
}
throw error;
}
},
};
}
export const contentProductionMutationAdapter =
createContentProductionMutationAdapter({
async transaction(run) {
const { db } = await import("@/lib/db");
return db.transaction((transaction) => run(transaction));
},
async writeAudit(entry, transaction) {
const { logAudit } = await import("@/lib/services/audit");
await logAudit(entry, transaction as never);
},
async executeOperation(operation, input, context, transaction) {
const { executeContentMutationOperation } = await import(
"./mutations-runtime"
);
return executeContentMutationOperation(
operation,
input,
context,
transaction,
);
},
});
@@ -0,0 +1,35 @@
import "server-only";
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type {
ContentMutationContext,
ContentMutationOperation,
ContentMutationSnapshot,
} from "./mutations";
import { ContentMutationFailure } from "./mutations";
export async function executeContentMutationOperation(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
transaction?: unknown,
): Promise<ContentMutationSnapshot> {
const databaseResult = await executeContentDatabaseMutation(
operation,
input,
context,
transaction,
);
if (databaseResult) return databaseResult;
const externalResult = await executeContentExternalMutation(
operation,
input,
context,
);
if (externalResult) return externalResult;
throw new ContentMutationFailure(
"VALIDATION",
"errors.housekeeping.validation",
);
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import {
CONTENT_MUTATION_OPERATIONS,
createContentMutationInvocation,
createContentMutationService,
} from "./mutations";
function context(
granted: readonly string[],
actorId = 42,
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: actorId, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
describe("Content mutation service authority", () => {
it("rehydrates server authority and rejects forged actor identity", async () => {
const adapter = { execute: vi.fn() };
const service = createContentMutationService(
adapter,
async () => context([PERMS.NEWS_EDIT], 42),
);
const result = await service.execute(
{ correlationId: "forged", expectedActorId: 7 },
"article.change",
{ action: "create", title: "Forged" },
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(adapter.execute).not.toHaveBeenCalled();
});
it("requires the exact operation ACL even after dispatcher authorization", async () => {
const adapter = { execute: vi.fn() };
const service = createContentMutationService(
adapter,
async () => context([PERMS.NEWS_EDIT]),
);
const result = await service.execute(
{ correlationId: "brand", expectedActorId: 42 },
"theme.update",
{},
);
expect(result).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(adapter.execute).not.toHaveBeenCalled();
});
it("executes every declared operation through the real service boundary", async () => {
const execute = vi.fn(async (_operation, _input, mutationContext) => ({
before: null,
after: { actorId: mutationContext.capability.actor.id },
}));
const service = createContentMutationService(
{ execute },
async () => context(Object.values(PERMS)),
);
const invocation = createContentMutationInvocation(
{ id: 42 },
"operation-matrix",
);
for (const operation of CONTENT_MUTATION_OPERATIONS) {
const result = await service.execute(invocation, operation, {});
expect(result.ok, operation).toBe(true);
}
expect(execute).toHaveBeenCalledTimes(CONTENT_MUTATION_OPERATIONS.length);
});
it("maps adapter failures without exposing storage or template payloads", async () => {
const service = createContentMutationService(
{
execute: async () => {
throw new Error("C:\\private\\template.json: secret body");
},
},
async () => context([PERMS.SETTINGS_EDIT]),
);
const result = await service.execute(
{ correlationId: "redacted", expectedActorId: 42 },
"translation.cms.save",
{ data: { secret: "body" } },
);
expect(result).toMatchObject({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
});
expect(JSON.stringify(result)).not.toContain("secret body");
});
});
@@ -0,0 +1,210 @@
import { PERMS } from "@/lib/permission-slugs";
import { satisfiesCapability } from "../../../foundation/capability-context";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingErrorCode,
type HousekeepingPartialCompletion,
type HousekeepingResult,
ok,
} from "../../../foundation/contracts";
export const CONTENT_MUTATION_OPERATIONS = [
"article.change",
"ad.change",
"banner.change",
"event-type.change",
"event.change",
"event-prize.change",
"event-winner.add",
"poll.change",
"poll-question.change",
"photo.delete",
"media.upload",
"media.delete",
"navigation.update",
"tag.change",
"prefix.change",
"prefix-blacklist.change",
"prefix-settings.update",
"help-question.change",
"writeable-box.change",
"email-template.change",
"theme.update",
"theme.apply-preset",
"theme.custom-change",
"theme.apply-custom",
"favicon.save",
"favicon.delete",
"logo.save",
"translation.cms.save",
"translation.client.save",
"translation.emulator.save",
] as const;
export type ContentMutationOperation =
(typeof CONTENT_MUTATION_OPERATIONS)[number];
export interface ContentMutationSnapshot {
readonly before: Readonly<Record<string, unknown>> | null;
readonly after: Readonly<Record<string, unknown>> | null;
readonly output?: Readonly<Record<string, unknown>>;
readonly completion?: HousekeepingPartialCompletion;
}
export interface ContentMutationInvocation {
readonly correlationId: string;
readonly expectedActorId: number;
readonly legacy?: boolean;
}
export interface ContentMutationContext {
readonly capability: HousekeepingCapabilityContext;
readonly correlationId: string;
readonly legacy: boolean;
}
export interface ContentMutationAdapter {
execute(
operation: ContentMutationOperation,
input: unknown,
context: ContentMutationContext,
): Promise<ContentMutationSnapshot>;
}
export interface ContentMutationService {
execute(
invocation: ContentMutationInvocation,
operation: ContentMutationOperation,
input: unknown,
): Promise<HousekeepingResult<ContentMutationSnapshot>>;
}
export class ContentMutationFailure extends Error {
constructor(
readonly code: HousekeepingErrorCode,
readonly messageKey: string,
readonly fieldErrors?: Readonly<Record<string, readonly string[]>>,
) {
super(messageKey);
this.name = "ContentMutationFailure";
}
}
const OPERATION_PERMISSION = Object.freeze({
"article.change": PERMS.NEWS_EDIT,
"ad.change": PERMS.PAGES_EDIT,
"banner.change": PERMS.BANNERS_EDIT,
"event-type.change": PERMS.EVENTS_EDIT,
"event.change": PERMS.EVENTS_EDIT,
"event-prize.change": PERMS.EVENTS_EDIT,
"event-winner.add": PERMS.EVENTS_EDIT,
"poll.change": PERMS.POLLS_EDIT,
"poll-question.change": PERMS.POLLS_EDIT,
"photo.delete": PERMS.PAGES_EDIT,
"media.upload": PERMS.PAGES_EDIT,
"media.delete": PERMS.PAGES_EDIT,
"navigation.update": PERMS.SETTINGS_EDIT,
"tag.change": PERMS.PAGES_EDIT,
"prefix.change": PERMS.PREFIXES_EDIT,
"prefix-blacklist.change": PERMS.PREFIXES_EDIT,
"prefix-settings.update": PERMS.PREFIXES_EDIT,
"help-question.change": PERMS.PAGES_EDIT,
"writeable-box.change": PERMS.PAGES_EDIT,
"email-template.change": PERMS.PAGES_EDIT,
"theme.update": PERMS.SETTINGS_EDIT,
"theme.apply-preset": PERMS.SETTINGS_EDIT,
"theme.custom-change": PERMS.SETTINGS_EDIT,
"theme.apply-custom": PERMS.SETTINGS_EDIT,
"favicon.save": PERMS.SETTINGS_EDIT,
"favicon.delete": PERMS.SETTINGS_EDIT,
"logo.save": PERMS.SETTINGS_EDIT,
"translation.cms.save": PERMS.SETTINGS_EDIT,
"translation.client.save": PERMS.SETTINGS_EDIT,
"translation.emulator.save": PERMS.SETTINGS_EDIT,
} satisfies Record<ContentMutationOperation, string>);
export function contentMutationCapability(
operation: ContentMutationOperation,
) {
return anyCapability(OPERATION_PERMISSION[operation]);
}
export function createContentMutationService(
adapter: ContentMutationAdapter,
resolveCapabilityContext: () => Promise<HousekeepingCapabilityContext>,
): ContentMutationService {
return {
async execute(invocation, operation, input) {
let capability: HousekeepingCapabilityContext;
try {
capability = await resolveCapabilityContext();
} catch {
return fail(
"UNAUTHENTICATED",
"errors.housekeeping.unauthenticated",
invocation.correlationId,
);
}
if (
capability.actor.id !== invocation.expectedActorId ||
!satisfiesCapability(capability, contentMutationCapability(operation))
) {
return fail(
"FORBIDDEN",
"errors.housekeeping.forbidden",
invocation.correlationId,
);
}
try {
const snapshot = await adapter.execute(operation, input, {
capability,
correlationId: invocation.correlationId,
legacy: invocation.legacy === true,
});
return ok(snapshot, invocation.correlationId, snapshot.completion);
} catch (error) {
if (error instanceof ContentMutationFailure) {
return fail(
error.code,
error.messageKey,
invocation.correlationId,
error.fieldErrors,
);
}
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
invocation.correlationId,
);
}
},
};
}
export function createContentMutationInvocation(
actor: { readonly id: number },
correlationId: string,
): ContentMutationInvocation {
return { correlationId, expectedActorId: actor.id, legacy: true };
}
const productionAdapter: ContentMutationAdapter = {
async execute(operation, input, context) {
const { contentProductionMutationAdapter } = await import(
"./mutations-production"
);
return contentProductionMutationAdapter.execute(operation, input, context);
},
};
export const contentMutationService = createContentMutationService(
productionAdapter,
async () => {
const { getHousekeepingCapabilityContext } = await import(
"../../../foundation/server-capability-context"
);
return getHousekeepingCapabilityContext();
},
);