feat(housekeeping): model people workflows
This commit is contained in:
1 parent
c325c53774
commit
e3f8b51d31
13 files changed
+3736
No files matched your search
@@ -0,0 +1,408 @@
|
||||
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 | 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[];
|
||||
}
|
||||
|
||||
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 ownerId: number;
|
||||
readonly ownerUsername: string | null;
|
||||
readonly memberCount: number;
|
||||
readonly createdAt: string | null;
|
||||
readonly href: `/ase/people/community/guilds/${number}`;
|
||||
}
|
||||
|
||||
export interface PeopleGuildDetail extends PeopleGuildSummary {
|
||||
readonly roomId: number;
|
||||
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: number;
|
||||
readonly userId: number;
|
||||
readonly username: string | null;
|
||||
readonly rankId: number;
|
||||
readonly content: string;
|
||||
readonly createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface PeopleTeam {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly rank: number;
|
||||
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 PeopleTicketSummary {
|
||||
readonly id: number;
|
||||
readonly subject: string;
|
||||
readonly status: string;
|
||||
readonly priority: string;
|
||||
readonly creatorId: number;
|
||||
readonly creatorUsername: string | null;
|
||||
readonly updatedAt: string | null;
|
||||
readonly href: `/ase/people/support/tickets/${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;
|
||||
}[];
|
||||
}
|
||||
|
||||
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 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;
|
||||
}[];
|
||||
}
|
||||
|
||||
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 | null;
|
||||
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 = 1_000_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",
|
||||
};
|
||||
}
|
||||
|
||||
function compareValues(left: string | number, right: string | number): number {
|
||||
if (typeof left === "number" && typeof right === "number") {
|
||||
return left - right;
|
||||
}
|
||||
return String(left).localeCompare(String(right), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
export function createPeoplePage<T extends { readonly id: number }>(
|
||||
rows: readonly T[],
|
||||
total: number,
|
||||
input: NormalizedListInput,
|
||||
sortValue: (row: T) => string | number,
|
||||
): Page<T> {
|
||||
const items = [...rows]
|
||||
.sort((left, right) => {
|
||||
const primary = compareValues(sortValue(left), sortValue(right));
|
||||
return (
|
||||
(input.order === "desc" ? -primary : primary) || left.id - right.id
|
||||
);
|
||||
})
|
||||
.slice(input.offset, input.offset + input.pageSize);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: boundedInteger(total, rows.length, 0, Number.MAX_SAFE_INTEGER),
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function peopleUserHref(id: number): `/ase/people/users/${number}` {
|
||||
return `/ase/people/users/${id}`;
|
||||
}
|
||||
|
||||
export function peopleGuildHref(
|
||||
id: number,
|
||||
): `/ase/people/community/guilds/${number}` {
|
||||
return `/ase/people/community/guilds/${id}`;
|
||||
}
|
||||
|
||||
export function peopleTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/tickets/${number}` {
|
||||
return `/ase/people/support/tickets/${id}`;
|
||||
}
|
||||
|
||||
export function peopleHelpTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/help-tickets/${number}` {
|
||||
return `/ase/people/support/help-tickets/${id}`;
|
||||
}
|
||||
|
||||
export function peopleCfhHref(
|
||||
id: number,
|
||||
): `/ase/people/moderation/cfh/${number}` {
|
||||
return `/ase/people/moderation/cfh/${id}`;
|
||||
}
|
||||
|
||||
export function normalizePeopleUser(
|
||||
row: PeopleUserRecord,
|
||||
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
|
||||
): PeopleUserSummary {
|
||||
const id = toPeopleNumber(row.id);
|
||||
const bannedUntil = toPeopleNumber(row.bannedUntil, 0);
|
||||
return {
|
||||
id,
|
||||
username: String(row.username ?? ""),
|
||||
rank: toPeopleNumber(row.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: bannedUntil > 0 ? bannedUntil : null,
|
||||
href: peopleUserHref(id),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user