import type { HousekeepingResult } from "../../foundation/contracts"; export interface ListInput { readonly search?: string | null; readonly pageSize?: number | null; readonly offset?: number | null; readonly sort?: string | null; readonly order?: string | null; } export interface NormalizedListInput { readonly search: string; readonly pageSize: number; readonly offset: number; readonly sort: string; readonly order: "asc" | "desc"; } export interface Page { readonly items: readonly T[]; readonly total: number; readonly pageSize: number; readonly offset: number; } export interface PeopleUserRecord { readonly id: unknown; readonly username: unknown; readonly rank: unknown; readonly online: unknown; readonly mail?: unknown; readonly ipCurrent?: unknown; readonly bannedUntil?: unknown; } export interface PeopleUserSummary { readonly id: number; readonly username: string; readonly rank: number; readonly online: boolean; readonly mail: string | null; readonly ipCurrent: string | null; readonly bannedUntil: number | null; readonly href: `/ase/people/users/${number}`; } export interface PeopleSanctionSummary { readonly id: number; readonly kind: string; readonly reason: string; readonly createdAt: string | null; readonly expiresAt: string | 0 | null; readonly active: boolean; } export interface PeopleUserDetail extends PeopleUserSummary { readonly motto: string; readonly look: string; readonly accountCreated: string | null; readonly lastLogin: string | null; readonly sanctions: readonly PeopleSanctionSummary[]; readonly watched: boolean; readonly permission: { readonly rankName: string; readonly ranks: readonly PeoplePermissionRank[]; }; } export interface PeoplePermissionRank { readonly id: number; readonly name: string; } export interface PeopleMultiAccountCluster { readonly key: string; readonly accountCount: number; readonly accounts: readonly Pick< PeopleUserSummary, "id" | "username" | "rank" | "online" | "href" >[]; } export interface PeopleOnlineUser { readonly id: number; readonly username: string; readonly motto: string; readonly look: string; readonly href: `/ase/people/users/${number}`; } export interface PeopleGuildSummary { readonly id: number; readonly name: string; readonly description: string; readonly userId: number; readonly ownerUsername: string | null; readonly roomId: number; readonly memberCount: number; readonly createdAt: string | null; readonly href: `/ase/people/community/guilds/${number}`; } export interface PeopleGuildDetail extends PeopleGuildSummary { readonly threadCount: number; readonly members: readonly { readonly id: number; readonly username: string; readonly level: number; readonly href: `/ase/people/users/${number}`; }[]; } export interface PeopleStaffApplication { readonly id: string; readonly userId: number; readonly username: string | null; readonly rankId: number; readonly content: string; readonly createdAt: string | null; } export interface PeopleTeam { readonly id: string; readonly name: string; readonly rank: string; readonly hidden: boolean; readonly badge: string | null; readonly jobDescription: string | null; } export interface PeopleModerationTeamMember { readonly id: number; readonly username: string; readonly rank: number; readonly openCfh: number; readonly openTickets: number; readonly actionCount: number; readonly href: `/ase/people/users/${number}`; } export interface PeopleQueueSnapshot { readonly tickets: number; readonly helpTickets: number; readonly cfh: number; readonly activeBans: number; } export interface PeopleSupportStaff { readonly id: number; readonly username: string; readonly rank: number; readonly href: `/ase/people/users/${number}`; } export interface PeopleTicketSummary { readonly source: "cms" | "help"; readonly id: number; readonly subject: string; readonly status: string; readonly priority: string; readonly creatorId: number | null; readonly creatorUsername: string | null; readonly updatedAt: string | null; readonly href: | `/ase/people/support/tickets/${number}` | `/ase/people/support/help-tickets/${number}`; } export interface PeopleTicketDeskSummary extends PeopleTicketSummary { readonly source: "cms"; readonly category: string; readonly assigneeId: number | null; readonly messageCount: number; } export interface PeopleTicketDetail extends PeopleTicketSummary { readonly category: string; readonly assigneeId: number | null; readonly messages: readonly { readonly id: number; readonly userId: number; readonly username: string | null; readonly message: string; readonly isStaff: boolean; readonly createdAt: string | null; }[]; readonly queue: PeopleQueueSnapshot; readonly staff: readonly PeopleSupportStaff[]; } export interface PeopleHelpTicketSummary { readonly id: number; readonly title: string; readonly open: boolean; readonly userId: number | null; readonly username: string | null; readonly updatedAt: string | null; readonly replyCount: number; readonly href: `/ase/people/support/help-tickets/${number}`; } export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary { readonly categoryId: number | null; readonly categoryName: string | null; readonly content: string; readonly replies: readonly { readonly id: number; readonly userId: number; readonly username: string | null; readonly content: string; readonly createdAt: string | null; }[]; readonly queue: PeopleQueueSnapshot; readonly staff: readonly PeopleSupportStaff[]; readonly activeBan: PeopleBanSummary | null; } export interface PeopleTicketTemplate { readonly id: number; readonly title: string; readonly content: string; readonly category: string; readonly sortOrder: number; } export interface PeopleCfhSummary { readonly id: number; readonly state: number; readonly senderId: number; readonly senderUsername: string | null; readonly reportedId: number; readonly reportedUsername: string | null; readonly moderatorId: number; readonly issue: string; readonly createdAt: string | null; readonly href: `/ase/people/moderation/cfh/${number}`; } export interface PeopleCfhDetail extends PeopleCfhSummary { readonly roomId: number; readonly activeBan: PeopleBanSummary | null; } export interface PeopleBanSummary { readonly id: number; readonly userId: number; readonly username: string | null; readonly staffId: number; readonly staffUsername: string | null; readonly type: string; readonly reason: string; readonly createdAt: string | null; readonly expiresAt: string | 0; readonly active: boolean; } export interface PeopleModerationSnapshot extends PeopleQueueSnapshot { readonly staffOnline: number; readonly recentActions: number; } export interface PeopleIpRule { readonly id: number; readonly ip: string; readonly note: string | null; readonly createdAt: string | null; } export interface PeopleVpnSetting { readonly key: string; readonly value: string; } export interface PeopleWordFilterEntry { readonly id: number; readonly word: string; readonly replacement: string | null; } export interface PeopleQueries { users(input: ListInput): Promise>>; user(id: number): Promise>; queue(): Promise>; } const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; const MAX_OFFSET = 10_000; const MAX_SEARCH_LENGTH = 128; function boundedInteger( value: number | null | undefined, fallback: number, minimum: number, maximum: number, ): number { if (!Number.isFinite(value)) return fallback; return Math.min(Math.max(Math.trunc(value as number), minimum), maximum); } export function normalizePeopleListInput( input: ListInput, allowedSorts: readonly string[], defaultSort: string, ): NormalizedListInput { const safeDefault = allowedSorts.includes(defaultSort) ? defaultSort : (allowedSorts[0] ?? "id"); const requestedSort = input.sort?.trim() ?? ""; const search = Array.from(input.search ?? "") .filter((character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint >= 32 && codePoint !== 127; }) .join("") .trim() .slice(0, MAX_SEARCH_LENGTH); return { pageSize: boundedInteger( input.pageSize, DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE, ), offset: boundedInteger(input.offset, 0, 0, MAX_OFFSET), search, sort: allowedSorts.includes(requestedSort) ? requestedSort : safeDefault, order: input.order === "desc" ? "desc" : "asc", }; } export function createPeoplePage( rows: readonly T[], total: number, input: NormalizedListInput, ): Page { if ( !Number.isSafeInteger(total) || total < 0 || rows.length > input.pageSize ) { throw new Error("invalid People page"); } assertPeopleSerializable(rows); return { items: [...rows], total, pageSize: input.pageSize, offset: input.offset, }; } export function toPeopleNumber(value: unknown, fallback = 0): number { const parsed = typeof value === "bigint" ? Number(value) : Number(value); return Number.isSafeInteger(parsed) ? parsed : fallback; } function positiveSafeInteger(value: unknown): number { const parsed = typeof value === "bigint" ? Number(value) : Number(value); if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new Error("invalid People identifier"); } return parsed; } function nonNegativeSafeInteger(value: unknown): number { const parsed = typeof value === "bigint" ? Number(value) : Number(value); if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new Error("invalid People number"); } return parsed; } export function toPeopleIsoDate(value: unknown): string | null { if (value === null || value === undefined || value === "") return null; let date: Date; if (value instanceof Date) { date = value; } else { const numeric = Number(value); date = Number.isFinite(numeric) ? new Date( Math.abs(numeric) < 1_000_000_000_000 ? numeric * 1000 : numeric, ) : new Date(String(value)); } if (!Number.isFinite(date.getTime())) throw new Error("invalid People date"); return date.toISOString(); } function nullableString(value: unknown): string | null { if (value === null || value === undefined) return null; if (typeof value !== "string") throw new Error("invalid People string"); const normalized = value.trim(); return normalized.length > 0 ? normalized : null; } export function peopleUserHref(id: number): `/ase/people/users/${number}` { positiveSafeInteger(id); return `/ase/people/users/${id}`; } export function peopleGuildHref( id: number, ): `/ase/people/community/guilds/${number}` { positiveSafeInteger(id); return `/ase/people/community/guilds/${id}`; } export function peopleTicketHref( id: number, ): `/ase/people/support/tickets/${number}` { positiveSafeInteger(id); return `/ase/people/support/tickets/${id}`; } export function peopleHelpTicketHref( id: number, ): `/ase/people/support/help-tickets/${number}` { positiveSafeInteger(id); return `/ase/people/support/help-tickets/${id}`; } export function peopleCfhHref( id: number, ): `/ase/people/moderation/cfh/${number}` { positiveSafeInteger(id); return `/ase/people/moderation/cfh/${id}`; } export function normalizePeopleUser( row: PeopleUserRecord, projection: { readonly includeMail: boolean; readonly includeIp: boolean }, ): PeopleUserSummary { const id = positiveSafeInteger(row.id); if (typeof row.username !== "string" || row.username.trim().length === 0) { throw new Error("invalid People username"); } const rank = nonNegativeSafeInteger(row.rank); const bannedUntil = row.bannedUntil === null || row.bannedUntil === undefined ? null : nonNegativeSafeInteger(row.bannedUntil); const onlineValues = new Set([true, false, 0, 1, "0", "1", "true", "false"]); if (!onlineValues.has(row.online as never)) throw new Error("invalid People online value"); return { id, username: row.username, rank, online: row.online === true || row.online === 1 || row.online === "1" || row.online === "true", mail: projection.includeMail ? nullableString(row.mail) : null, ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null, bannedUntil, href: peopleUserHref(id), }; } const DATE_KEYS = new Set([ "accountCreated", "createdAt", "expiresAt", "lastLogin", "updatedAt", ]); export function assertPeopleSerializable(value: unknown): void { function permitsZeroSentinel(parent: unknown, key: string): boolean { if (typeof parent !== "object" || parent === null) return false; const href = Reflect.get(parent, "href"); if (typeof href !== "string") return false; if (href.startsWith("/ase/people/community/guilds/")) { return key === "userId" || key === "roomId"; } if (href.startsWith("/ase/people/moderation/cfh/")) { return ( key === "senderId" || key === "reportedId" || key === "roomId" || key === "moderatorId" ); } return false; } function visit(current: unknown, key = "", parent?: unknown): void { if ( current === null || typeof current === "string" || typeof current === "boolean" ) { if ( typeof current === "string" && DATE_KEYS.has(key) && !Number.isFinite(Date.parse(current)) ) { throw new Error("invalid People date string"); } return; } if (typeof current === "number") { if (!Number.isSafeInteger(current)) throw new Error("invalid People number"); if ( (key === "id" || key.endsWith("Id")) && current <= 0 && !(current === 0 && permitsZeroSentinel(parent, key)) ) { throw new Error("invalid People identifier"); } return; } if (Array.isArray(current)) { for (const item of current) visit(item, "", current); return; } if (typeof current !== "object" || current instanceof Date) { throw new Error("non-serializable People value"); } for (const [childKey, child] of Object.entries(current)) { if (child === undefined) throw new Error("undefined People value"); visit(child, childKey, current); } } visit(value); }