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 { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw validation(); } return value as Record; } 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, 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): 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 { await logStaffActivity({ staffId: context.capability.actor.id, action, description, ...(targetType ? { targetType } : {}), ...(targetId === undefined ? {} : { targetId }), }); } async function uniqueSlug( databaseConnection: ContentDatabase, value: string, ): Promise { 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 { 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 = {}; 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 { 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) { 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) { const values: Partial = {}; 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 { 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 { 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; 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 { 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; 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 { 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 { 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 { 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; 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 { 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; 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 { 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 = {}; 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 { 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 = {}; 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 { 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 { const settings = record(record(input).settings ?? input); const accepted: Record = {}; 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 { 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 = {}; 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 { 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 = {}; 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 { 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 = {}; 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 > > = { "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 { const handler = DATABASE_HANDLERS[operation]; return handler ? handler(input, context, transaction) : null; }