Files
buzz/lib/api-services.ts

309 lines
9.1 KiB
TypeScript

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<number>;
uidExists: (uid: string, excludeEditToken?: string) => Promise<boolean>;
characterExists: (character: string) => Promise<boolean>;
insert: (data: ArtifactSubmissionData) => Promise<ArtifactSubmissionResult>;
replace: (
edit: ArtifactEdit,
data: ArtifactSubmissionData,
editToken: string,
) => Promise<ArtifactSubmissionResult | undefined>;
createEditToken: () => string;
afterSubmit: () => void | Promise<void>;
};
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<string, FormDataEntryValue>,
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<unknown>;
};
async function requireAdmin(deps: AdminDependencies) {
if (!(await deps.adminCheck())) throw "Unauthorized";
}
export type SetLimitDependencies = AdminDependencies & {
persist: (limit: number) => Promise<void>;
afterSet: (limit: number) => void | Promise<void>;
};
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<StoredTierlistState | undefined>;
update: (uuid: string, data: TierlistState) => Promise<void>;
insert: (data: InsertTierlistState) => Promise<void>;
getStates: (list: string) => Promise<StoredTierlistState[]>;
afterSet: (
list: string,
states: StoredTierlistState[],
data: TierlistState,
) => void | Promise<void>;
};
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<string, string[]>,
) => Promise<void>;
afterSet: (
list: string,
placements: Record<string, string[]>,
) => void | Promise<void>;
};
const Placements = z.record(nonEmpty, z.array(nonEmpty));
export async function setTierlistPlacementsService(
listInput: string,
placementsInput: Record<string, string[]>,
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<CdnReference[]>,
): Promise<string[]> {
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<Tx> = AdminDependencies & {
transaction: <T>(callback: (tx: Tx) => Promise<T>) => Promise<T>;
findRefs: (id: string, tx: Tx) => Promise<CdnReference[]>;
deleteOne: (id: string, tx: Tx) => Promise<void>;
deleteMany: (ids: string[]) => Promise<void>;
afterDelete: (
deleted: number,
ids: string[],
incomplete: boolean,
) => Promise<void>;
};
export async function deleteCdnService<Tx>(
ids: string[],
force: boolean,
deps: CdnDeleteDependencies<Tx>,
) {
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<string>;
afterUpload: (id: string, name: string | null, size: number) => Promise<void>;
};
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<unknown>;
fetchVersion: () => Promise<unknown>;
setCached: (value: string) => void | Promise<void>;
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;
}