import z from "zod"; import { uidRegex } from "./const"; export type ArtifactSubmissionData = { name: string; uid: string; character: string; comment: string; }; export type ArtifactEdit = { sub: string; token: string }; export type ArtifactSubmissionResult = { id: string; queue: number | null }; export type ArtifactServiceDependencies = { getConfig: () => Promise<{ locked: boolean; limit: number }>; countQueued: () => Promise; uidExists: (uid: string, excludeEditToken?: string) => Promise; characterExists: (character: string) => Promise; insert: (data: ArtifactSubmissionData) => Promise; replace: ( edit: ArtifactEdit, data: ArtifactSubmissionData, editToken: string, ) => Promise; createEditToken: () => string; afterSubmit: () => void | Promise; }; const ArtifactSubmission = z.object( { name: z.string().max(64, "ชื่อยาวเกินไป ต้องไม่เกิน 64 ตัวอักษร"), uid: z.string().regex(uidRegex, "UID ไม่ถูกต้อง ต้องเป็นเลข 9 หรือ 10 หลัก เท่านั้น"), character: z.string(), comment: z.string().max(1024, "ข้อความเพิ่มเติมยาวเกินไป ต้องไม่เกิน 1024 ตัวอักษร"), }, "กรุณากรอกข้อมูลให้ครบถ้วน", ); export async function submitArtifactService( input: Record, edit: ArtifactEdit | undefined, deps: ArtifactServiceDependencies, ) { const config = await deps.getConfig(); if (config.locked) return "ปิดรับลงทะเบียนชั่วคราว เนื่องจากมีผู้ลงจำนวนมาก"; if (!edit) { const count = await deps.countQueued(); if (config.limit !== -1 && count >= config.limit) return `คิวลงทะเบียนเต็มแล้ว (${config.limit} ครั้ง)`; } const parsed = ArtifactSubmission.safeParse(input); if (!parsed.success) return z.prettifyError(parsed.error); const data = parsed.data; if (await deps.uidExists(data.uid, edit?.token)) return "คุณลงทะเบียนไปแล้ว"; if (!(await deps.characterExists(data.character))) return "ไม่พบตัวละครที่เลือก"; const queue = edit ? await deps.replace(edit, data, deps.createEditToken()) : await deps.insert(data); if (!queue) return "คิวนี้แก้ไม่ได้แล้ว"; await deps.afterSubmit(); return queue; } export type AdminDependencies = { adminCheck: () => Promise; }; async function requireAdmin(deps: AdminDependencies) { if (!(await deps.adminCheck())) throw "Unauthorized"; } export type SetLimitDependencies = AdminDependencies & { persist: (limit: number) => Promise; afterSet: (limit: number) => void | Promise; }; export async function setArtifactLimitService( limit: number, deps: SetLimitDependencies, ) { await requireAdmin(deps); if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < -1) throw new TypeError( "Artifact limit must be a finite integer greater than or equal to -1", ); await deps.persist(limit); await deps.afterSet(limit); } export type TierlistState = { uuid?: string; char?: string; ref?: string; list?: string; comment?: string; badges?: string[] | null; }; type StoredTierlistState = TierlistState & { uuid: string; char: string; ref: string; list: string; comment: string; badges: string[] | null; }; type InsertTierlistState = TierlistState & { char: string; ref: string; list: string; }; export type TierlistStateDependencies = AdminDependencies & { find: ( identifier: { uuid: string } | { ref: string; list: string }, ) => Promise; update: (uuid: string, data: TierlistState) => Promise; insert: (data: InsertTierlistState) => Promise; getStates: (list: string) => Promise; afterSet: ( list: string, states: StoredTierlistState[], data: TierlistState, ) => void | Promise; }; const nonEmpty = z.string().min(1); const TierlistStateData = z.object({ uuid: nonEmpty.optional(), char: nonEmpty.optional(), ref: nonEmpty.optional(), list: nonEmpty.optional(), comment: z.string().optional(), badges: z.array(nonEmpty).max(4).nullable().optional(), }); export async function setTierlistStateService( input: TierlistState, deps: TierlistStateDependencies, ) { await requireAdmin(deps); const data = TierlistStateData.parse(input); const identifier = data.uuid ? { uuid: data.uuid } : data.ref && data.list ? { ref: data.ref, list: data.list } : undefined; if (!identifier) throw new TypeError("Tierlist state requires uuid or both ref and list"); const existing = await deps.find(identifier); if (existing) await deps.update(existing.uuid, data); else { const inserted = z .object({ char: nonEmpty, ref: nonEmpty, list: nonEmpty }) .passthrough() .parse(data) as InsertTierlistState; await deps.insert(inserted); } const list = data.list ?? existing?.list; if (!list) throw new TypeError("Tierlist state list is required"); const states = await deps.getStates(list); await deps.afterSet(list, states, data); } export type TierlistPlacementsDependencies = AdminDependencies & { persist: ( list: string, placements: Record, ) => Promise; afterSet: ( list: string, placements: Record, ) => void | Promise; }; const Placements = z.record(nonEmpty, z.array(nonEmpty)); export async function setTierlistPlacementsService( listInput: string, placementsInput: Record, deps: TierlistPlacementsDependencies, ) { await requireAdmin(deps); const list = nonEmpty.parse(listInput); const placements = Placements.parse(placementsInput); const { untiered: _, ...persisted } = placements; await deps.persist(list, persisted); await deps.afterSet(list, placements); } export type CdnReference = { table: string; id: string }; export async function checkCdnRefsService( id: string | string[], findRefs: (id: string) => Promise, ): Promise { const ids = typeof id === "string" ? [id] : id; const refs = await Promise.all( ids.map(async (cdnId) => (await findRefs(cdnId)).map((ref) => `${cdnId}=>${ref.table}#${ref.id}`), ), ); return refs.flat(); } export type CdnDeleteDependencies = AdminDependencies & { transaction: (callback: (tx: Tx) => Promise) => Promise; findRefs: (id: string, tx: Tx) => Promise; deleteOne: (id: string, tx: Tx) => Promise; deleteMany: (ids: string[]) => Promise; afterDelete: ( deleted: number, ids: string[], incomplete: boolean, ) => Promise; }; export async function deleteCdnService( ids: string[], force: boolean, deps: CdnDeleteDependencies, ) { await requireAdmin(deps); if (force) { await deps.deleteMany(ids); await deps.afterDelete(ids.length, ids, false); return; } const result = await deps.transaction(async (tx) => { let deleted = 0; for (const id of ids) { const refs = await deps.findRefs(id, tx); if (refs.length) return { id, refs, deleted }; await deps.deleteOne(id, tx); deleted++; } return { deleted }; }); await deps.afterDelete(result.deleted, ids, "id" in result); if ("id" in result && result.refs) { return { id: result.id, refs: result.refs.map((ref) => `${result.id}=>${ref.table}#${ref.id}`), }; } } export type CdnifyDependencies = { insert: (data: { name: string | null; data: Buffer; size: string; type: string; }) => Promise; afterUpload: (id: string, name: string | null, size: number) => Promise; }; export async function cdnifyService( data: Blob | File, name: string | null, deps: CdnifyDependencies, ) { const id = await deps.insert({ name, data: Buffer.from(await data.arrayBuffer()), size: `${data.size}`, type: data.type, }); await deps.afterUpload(id, name, data.size); return id; } export type AmberVhDependencies = { getCached: () => Promise; fetchVersion: () => Promise; setCached: (value: string) => void | Promise; schedule?: (callback: () => void) => void; }; const AmberVersion = z.object({ response: z.number().positive(), data: z.object({ vh: z.string().max(10) }), }); export async function getAmberVhService(deps: AmberVhDependencies) { try { const cached = await deps.getCached(); if (typeof cached === "string" && cached) return cached; } catch {} const { data } = AmberVersion.parse(await deps.fetchVersion()); const schedule = deps.schedule ?? queueMicrotask; schedule(() => { Promise.resolve(deps.setCached(data.vh)).catch(() => {}); }); return data.vh; }