Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/content/services/mutation-runtime-database.ts
T

1124 lines
32 KiB
TypeScript

import "server-only";
import { eq, type SQL, 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 hasOwn(data: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(data, key);
}
function booleanValue(value: unknown): boolean {
if (value === true || value === 1 || value === "1" || value === "on")
return true;
if (value === false || value === 0 || value === "0" || value === "")
return false;
throw validation();
}
function requirePatch(values: Record<string, unknown>): void {
if (Object.keys(values).length === 0) throw validation();
}
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 values: Partial<typeof WebsiteArticles.$inferInsert> = {};
if (hasOwn(data, "title")) values.title = text(data.title, 255, true);
if (hasOwn(data, "slug")) {
const rawSlug = text(data.slug, 255);
values.slug = rawSlug
? await uniqueSlug(connection, rawSlug)
: existing.slug;
}
if (hasOwn(data, "shortStory") || hasOwn(data, "summary")) {
values.shortStory = text(data.shortStory ?? data.summary, 255);
}
if (hasOwn(data, "fullStory") || hasOwn(data, "content")) {
values.fullStory = rawText(data.fullStory ?? data.content);
}
if (hasOwn(data, "image")) values.image = text(data.image, 255);
requirePatch(values);
values.updatedAt = new Date();
await connection
.update(WebsiteArticles)
.set(values)
.where(eq(WebsiteArticles.id, id));
const title = values.title ?? existing.title;
const slug = values.slug ?? existing.slug;
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,
};
}
function bannerPatch(data: Record<string, unknown>) {
const values: Partial<typeof WebsiteBanner.$inferInsert> = {};
if (hasOwn(data, "title")) values.title = text(data.title, 255, true);
if (hasOwn(data, "subtitle")) values.subtitle = text(data.subtitle, 500);
if (hasOwn(data, "image")) values.image = text(data.image, 500);
if (hasOwn(data, "link")) values.link = text(data.link, 500);
if (hasOwn(data, "color")) values.color = text(data.color, 20);
if (hasOwn(data, "isActive"))
values.isActive = booleanValue(data.isActive) ? 1 : 0;
if (hasOwn(data, "sortOrder"))
values.sortOrder = nonNegativeInteger(data.sortOrder);
if (hasOwn(data, "startDate"))
values.startDate = data.startDate ? text(data.startDate, 50) : null;
if (hasOwn(data, "endDate"))
values.endDate = data.endDate ? text(data.endDate, 50) : null;
requirePatch(values);
return values;
}
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 = bannerPatch(data);
await connection
.update(WebsiteBanner)
.set(values)
.where(eq(WebsiteBanner.id, id));
return {
before: existing,
after: { id, title: values.title ?? existing.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 values: Partial<typeof Tags.$inferInsert> = {};
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
if (hasOwn(data, "backgroundColor")) {
values.backgroundColor = text(data.backgroundColor, 10, true);
}
requirePatch(values);
values.updatedAt = new Date();
await connection.update(Tags).set(values).where(eq(Tags.id, id));
await activity(
context,
"tag_update",
`Updated tag #${id}${values.name ? ` to ${values.name}` : ""}`,
"tag",
Number(id),
);
return {
before: { id: id.toString() },
after: { id: id.toString(), ...values, updatedAt: undefined },
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 values: Record<string, unknown> = {};
const assignments: SQL[] = [];
if (hasOwn(data, "text")) {
values.text = text(data.text, 255, true);
assignments.push(sql`text = ${values.text}`);
}
if (hasOwn(data, "color")) {
values.color = text(data.color, 32, true);
assignments.push(sql`color = ${values.color}`);
}
if (hasOwn(data, "icon")) {
values.icon = text(data.icon, 255);
assignments.push(sql`icon = ${values.icon}`);
}
if (hasOwn(data, "effect")) {
values.effect = text(data.effect, 255);
assignments.push(sql`effect = ${values.effect}`);
}
if (hasOwn(data, "active")) {
values.active = booleanValue(data.active) ? 1 : 0;
assignments.push(sql`active = ${values.active}`);
}
requirePatch(values);
await connection.execute(
sql`UPDATE custom_prefixes SET ${sql.join(assignments, sql`, `)} WHERE id = ${id}`,
);
return {
before: { id },
after: { id, ...values },
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 };
}
if (action === "create") {
const createValues = {
name: text(data.name, 255, true),
content: canonicalize(rawText(data.content ?? data.answer, 100_000)),
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 (!createValues.content) throw validation();
const [result] = (await connection
.insert(WebsiteHelpCenterCategories)
.values(createValues)) as unknown as [ResultSetHeader];
const id = insertedId(result);
await activity(
context,
"help_create",
`Created help-center entry #${id} (${createValues.name})`,
"help_center_category",
Number(id),
);
return {
before: null,
after: { id, name: createValues.name },
output: { id },
};
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
const values: Partial<typeof WebsiteHelpCenterCategories.$inferInsert> = {};
if (hasOwn(data, "name")) values.name = text(data.name, 255, true);
if (hasOwn(data, "content") || hasOwn(data, "answer")) {
values.content = canonicalize(
rawText(data.content ?? data.answer, 100_000),
);
if (!values.content) throw validation();
}
if (hasOwn(data, "position"))
values.position = positiveInteger(data.position);
for (const key of ["imageUrl", "buttonText", "buttonUrl"] as const) {
if (hasOwn(data, key)) values[key] = text(data[key], 255) || null;
}
for (const [key, fallback] of [
["buttonColor", "#eeb425"],
["buttonBorderColor", "#facc15"],
] as const) {
if (hasOwn(data, key)) values[key] = text(data[key], 16) || fallback;
}
if (hasOwn(data, "smallBox")) values.smallBox = booleanValue(data.smallBox);
requirePatch(values);
await connection
.update(WebsiteHelpCenterCategories)
.set(values)
.where(eq(WebsiteHelpCenterCategories.id, id));
await activity(
context,
"help_update",
"Updated help-center entry #" +
id +
(values.name ? ` (${values.name})` : ""),
"help_center_category",
Number(id),
);
return {
before: { id: id.toString() },
after: { id: id.toString(), ...values },
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() },
};
}
if (action === "create") {
const createValues = {
title: text(data.title, 255, true),
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(),
};
const now = new Date();
const [result] = (await connection
.insert(WebsiteWriteableBoxes)
.values({ ...createValues, createdAt: now })) as unknown as [
ResultSetHeader,
];
const id = insertedId(result);
await activity(
context,
"writeable_box_create",
`Created writeable box ${createValues.title} (#${id})`,
"writeable_box",
Number(id),
);
return {
before: null,
after: { id, title: createValues.title },
output: { id },
};
}
if (action !== "update") throw validation();
const id = positiveBigInt(data.id);
const values: Partial<typeof WebsiteWriteableBoxes.$inferInsert> = {};
if (hasOwn(data, "title")) values.title = text(data.title, 255, true);
if (hasOwn(data, "icon")) values.icon = text(data.icon, 255) || null;
if (hasOwn(data, "content")) values.content = rawText(data.content, 100_000);
if (hasOwn(data, "position"))
values.position = nonNegativeInteger(data.position);
if (hasOwn(data, "isActive")) values.isActive = booleanValue(data.isActive);
requirePatch(values);
values.updatedAt = new Date();
await connection
.update(WebsiteWriteableBoxes)
.set(values)
.where(eq(WebsiteWriteableBoxes.id, id));
await activity(
context,
"writeable_box_update",
"Updated writeable box #" + id + (values.title ? ` (${values.title})` : ""),
"writeable_box",
Number(id),
);
return {
before: { id: id.toString() },
after: { id: id.toString(), ...values, updatedAt: undefined },
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 };
}
if (action === "create") {
const name = text(data.name, 255, true);
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: booleanValue(data.isActive ?? false),
};
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);
const values: Partial<typeof EmailTemplates.$inferInsert> = {};
if (hasOwn(data, "subject")) values.subject = text(data.subject, 255, true);
if (hasOwn(data, "body")) {
values.body = rawText(data.body, 500_000);
if (!values.body) throw validation();
}
if (hasOwn(data, "variables"))
values.variables = text(data.variables, 20_000) || null;
if (hasOwn(data, "isActive")) values.isActive = booleanValue(data.isActive);
requirePatch(values);
await connection
.update(EmailTemplates)
.set(values)
.where(eq(EmailTemplates.id, id));
return {
before: { id: id.toString() },
after: { id: id.toString(), ...values },
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;
}