532 lines
14 KiB
TypeScript
532 lines
14 KiB
TypeScript
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<T> {
|
|
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<HousekeepingResult<Page<PeopleUserSummary>>>;
|
|
user(id: number): Promise<HousekeepingResult<PeopleUserDetail>>;
|
|
queue(): Promise<HousekeepingResult<PeopleQueueSnapshot>>;
|
|
}
|
|
|
|
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<T>(
|
|
rows: readonly T[],
|
|
total: number,
|
|
input: NormalizedListInput,
|
|
): Page<T> {
|
|
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);
|
|
}
|