1124 lines
32 KiB
TypeScript
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;
|
|
}
|