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