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
@@ -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();
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user