From e3f8b51d312891e1a62ead74110775d9f84c854b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 29 Aug 2026 01:17:51 +0200 Subject: [PATCH] feat(housekeeping): model people workflows --- .../task-11-report.md | 133 +++++ .../domains/people/models.test.ts | 153 +++++ .../housekeeping/domains/people/models.ts | 408 ++++++++++++++ .../domains/people/queries/community.ts | 289 ++++++++++ .../domains/people/queries/moderation.ts | 533 ++++++++++++++++++ .../people-adapters-production.test.ts | 148 +++++ .../people/queries/people-queries.test.ts | 492 ++++++++++++++++ .../domains/people/queries/staff.ts | 281 +++++++++ .../domains/people/queries/support.ts | 505 +++++++++++++++++ .../domains/people/queries/users.ts | 424 ++++++++++++++ .../domains/people/routes.test.ts | 169 ++++++ .../housekeeping/domains/people/routes.ts | 159 ++++++ .../foundation-source-contract.test.ts | 42 ++ 13 files changed, 3736 insertions(+) create mode 100644 .superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md create mode 100644 src/features/housekeeping/domains/people/models.test.ts create mode 100644 src/features/housekeeping/domains/people/models.ts create mode 100644 src/features/housekeeping/domains/people/queries/community.ts create mode 100644 src/features/housekeeping/domains/people/queries/moderation.ts create mode 100644 src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts create mode 100644 src/features/housekeeping/domains/people/queries/people-queries.test.ts create mode 100644 src/features/housekeeping/domains/people/queries/staff.ts create mode 100644 src/features/housekeeping/domains/people/queries/support.ts create mode 100644 src/features/housekeeping/domains/people/queries/users.ts create mode 100644 src/features/housekeeping/domains/people/routes.test.ts create mode 100644 src/features/housekeeping/domains/people/routes.ts diff --git a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md new file mode 100644 index 00000000..695d39d3 --- /dev/null +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md @@ -0,0 +1,133 @@ +# Task 11 — People workflow read models + +Status: DONE + +## Delivered scope + +- Added the exact 24-route People catalog covering the 39 migration-matrix entries across users, multi-account review, online/community, guilds, staff applications/teams, support tickets/help tickets, CFH, moderation overview, bans, IP rules, VPN settings, and word filter workflows. +- Added canonical, JSON-serializable People DTOs, `/ase/people` link builders, bounded list normalization, and stable sorting with numeric-ID tie breaking. +- Added injected query factories and narrow server-only production adapters for user/detail, community/guild, staff/applications/teams, support queues/tickets/help/CFH, and moderation/bans/sanctions sources. +- Reused foundation `HousekeepingResult`, error codes, capability context, authorization, and canonical href contracts. People-local `ListInput` and `Page` were added because no shared foundation equivalents exist in this checkout. +- Kept the People manifest, global handlers, pages, mutations, providers, widgets, search, and inbox unchanged for Task 12. + +## Security and behavior decisions + +- User mail and current IP remain independently nullable fields. Each is projected only when the capability context contains the existing `PERMS.USERS_VIEW`; `PERMS.MOD_USERS_VIEW` alone receives the safe base projection with both values set to `null`, and a context with neither permission is forbidden. +- No new ACL slug or rank threshold was introduced. Staff filtering reuses the existing `getMinStaffRank()` source. +- The production user selection is explicit and excludes passwords, authentication tickets, secrets, and two-factor material. VPN settings intentionally exclude `vpn_api_key`. +- Adapters fail closed. Missing detail entities map to `NOT_FOUND`; invalid identifiers to `VALIDATION`; adapter and count failures to `DEPENDENCY_UNAVAILABLE`. No partial-result shape is returned because no People DTO explicitly names failed sources. +- Pagination clamps page size to 100 and offset to 1,000,000. Production list adapters fetch the full prefix required for in-memory stable sorting/pagination, avoiding double-offset truncation. + +## Strict TDD evidence + +### Cycle 1 — exact route catalog + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts +Test Files 1 failed +Error: Cannot find module './routes' +``` + +GREEN: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts +Test Files 1 passed (1) +Tests 3 passed (3) +``` + +### Cycle 2 — canonical models and normalizers + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts +Test Files 1 failed +Error: Cannot find module './models' +``` + +GREEN: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts +Test Files 1 passed (1) +Tests 5 passed (5) +``` + +### Cycle 3 — injected-adapter read queries + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts +Test Files 1 failed +Error: Cannot find module './community' +``` + +GREEN: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts +Test Files 1 passed (1) +Tests 10 passed (10) +``` + +Production-source contract RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +Test Files 1 failed (1) +Tests 2 failed (2) +Reason: production adapters and buildPeopleUserSelection were not yet exported. +``` + +Pagination regression RED after adding the production contract fixture: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +Test Files 1 failed (1) +Tests 1 failed | 2 passed (3) +Expected ["203.0.113.1", "203.0.113.2"], received ["203.0.113.2"]. +``` + +GREEN after the minimal prefix-fetch correction: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +Test Files 1 passed (1) +Tests 3 passed (3) +``` + +The query tests cover adversarial page size/offset/search, stable tie sorting, empty/missing entities, adapter and count failures, PII capability combinations, serializable DTOs, explicit source projection, and production pagination. + +## Verification + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +Test Files 4 passed (4) +Tests 21 passed (21) + +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/authorization.test.ts src/features/housekeeping/foundation/capability-context.test.ts src/features/housekeeping/foundation/contracts/contracts.test.ts +Test Files 8 passed (8) +Tests 65 passed (65) + +pnpm test:housekeeping +Test Files 49 passed (49) +Tests 416 passed (416) + +pnpm typecheck +tsc --noEmit +Exit 0 + +pnpm exec biome check --formatter-enabled=false <12 exact Task 11 TypeScript files> +Checked 12 files. No fixes applied. + +git diff --check +Exit 0 +``` + +Both `pnpm test:housekeeping` and `pnpm typecheck` emitted the environment warning: the repository requires Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. Tests and typecheck still exited successfully. + +No database operation, deployment, push, or pull-request update was performed. diff --git a/src/features/housekeeping/domains/people/models.test.ts b/src/features/housekeeping/domains/people/models.test.ts new file mode 100644 index 00000000..477ed358 --- /dev/null +++ b/src/features/housekeeping/domains/people/models.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { + createPeoplePage, + normalizePeopleListInput, + normalizePeopleUser, + type PeopleUserDetail, + peopleCfhHref, + peopleGuildHref, + peopleHelpTicketHref, + peopleTicketHref, + peopleUserHref, + toPeopleIsoDate, +} from "./models"; + +describe("People list normalization", () => { + it("clamps adversarial page size and offset values and bounds search text", () => { + expect( + normalizePeopleListInput( + { + pageSize: 99_999, + offset: -50, + search: ` alice\u0000${"x".repeat(300)} `, + sort: "not-a-column", + order: "sideways", + }, + ["id", "username"], + "username", + ), + ).toEqual({ + pageSize: 100, + offset: 0, + search: `alicex${"x".repeat(122)}`, + sort: "username", + order: "asc", + }); + + expect( + normalizePeopleListInput( + { pageSize: Number.NaN, offset: Number.POSITIVE_INFINITY }, + ["id"], + "id", + ), + ).toMatchObject({ pageSize: 20, offset: 0 }); + }); + + it("sorts a page deterministically with an id tie breaker", () => { + const input = normalizePeopleListInput( + { pageSize: 2, offset: 1, sort: "username", order: "asc" }, + ["id", "username"], + "id", + ); + const page = createPeoplePage( + [ + { id: 4, username: "Bob" }, + { id: 3, username: "alice" }, + { id: 1, username: "Alice" }, + { id: 2, username: "alice" }, + ], + 4, + input, + (row) => row.username, + ); + + expect(page).toEqual({ + items: [ + { id: 2, username: "alice" }, + { id: 3, username: "alice" }, + ], + total: 4, + pageSize: 2, + offset: 1, + }); + }); +}); + +describe("People canonical models", () => { + const rawUser = { + id: 7n, + username: "Alice", + rank: "5", + online: "1", + mail: "alice@example.test", + ipCurrent: "203.0.113.9", + bannedUntil: "1700000000", + }; + + it("projects mail and current IP independently and never substitutes a redaction", () => { + expect( + normalizePeopleUser(rawUser, { includeMail: false, includeIp: false }), + ).toEqual({ + id: 7, + username: "Alice", + rank: 5, + online: true, + mail: null, + ipCurrent: null, + bannedUntil: 1_700_000_000, + href: "/ase/people/users/7", + }); + expect( + normalizePeopleUser(rawUser, { includeMail: true, includeIp: false }), + ).toMatchObject({ mail: "alice@example.test", ipCurrent: null }); + expect( + normalizePeopleUser(rawUser, { includeMail: false, includeIp: true }), + ).toMatchObject({ mail: null, ipCurrent: "203.0.113.9" }); + }); + + it("normalizes BigInt and Date values to JSON-safe DTO primitives", () => { + const detail: PeopleUserDetail = { + ...normalizePeopleUser(rawUser, { + includeMail: true, + includeIp: true, + }), + motto: "Hello", + look: "hd-180-1", + accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")), + lastLogin: toPeopleIsoDate(1_700_000_000), + sanctions: [], + }; + + expect(JSON.parse(JSON.stringify(detail))).toEqual({ + id: 7, + username: "Alice", + rank: 5, + online: true, + mail: "alice@example.test", + ipCurrent: "203.0.113.9", + bannedUntil: 1_700_000_000, + href: "/ase/people/users/7", + motto: "Hello", + look: "hd-180-1", + accountCreated: "2026-08-29T10:20:30.000Z", + lastLogin: "2023-11-14T22:13:20.000Z", + sanctions: [], + }); + }); + + it("builds canonical People entity links only", () => { + expect([ + peopleUserHref(4), + peopleGuildHref(5), + peopleTicketHref(6), + peopleHelpTicketHref(7), + peopleCfhHref(8), + ]).toEqual([ + "/ase/people/users/4", + "/ase/people/community/guilds/5", + "/ase/people/support/tickets/6", + "/ase/people/support/help-tickets/7", + "/ase/people/moderation/cfh/8", + ]); + }); +}); diff --git a/src/features/housekeeping/domains/people/models.ts b/src/features/housekeeping/domains/people/models.ts new file mode 100644 index 00000000..1fee726e --- /dev/null +++ b/src/features/housekeeping/domains/people/models.ts @@ -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 { + 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>>; + user(id: number): Promise>; + queue(): Promise>; +} + +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( + rows: readonly T[], + total: number, + input: NormalizedListInput, + sortValue: (row: T) => string | number, +): Page { + 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), + }; +} diff --git a/src/features/housekeeping/domains/people/queries/community.ts b/src/features/housekeeping/domains/people/queries/community.ts new file mode 100644 index 00000000..ce2d3a16 --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/community.ts @@ -0,0 +1,289 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import { + createPeoplePage, + type ListInput, + normalizePeopleListInput, + type Page, + type PeopleGuildDetail, + type PeopleGuildSummary, + type PeopleOnlineUser, + peopleGuildHref, + peopleUserHref, + toPeopleIsoDate, +} from "../models"; + +export interface PeopleCommunityAdapters { + loadOnline(input: ReturnType): Promise<{ + readonly rows: readonly PeopleOnlineUser[]; + readonly total: number; + }>; + loadGuilds(input: ReturnType): Promise<{ + readonly rows: readonly PeopleGuildSummary[]; + readonly total: number; + }>; + loadGuild(id: number): Promise; +} + +export type PeopleCommunityQueryInput = + | { readonly routeId: "people.community.online"; readonly list: ListInput } + | { readonly routeId: "people.community.guilds"; readonly list: ListInput } + | { readonly routeId: "people.community.guild-detail"; readonly id: number }; + +export type PeopleCommunityQueryData = + | { readonly kind: "online"; readonly page: Page } + | { readonly kind: "guilds"; readonly page: Page } + | { readonly kind: "guild"; readonly guild: PeopleGuildDetail }; + +function unavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +export function createPeopleCommunityQuery( + adapters: PeopleCommunityAdapters, +): HousekeepingQuery { + return { + id: "people.community.query", + owner: "people", + capability: anyCapability(PERMS.USERS_VIEW), + async run(context, input) { + const authorization = authorizeHousekeeping( + context, + anyCapability(PERMS.USERS_VIEW), + ); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + if (input.routeId === "people.community.guild-detail") { + if (!Number.isSafeInteger(input.id) || input.id <= 0) { + return fail( + "VALIDATION", + "errors.housekeeping.validation", + correlationId, + { id: ["invalid"] }, + ); + } + try { + const guild = await adapters.loadGuild(input.id); + return guild === null + ? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId) + : ok({ kind: "guild" as const, guild }, correlationId); + } catch { + return unavailable(correlationId); + } + } + + const list = normalizePeopleListInput( + input.list, + ["id", "username", "name"], + "id", + ); + try { + if (input.routeId === "people.community.online") { + const result = await adapters.loadOnline(list); + return ok( + { + kind: "online" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "username" ? row.username : row.id, + ), + }, + correlationId, + ); + } + + const result = await adapters.loadGuilds(list); + return ok( + { + kind: "guilds" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "name" ? row.name : row.id, + ), + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + }, + }; +} + +function resultRows(result: unknown): T[] { + if (!Array.isArray(result)) return []; + return Array.isArray(result[0]) ? (result[0] as T[]) : []; +} + +export const peopleCommunityAdapters: PeopleCommunityAdapters = { + async loadOnline(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`online = '1' AND (username LIKE ${pattern} OR motto LIKE ${pattern})` + : sql`online = '1'`; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT id, username, motto, look + FROM users + WHERE ${where} + ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${ + input.order === "desc" ? sql`DESC` : sql`ASC` + }, id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`), + ]); + const rows = resultRows<{ + id: number; + username: string; + motto: string; + look: string; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + username: row.username, + motto: row.motto, + look: row.look, + href: peopleUserHref(Number(row.id)), + })); + const total = Number( + resultRows<{ total: number }>(countResult)[0]?.total ?? 0, + ); + return { rows, total }; + }, + async loadGuilds(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE g.name LIKE ${pattern} OR g.description LIKE ${pattern} OR u.username LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT g.id, g.name, g.description, g.user_id AS ownerId, + u.username AS ownerUsername, g.date_created AS createdAt, + COUNT(gm.id) AS memberCount + FROM guilds g + LEFT JOIN users u ON u.id = g.user_id + LEFT JOIN guilds_members gm ON gm.guild_id = g.id + ${where} + GROUP BY g.id, g.name, g.description, g.user_id, u.username, g.date_created + ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${ + input.order === "desc" ? sql`DESC` : sql`ASC` + }, g.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total + FROM guilds g LEFT JOIN users u ON u.id = g.user_id + ${where} + `), + ]); + const rows = resultRows<{ + id: number; + name: string; + description: string; + ownerId: number; + ownerUsername: string | null; + memberCount: number; + createdAt: number; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + name: row.name, + description: row.description, + ownerId: Number(row.ownerId), + ownerUsername: row.ownerUsername, + memberCount: Number(row.memberCount), + createdAt: toPeopleIsoDate(row.createdAt), + href: peopleGuildHref(Number(row.id)), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadGuild(id) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const [guildResult, membersResult, threadsResult] = await Promise.all([ + db.execute(sql` + SELECT g.id, g.name, g.description, g.user_id AS ownerId, + u.username AS ownerUsername, g.room_id AS roomId, + g.date_created AS createdAt, COUNT(gm.id) AS memberCount + FROM guilds g + LEFT JOIN users u ON u.id = g.user_id + LEFT JOIN guilds_members gm ON gm.guild_id = g.id + WHERE g.id = ${id} + GROUP BY g.id, g.name, g.description, g.user_id, u.username, g.room_id, g.date_created + LIMIT 1 + `), + db.execute(sql` + SELECT u.id, u.username, gm.level_id AS level + FROM guilds_members gm + JOIN users u ON u.id = gm.user_id + WHERE gm.guild_id = ${id} + ORDER BY gm.level_id DESC, u.username ASC, u.id ASC + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM guilds_forums_threads WHERE guild_id = ${id} + `), + ]); + const row = resultRows<{ + id: number; + name: string; + description: string; + ownerId: number; + ownerUsername: string | null; + roomId: number; + createdAt: number; + memberCount: number; + }>(guildResult)[0]; + if (!row) return null; + return { + id: Number(row.id), + name: row.name, + description: row.description, + ownerId: Number(row.ownerId), + ownerUsername: row.ownerUsername, + memberCount: Number(row.memberCount), + createdAt: toPeopleIsoDate(row.createdAt), + href: peopleGuildHref(Number(row.id)), + roomId: Number(row.roomId), + threadCount: Number( + resultRows<{ total: number }>(threadsResult)[0]?.total ?? 0, + ), + members: resultRows<{ + id: number; + username: string; + level: number; + }>(membersResult).map((member) => ({ + id: Number(member.id), + username: member.username, + level: Number(member.level), + href: peopleUserHref(Number(member.id)), + })), + }; + }, +}; + +export const peopleCommunityQuery = createPeopleCommunityQuery( + peopleCommunityAdapters, +); diff --git a/src/features/housekeeping/domains/people/queries/moderation.ts b/src/features/housekeeping/domains/people/queries/moderation.ts new file mode 100644 index 00000000..6369daab --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/moderation.ts @@ -0,0 +1,533 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import { + createPeoplePage, + type ListInput, + normalizePeopleListInput, + type Page, + type PeopleBanSummary, + type PeopleCfhDetail, + type PeopleCfhSummary, + type PeopleIpRule, + type PeopleModerationSnapshot, + type PeopleVpnSetting, + type PeopleWordFilterEntry, + peopleCfhHref, + toPeopleIsoDate, +} from "../models"; + +interface ModerationRows { + readonly rows: readonly T[]; + readonly total: number; +} + +export interface PeopleModerationAdapters { + loadOverview(): Promise; + loadCfh( + input: ReturnType, + ): Promise>; + loadCfhDetail(id: number): Promise; + loadBans( + input: ReturnType, + ): Promise>; + loadIpRules(): Promise<{ + readonly blacklist: readonly PeopleIpRule[]; + readonly whitelist: readonly PeopleIpRule[]; + }>; + loadVpnSettings(): Promise; + loadWordFilter( + input: ReturnType, + ): Promise>; +} + +export type PeopleModerationQueryInput = + | { readonly routeId: "people.moderation.overview" } + | { readonly routeId: "people.moderation.cfh"; readonly list: ListInput } + | { readonly routeId: "people.moderation.cfh-detail"; readonly id: number } + | { readonly routeId: "people.moderation.bans"; readonly list: ListInput } + | { readonly routeId: "people.moderation.ip" } + | { readonly routeId: "people.moderation.vpn" } + | { + readonly routeId: "people.moderation.word-filter"; + readonly list: ListInput; + }; + +export type PeopleModerationQueryData = + | { readonly kind: "overview"; readonly snapshot: PeopleModerationSnapshot } + | { readonly kind: "cfh"; readonly page: Page } + | { readonly kind: "cfh-detail"; readonly ticket: PeopleCfhDetail } + | { readonly kind: "bans"; readonly page: Page } + | { + readonly kind: "ip-rules"; + readonly blacklist: readonly PeopleIpRule[]; + readonly whitelist: readonly PeopleIpRule[]; + } + | { readonly kind: "vpn"; readonly settings: readonly PeopleVpnSetting[] } + | { + readonly kind: "word-filter"; + readonly page: Page; + }; + +const broadCapability = anyCapability( + PERMS.MODERATION_VIEW, + PERMS.MOD_CFH_VIEW, + PERMS.BANS_VIEW, + PERMS.MOD_BANS_VIEW, + PERMS.SETTINGS_VIEW, + PERMS.WORDFILTER_VIEW, +); + +function routeCapability(routeId: PeopleModerationQueryInput["routeId"]) { + if ( + routeId === "people.moderation.cfh" || + routeId === "people.moderation.cfh-detail" + ) { + return anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW); + } + if (routeId === "people.moderation.bans") { + return anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW); + } + if ( + routeId === "people.moderation.ip" || + routeId === "people.moderation.vpn" + ) { + return anyCapability(PERMS.SETTINGS_VIEW); + } + if (routeId === "people.moderation.word-filter") { + return anyCapability(PERMS.WORDFILTER_VIEW); + } + return anyCapability( + PERMS.MODERATION_VIEW, + PERMS.MOD_CFH_VIEW, + PERMS.MOD_ACTIONS, + PERMS.MODERATION_EDIT, + PERMS.MOD_BANS_VIEW, + PERMS.BANS_VIEW, + PERMS.MOD_TICKETS_VIEW, + PERMS.TICKETS_VIEW, + PERMS.MOD_TEAM_VIEW, + PERMS.MOD_USERS_VIEW, + PERMS.USERS_VIEW, + ); +} + +export function createPeopleModerationQuery( + adapters: PeopleModerationAdapters, +): HousekeepingQuery { + return { + id: "people.moderation.query", + owner: "people", + capability: broadCapability, + async run(context, input) { + const authorization = authorizeHousekeeping( + context, + routeCapability(input.routeId), + ); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + try { + if (input.routeId === "people.moderation.overview") { + return ok( + { + kind: "overview" as const, + snapshot: await adapters.loadOverview(), + }, + correlationId, + ); + } + if (input.routeId === "people.moderation.cfh-detail") { + if (!Number.isSafeInteger(input.id) || input.id <= 0) { + return fail( + "VALIDATION", + "errors.housekeeping.validation", + correlationId, + { id: ["invalid"] }, + ); + } + const ticket = await adapters.loadCfhDetail(input.id); + return ticket === null + ? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId) + : ok({ kind: "cfh-detail" as const, ticket }, correlationId); + } + if (input.routeId === "people.moderation.ip") { + const rules = await adapters.loadIpRules(); + return ok({ kind: "ip-rules" as const, ...rules }, correlationId); + } + if (input.routeId === "people.moderation.vpn") { + return ok( + { + kind: "vpn" as const, + settings: await adapters.loadVpnSettings(), + }, + correlationId, + ); + } + + const list = normalizePeopleListInput( + input.list, + ["id", "username", "createdAt", "word"], + "id", + ); + if (input.routeId === "people.moderation.cfh") { + const result = await adapters.loadCfh(list); + return ok( + { + kind: "cfh" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "username" + ? (row.reportedUsername ?? "") + : row.id, + ), + }, + correlationId, + ); + } + if (input.routeId === "people.moderation.bans") { + const result = await adapters.loadBans(list); + return ok( + { + kind: "bans" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "username" ? (row.username ?? "") : row.id, + ), + }, + correlationId, + ); + } + const result = await adapters.loadWordFilter(list); + return ok( + { + kind: "word-filter" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "word" ? row.word : row.id, + ), + }, + correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); + } + }, + }; +} + +function resultRows(result: unknown): T[] { + if (!Array.isArray(result)) return []; + return Array.isArray(result[0]) ? (result[0] as T[]) : []; +} + +export const peopleModerationAdapters: PeopleModerationAdapters = { + async loadOverview() { + const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + import("@/lib/admin/min-staff-rank"), + ]); + const minStaffRank = await getMinStaffRank(); + const result = await db.execute(sql` + SELECT + (SELECT COUNT(*) FROM website_tickets WHERE status <> 'closed') AS tickets, + (SELECT COUNT(*) FROM website_help_center_tickets WHERE open = 1) AS helpTickets, + (SELECT COUNT(*) FROM support_tickets WHERE state <> 2) AS cfh, + (SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans, + (SELECT COUNT(*) FROM users WHERE online = '1' AND rank >= ${minStaffRank}) AS staffOnline, + (SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'mod_%') AS recentActions + `); + const row = resultRows(result)[0]; + if (!row) throw new Error("moderation overview unavailable"); + return { + tickets: Number(row.tickets), + helpTickets: Number(row.helpTickets), + cfh: Number(row.cfh), + activeBans: Number(row.activeBans), + staffOnline: Number(row.staffOnline), + recentActions: Number(row.recentActions), + }; + }, + async loadCfh(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE c.issue LIKE ${pattern} OR sender.username LIKE ${pattern} OR reported.username LIKE ${pattern} OR moderator.username LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT c.id, c.state, c.sender_id AS senderId, sender.username AS senderUsername, + c.reported_id AS reportedId, reported.username AS reportedUsername, + c.mod_id AS moderatorId, c.issue, c.timestamp AS createdAt + FROM support_tickets c + LEFT JOIN users sender ON sender.id = c.sender_id + LEFT JOIN users reported ON reported.id = c.reported_id + LEFT JOIN users moderator ON moderator.id = c.mod_id + ${where} + ORDER BY ${input.sort === "username" ? sql`reported.username` : sql`c.id`} ${ + input.order === "asc" ? sql`ASC` : sql`DESC` + }, c.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM support_tickets c + LEFT JOIN users sender ON sender.id = c.sender_id + LEFT JOIN users reported ON reported.id = c.reported_id + LEFT JOIN users moderator ON moderator.id = c.mod_id ${where} + `), + ]); + const rows = resultRows<{ + id: number; + state: number; + senderId: number; + senderUsername: string | null; + reportedId: number; + reportedUsername: string | null; + moderatorId: number; + issue: string; + createdAt: number; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + state: Number(row.state), + senderId: Number(row.senderId), + senderUsername: row.senderUsername, + reportedId: Number(row.reportedId), + reportedUsername: row.reportedUsername, + moderatorId: Number(row.moderatorId), + issue: row.issue, + createdAt: toPeopleIsoDate(row.createdAt), + href: peopleCfhHref(Number(row.id)), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadCfhDetail(id) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const result = await db.execute(sql` + SELECT c.id, c.state, c.sender_id AS senderId, sender.username AS senderUsername, + c.reported_id AS reportedId, reported.username AS reportedUsername, + c.mod_id AS moderatorId, c.issue, c.room_id AS roomId, + c.timestamp AS createdAt, b.id AS banId, b.user_id AS banUserId, + b.user_staff_id AS banStaffId, b.type AS banType, b.ban_reason AS banReason, + b.timestamp AS banCreatedAt, b.ban_expire AS banExpiresAt + FROM support_tickets c + LEFT JOIN users sender ON sender.id = c.sender_id + LEFT JOIN users reported ON reported.id = c.reported_id + LEFT JOIN bans b ON b.user_id = c.reported_id + AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) + WHERE c.id = ${id} + ORDER BY b.timestamp DESC, b.id DESC + LIMIT 1 + `); + const row = resultRows<{ + id: number; + state: number; + senderId: number; + senderUsername: string | null; + reportedId: number; + reportedUsername: string | null; + moderatorId: number; + issue: string; + roomId: number; + createdAt: number; + banId: number | null; + banUserId: number | null; + banStaffId: number | null; + banType: string | null; + banReason: string | null; + banCreatedAt: number | null; + banExpiresAt: number | null; + }>(result)[0]; + if (!row) return null; + return { + id: Number(row.id), + state: Number(row.state), + senderId: Number(row.senderId), + senderUsername: row.senderUsername, + reportedId: Number(row.reportedId), + reportedUsername: row.reportedUsername, + moderatorId: Number(row.moderatorId), + issue: row.issue, + roomId: Number(row.roomId), + createdAt: toPeopleIsoDate(row.createdAt), + href: peopleCfhHref(Number(row.id)), + activeBan: + row.banId === null + ? null + : { + id: Number(row.banId), + userId: Number(row.banUserId), + username: row.reportedUsername, + staffId: Number(row.banStaffId), + staffUsername: null, + type: row.banType ?? "account", + reason: row.banReason ?? "", + createdAt: toPeopleIsoDate(row.banCreatedAt), + expiresAt: + row.banExpiresAt === 0 + ? null + : toPeopleIsoDate(row.banExpiresAt), + active: true, + }, + }; + }, + async loadBans(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const search = input.search + ? sql`AND (u.username LIKE ${pattern} OR staff.username LIKE ${pattern} OR b.ban_reason LIKE ${pattern})` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT b.id, b.user_id AS userId, u.username, + b.user_staff_id AS staffId, staff.username AS staffUsername, + b.type, b.ban_reason AS reason, b.timestamp AS createdAt, + b.ban_expire AS expiresAt + FROM bans b + LEFT JOIN users u ON u.id = b.user_id + LEFT JOIN users staff ON staff.id = b.user_staff_id + WHERE (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) ${search} + ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`b.timestamp` : sql`b.id`} ${ + input.order === "asc" ? sql`ASC` : sql`DESC` + }, b.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM bans b + LEFT JOIN users u ON u.id = b.user_id + LEFT JOIN users staff ON staff.id = b.user_staff_id + WHERE (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) ${search} + `), + ]); + const rows = resultRows<{ + id: number; + userId: number; + username: string | null; + staffId: number; + staffUsername: string | null; + type: string; + reason: string; + createdAt: number; + expiresAt: number; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + userId: Number(row.userId), + username: row.username, + staffId: Number(row.staffId), + staffUsername: row.staffUsername, + type: row.type, + reason: row.reason, + createdAt: toPeopleIsoDate(row.createdAt), + expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt), + active: true, + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadIpRules() { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const [blacklistResult, whitelistResult] = await Promise.all([ + db.execute(sql` + SELECT id, ip_address AS ip, asn AS note, created_at AS createdAt + FROM website_ip_blacklist ORDER BY id DESC LIMIT 200 + `), + db.execute(sql` + SELECT id, ip_address AS ip, asn AS note, created_at AS createdAt + FROM website_ip_whitelist ORDER BY id DESC LIMIT 200 + `), + ]); + const mapRows = (result: unknown): PeopleIpRule[] => + resultRows<{ + id: bigint | number; + ip: string; + note: string | null; + createdAt: Date | string | null; + }>(result).map((row) => ({ + id: Number(row.id), + ip: row.ip, + note: row.note, + createdAt: toPeopleIsoDate(row.createdAt), + })); + return { + blacklist: mapRows(blacklistResult), + whitelist: mapRows(whitelistResult), + }; + }, + async loadVpnSettings() { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const result = await db.execute(sql` + SELECT s.key AS settingKey, s.value FROM website_settings s + WHERE s.key IN ('vpn_block_enabled', 'vpn_provider', 'vpn_block_message') + ORDER BY s.key ASC + `); + return resultRows<{ settingKey: string; value: string }>(result).map( + (row) => ({ + key: row.settingKey, + value: row.value, + }), + ); + }, + async loadWordFilter(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search ? sql`WHERE word LIKE ${pattern}` : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT id, word FROM website_wordfilter ${where} + ORDER BY ${input.sort === "word" ? sql`word` : sql`id`} ${ + input.order === "desc" ? sql`DESC` : sql`ASC` + }, id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute( + sql`SELECT COUNT(*) AS total FROM website_wordfilter ${where}`, + ), + ]); + const rows = resultRows<{ id: bigint | number; word: string }>( + rowsResult, + ).map((row) => ({ + id: Number(row.id), + word: row.word, + replacement: null, + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, +}; + +export const peopleModerationQuery = createPeopleModerationQuery( + peopleModerationAdapters, +); diff --git a/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts new file mode 100644 index 00000000..045f64cd --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import { peopleCommunityAdapters } from "./community"; +import { peopleModerationAdapters } from "./moderation"; +import { peopleStaffAdapters } from "./staff"; +import { peopleSupportAdapters } from "./support"; +import { buildPeopleUserSelection, peopleUsersAdapters } from "./users"; + +describe("People production adapter contracts", () => { + it("keeps each matrix-backed source behind a narrow server adapter", () => { + expect(Object.keys(peopleUsersAdapters).sort()).toEqual([ + "loadMultiAccounts", + "loadSanctions", + "loadUser", + "loadUsers", + ]); + expect(Object.keys(peopleCommunityAdapters).sort()).toEqual([ + "loadGuild", + "loadGuilds", + "loadOnline", + ]); + expect(Object.keys(peopleStaffAdapters).sort()).toEqual([ + "loadApplications", + "loadModerationTeam", + "loadTeams", + ]); + expect(Object.keys(peopleSupportAdapters).sort()).toEqual([ + "loadHelpTicket", + "loadHelpTickets", + "loadQueue", + "loadTemplates", + "loadTicket", + "loadTickets", + ]); + expect(Object.keys(peopleModerationAdapters).sort()).toEqual([ + "loadBans", + "loadCfh", + "loadCfhDetail", + "loadIpRules", + "loadOverview", + "loadVpnSettings", + "loadWordFilter", + ]); + }); + + it("selects mail and current IP only when their independent projections allow it", () => { + const user = { + id: "id", + username: "username", + rank: "rank", + online: "online", + mail: "mail", + ipCurrent: "ipCurrent", + password: "password", + authTicket: "authTicket", + secretKey: "secretKey", + twoFactorSecret: "twoFactorSecret", + }; + + expect( + buildPeopleUserSelection(user, { + includeMail: false, + includeIp: false, + }), + ).toEqual({ + id: "id", + username: "username", + rank: "rank", + online: "online", + }); + expect( + buildPeopleUserSelection(user, { + includeMail: true, + includeIp: false, + }), + ).toEqual({ + id: "id", + username: "username", + rank: "rank", + online: "online", + mail: "mail", + }); + expect( + buildPeopleUserSelection(user, { + includeMail: false, + includeIp: true, + }), + ).toEqual({ + id: "id", + username: "username", + rank: "rank", + online: "online", + ipCurrent: "ipCurrent", + }); + expect( + Object.keys( + buildPeopleUserSelection(user, { + includeMail: true, + includeIp: true, + }), + ), + ).not.toEqual( + expect.arrayContaining([ + "password", + "authTicket", + "secretKey", + "twoFactorSecret", + ]), + ); + }); + + it("returns the stable prefix needed for offset pagination of multi-account clusters", async () => { + const execute = vi + .fn() + .mockResolvedValueOnce([ + [ + { ipCurrent: "203.0.113.1", accountCount: 2 }, + { ipCurrent: "203.0.113.2", accountCount: 2 }, + { ipCurrent: "203.0.113.3", accountCount: 2 }, + ], + ]) + .mockResolvedValue([ + [ + { id: 1, username: "one", rank: 1, online: "0" }, + { id: 2, username: "two", rank: 1, online: "0" }, + ], + ]); + const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings, + values, + }); + vi.doMock("drizzle-orm", () => ({ sql })); + vi.doMock("@/lib/db", () => ({ db: { execute } })); + + const result = await peopleUsersAdapters.loadMultiAccounts({ + search: "", + pageSize: 1, + offset: 1, + sort: "id", + order: "asc", + }); + + expect(result.rows.map((row) => row.key)).toEqual([ + "203.0.113.1", + "203.0.113.2", + ]); + expect(result.total).toBe(3); + }); +}); diff --git a/src/features/housekeeping/domains/people/queries/people-queries.test.ts b/src/features/housekeeping/domains/people/queries/people-queries.test.ts new file mode 100644 index 00000000..5570a995 --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/people-queries.test.ts @@ -0,0 +1,492 @@ +import { describe, expect, it, vi } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { createPeopleCommunityQuery } from "./community"; +import { createPeopleModerationQuery } from "./moderation"; +import { createPeopleStaffQuery } from "./staff"; +import { createPeopleSupportQuery } from "./support"; +import { createPeopleUsersQuery } from "./users"; + +function context(granted: readonly string[]): HousekeepingCapabilityContext { + const permissions = new Set(granted); + return { + actor: { id: 42, username: "operator", rank: 99 }, + isSuperAdmin: false, + has: (slug) => permissions.has(slug), + hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)), + hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)), + }; +} + +const rawUser = (id: number, username: string) => ({ + id, + username, + rank: 5, + online: "1", + mail: `${username.toLowerCase()}@example.test`, + ipCurrent: `203.0.113.${id}`, + bannedUntil: null, +}); + +describe("People users query", () => { + it("normalizes list input, sorts stable ties, and projects PII for admin users view", async () => { + const loadUsers = vi.fn(async () => ({ + rows: [rawUser(3, "alice"), rawUser(1, "Alice"), rawUser(2, "alice")], + total: 3, + })); + const query = createPeopleUsersQuery({ + loadUsers, + loadUser: async () => null, + loadMultiAccounts: async () => ({ rows: [], total: 0 }), + loadSanctions: async () => [], + }); + + const result = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.list", + list: { + pageSize: 5_000, + offset: -9, + search: ` ali\u0000${"x".repeat(200)} `, + sort: "username", + order: "asc", + }, + }); + + expect(loadUsers).toHaveBeenCalledWith( + expect.objectContaining({ + pageSize: 100, + offset: 0, + search: `alix${"x".repeat(124)}`, + sort: "username", + }), + { includeMail: true, includeIp: true }, + ); + expect(result).toMatchObject({ + ok: true, + data: { + kind: "users", + page: { + items: [ + { + id: 1, + mail: "alice@example.test", + ipCurrent: "203.0.113.1", + href: "/ase/people/users/1", + }, + { id: 2 }, + { id: 3 }, + ], + total: 3, + pageSize: 100, + offset: 0, + }, + }, + }); + }); + + it("returns a base-only projection to mod users and fails closed for neither permission", async () => { + const loadUsers = vi.fn(async () => ({ + rows: [rawUser(1, "Alice")], + total: 1, + })); + const query = createPeopleUsersQuery({ + loadUsers, + loadUser: async () => null, + loadMultiAccounts: async () => ({ rows: [], total: 0 }), + loadSanctions: async () => [], + }); + + const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), { + routeId: "people.users.list", + list: {}, + }); + expect(loadUsers).toHaveBeenLastCalledWith(expect.anything(), { + includeMail: false, + includeIp: false, + }); + expect(moderator).toMatchObject({ + ok: true, + data: { + page: { items: [{ mail: null, ipCurrent: null }] }, + }, + }); + + loadUsers.mockClear(); + const forbidden = await query.run(context([]), { + routeId: "people.users.list", + list: {}, + }); + expect(forbidden).toMatchObject({ + ok: false, + error: { code: "FORBIDDEN" }, + }); + expect(loadUsers).not.toHaveBeenCalled(); + }); + + it("maps missing users, invalid ids, and adapter failures to stable errors", async () => { + const loadUser = vi.fn(async () => null); + const query = createPeopleUsersQuery({ + loadUsers: async () => { + throw new Error("count unavailable"); + }, + loadUser, + loadMultiAccounts: async () => ({ rows: [], total: 0 }), + loadSanctions: async () => [], + }); + + const invalid = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.detail", + id: -1, + }); + expect(invalid).toMatchObject({ + ok: false, + error: { code: "VALIDATION", fieldErrors: { id: ["invalid"] } }, + }); + expect(loadUser).not.toHaveBeenCalled(); + + const missing = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.detail", + id: 404, + }); + expect(missing).toMatchObject({ + ok: false, + error: { code: "NOT_FOUND" }, + }); + + const unavailable = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.list", + list: {}, + }); + expect(unavailable).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + + it("returns JSON-safe user details with sanctions and canonical links", async () => { + const query = createPeopleUsersQuery({ + loadUsers: async () => ({ rows: [], total: 0 }), + loadUser: async () => ({ + ...rawUser(9, "Nine"), + motto: "Hello", + look: "hd-180-1", + accountCreated: 1_700_000_000, + lastLogin: new Date("2026-08-29T09:00:00.000Z"), + }), + loadMultiAccounts: async () => ({ rows: [], total: 0 }), + loadSanctions: async () => [ + { + id: 8, + kind: "account", + reason: "test", + createdAt: "2026-08-20T00:00:00.000Z", + expiresAt: null, + active: true, + }, + ], + }); + + const result = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.detail", + id: 9, + }); + expect(result).toMatchObject({ + ok: true, + data: { + kind: "user", + user: { + id: 9, + href: "/ase/people/users/9", + accountCreated: "2023-11-14T22:13:20.000Z", + lastLogin: "2026-08-29T09:00:00.000Z", + sanctions: [{ id: 8 }], + }, + }, + }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); +}); + +describe("People community and staff queries", () => { + it("loads online, guild list, and guild detail workflows through narrow adapters", async () => { + const loadOnline = vi.fn(async () => ({ rows: [], total: 0 })); + const loadGuilds = vi.fn(async () => ({ + rows: [ + { + id: 2, + name: "Builders", + description: "Build", + ownerId: 4, + ownerUsername: "Owner", + memberCount: 3, + createdAt: "2026-08-01T00:00:00.000Z", + href: "/ase/people/community/guilds/2" as const, + }, + ], + total: 1, + })); + const loadGuild = vi.fn(async (id: number) => + id === 2 + ? { + id: 2, + name: "Builders", + description: "Build", + ownerId: 4, + ownerUsername: "Owner", + memberCount: 3, + createdAt: "2026-08-01T00:00:00.000Z", + href: "/ase/people/community/guilds/2" as const, + roomId: 7, + threadCount: 1, + members: [], + } + : null, + ); + const query = createPeopleCommunityQuery({ + loadOnline, + loadGuilds, + loadGuild, + }); + + expect( + await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.community.online", + list: {}, + }), + ).toMatchObject({ ok: true, data: { kind: "online" } }); + expect( + await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.community.guilds", + list: {}, + }), + ).toMatchObject({ + ok: true, + data: { kind: "guilds", page: { items: [{ id: 2 }] } }, + }); + expect( + await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.community.guild-detail", + id: 404, + }), + ).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + }); + + it("loads applications, teams, and moderation-team data with serializable DTOs", async () => { + const query = createPeopleStaffQuery({ + loadApplications: async () => ({ + rows: [ + { + id: 11, + userId: 5, + username: "Applicant", + rankId: 3, + content: "Why me", + createdAt: "2026-08-29T00:00:00.000Z", + }, + ], + total: 1, + }), + loadTeams: async () => ({ rows: [], total: 0 }), + loadModerationTeam: async () => ({ rows: [], total: 0 }), + }); + + const applications = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.staff.applications", + list: {}, + }); + const teams = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.staff.teams", + list: {}, + }); + const moderationTeam = await query.run(context([PERMS.MOD_TEAM_VIEW]), { + routeId: "people.staff.moderation-team", + list: {}, + }); + + expect(applications).toMatchObject({ + ok: true, + data: { kind: "applications", page: { items: [{ id: 11 }] } }, + }); + expect(teams).toMatchObject({ ok: true, data: { kind: "teams" } }); + expect(moderationTeam).toMatchObject({ + ok: true, + data: { kind: "moderation-team" }, + }); + expect(() => JSON.stringify(applications)).not.toThrow(); + }); +}); + +describe("People support query", () => { + it("returns the four-source queue snapshot and fails closed when a count fails", async () => { + const loadQueue = vi + .fn() + .mockResolvedValueOnce({ + tickets: 2, + helpTickets: 3, + cfh: 4, + activeBans: 5, + }) + .mockRejectedValueOnce(new Error("count failed")); + const query = createPeopleSupportQuery({ + loadQueue, + loadTickets: async () => ({ rows: [], total: 0 }), + loadTicket: async () => null, + loadTemplates: async () => ({ rows: [], total: 0 }), + loadHelpTickets: async () => ({ rows: [], total: 0 }), + loadHelpTicket: async () => null, + }); + + expect( + await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.queue", + }), + ).toMatchObject({ + ok: true, + data: { + kind: "queue", + queue: { tickets: 2, helpTickets: 3, cfh: 4, activeBans: 5 }, + }, + }); + expect( + await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.queue", + }), + ).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + + it("covers ticket desk/templates/detail and help-ticket list/detail reads", async () => { + const query = createPeopleSupportQuery({ + loadQueue: async () => ({ + tickets: 0, + helpTickets: 0, + cfh: 0, + activeBans: 0, + }), + loadTickets: async () => ({ rows: [], total: 0 }), + loadTicket: async () => null, + loadTemplates: async () => ({ rows: [], total: 0 }), + loadHelpTickets: async () => ({ rows: [], total: 0 }), + loadHelpTicket: async () => null, + }); + + for (const routeId of [ + "people.support.tickets", + "people.support.ticket-desk", + "people.support.help-tickets", + ] as const) { + expect( + await query.run(context([PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW]), { + routeId, + list: {}, + }), + ).toMatchObject({ ok: true }); + } + expect( + await query.run(context([PERMS.TICKETS_EDIT]), { + routeId: "people.support.ticket-templates", + list: {}, + }), + ).toMatchObject({ ok: true, data: { kind: "ticket-templates" } }); + for (const input of [ + { routeId: "people.support.ticket-detail" as const, id: 91 }, + { routeId: "people.support.help-ticket-detail" as const, id: 92 }, + ]) { + expect( + await query.run(context([PERMS.TICKETS_VIEW]), input), + ).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + } + }); +}); + +describe("People moderation query", () => { + it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => { + const query = createPeopleModerationQuery({ + loadOverview: async () => ({ + tickets: 1, + helpTickets: 2, + cfh: 3, + activeBans: 4, + staffOnline: 5, + recentActions: 6, + }), + loadCfh: async () => ({ rows: [], total: 0 }), + loadCfhDetail: async () => null, + loadBans: async () => ({ rows: [], total: 0 }), + loadIpRules: async () => ({ blacklist: [], whitelist: [] }), + loadVpnSettings: async () => [], + loadWordFilter: async () => ({ rows: [], total: 0 }), + }); + + expect( + await query.run(context([PERMS.MODERATION_VIEW]), { + routeId: "people.moderation.overview", + }), + ).toMatchObject({ + ok: true, + data: { kind: "overview", snapshot: { cfh: 3, activeBans: 4 } }, + }); + expect( + await query.run(context([PERMS.MOD_CFH_VIEW]), { + routeId: "people.moderation.cfh", + list: {}, + }), + ).toMatchObject({ ok: true, data: { kind: "cfh" } }); + expect( + await query.run(context([PERMS.MOD_CFH_VIEW]), { + routeId: "people.moderation.cfh-detail", + id: 77, + }), + ).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } }); + expect( + await query.run(context([PERMS.MOD_BANS_VIEW]), { + routeId: "people.moderation.bans", + list: {}, + }), + ).toMatchObject({ ok: true, data: { kind: "bans" } }); + for (const input of [ + { routeId: "people.moderation.ip" as const }, + { routeId: "people.moderation.vpn" as const }, + ]) { + expect( + await query.run(context([PERMS.SETTINGS_VIEW]), input), + ).toMatchObject({ ok: true }); + } + expect( + await query.run(context([PERMS.WORDFILTER_VIEW]), { + routeId: "people.moderation.word-filter", + list: {}, + }), + ).toMatchObject({ ok: true, data: { kind: "word-filter" } }); + }); + + it("maps adapter exceptions to dependency unavailable without partial data", async () => { + const down = async () => { + throw new Error("database unavailable"); + }; + const query = createPeopleModerationQuery({ + loadOverview: down, + loadCfh: down, + loadCfhDetail: down, + loadBans: down, + loadIpRules: down, + loadVpnSettings: down, + loadWordFilter: down, + }); + + expect( + await query.run(context([PERMS.MOD_BANS_VIEW]), { + routeId: "people.moderation.bans", + list: {}, + }), + ).toMatchObject({ + ok: false, + error: { + code: "DEPENDENCY_UNAVAILABLE", + messageKey: "errors.housekeeping.dependencyUnavailable", + }, + }); + }); +}); diff --git a/src/features/housekeeping/domains/people/queries/staff.ts b/src/features/housekeeping/domains/people/queries/staff.ts new file mode 100644 index 00000000..5547c451 --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/staff.ts @@ -0,0 +1,281 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import { + createPeoplePage, + type ListInput, + normalizePeopleListInput, + type Page, + type PeopleModerationTeamMember, + type PeopleStaffApplication, + type PeopleTeam, + peopleUserHref, + toPeopleIsoDate, +} from "../models"; + +interface StaffRows { + readonly rows: readonly T[]; + readonly total: number; +} + +export interface PeopleStaffAdapters { + loadApplications( + input: ReturnType, + ): Promise>; + loadTeams( + input: ReturnType, + ): Promise>; + loadModerationTeam( + input: ReturnType, + ): Promise>; +} + +export type PeopleStaffQueryInput = + | { readonly routeId: "people.staff.applications"; readonly list: ListInput } + | { readonly routeId: "people.staff.teams"; readonly list: ListInput } + | { + readonly routeId: "people.staff.moderation-team"; + readonly list: ListInput; + }; + +export type PeopleStaffQueryData = + | { + readonly kind: "applications"; + readonly page: Page; + } + | { readonly kind: "teams"; readonly page: Page } + | { + readonly kind: "moderation-team"; + readonly page: Page; + }; + +export function createPeopleStaffQuery( + adapters: PeopleStaffAdapters, +): HousekeepingQuery { + return { + id: "people.staff.query", + owner: "people", + capability: anyCapability( + PERMS.USERS_VIEW, + PERMS.MODERATION_VIEW, + PERMS.MOD_TEAM_VIEW, + ), + async run(context, input) { + const requirement = + input.routeId === "people.staff.moderation-team" + ? anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_TEAM_VIEW) + : anyCapability(PERMS.USERS_VIEW); + const authorization = authorizeHousekeeping(context, requirement); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + const list = normalizePeopleListInput( + input.list, + ["id", "name", "username", "createdAt"], + "id", + ); + + try { + if (input.routeId === "people.staff.applications") { + const result = await adapters.loadApplications(list); + return ok( + { + kind: "applications" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "username" + ? (row.username ?? "") + : list.sort === "createdAt" + ? (row.createdAt ?? "") + : row.id, + ), + }, + correlationId, + ); + } + + if (input.routeId === "people.staff.teams") { + const result = await adapters.loadTeams(list); + return ok( + { + kind: "teams" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "name" ? row.name : row.id, + ), + }, + correlationId, + ); + } + + const result = await adapters.loadModerationTeam(list); + return ok( + { + kind: "moderation-team" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "username" ? row.username : row.id, + ), + }, + correlationId, + ); + } catch { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); + } + }, + }; +} + +function resultRows(result: unknown): T[] { + if (!Array.isArray(result)) return []; + return Array.isArray(result[0]) ? (result[0] as T[]) : []; +} + +export const peopleStaffAdapters: PeopleStaffAdapters = { + async loadApplications(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE u.username LIKE ${pattern} OR a.content LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT a.id, a.user_id AS userId, u.username, a.rank_id AS rankId, + a.content, a.created_at AS createdAt + FROM website_staff_applications a + LEFT JOIN users u ON u.id = a.user_id + ${where} + ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`a.created_at` : sql`a.id`} ${ + input.order === "asc" ? sql`ASC` : sql`DESC` + }, a.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total + FROM website_staff_applications a + LEFT JOIN users u ON u.id = a.user_id + ${where} + `), + ]); + const rows = resultRows<{ + id: bigint | number; + userId: number; + username: string | null; + rankId: number; + content: string; + createdAt: Date | string | null; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + userId: Number(row.userId), + username: row.username, + rankId: Number(row.rankId), + content: row.content, + createdAt: toPeopleIsoDate(row.createdAt), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadTeams(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE rank_name LIKE ${pattern} OR job_description LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT id, rank_name AS name, hidden_rank AS hidden, + badge, job_description AS jobDescription + FROM website_teams + ${where} + ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${ + input.order === "desc" ? sql`DESC` : sql`ASC` + }, id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`), + ]); + const rows = resultRows<{ + id: bigint | number; + name: string; + hidden: boolean | number; + badge: string | null; + jobDescription: string | null; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + name: row.name, + rank: Number(row.id), + hidden: Boolean(row.hidden), + badge: row.badge, + jobDescription: row.jobDescription, + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadModerationTeam(input) { + const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + import("@/lib/admin/min-staff-rank"), + ]); + const minStaffRank = await getMinStaffRank(); + const pattern = `%${input.search}%`; + const search = input.search ? sql`AND u.username LIKE ${pattern}` : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT u.id, u.username, u.rank, + (SELECT COUNT(*) FROM support_tickets c WHERE c.mod_id = u.id) AS openCfh, + (SELECT COUNT(*) FROM website_tickets t WHERE t.assignee_id = u.id) AS openTickets, + (SELECT COUNT(*) FROM admin_audit_log a WHERE a.user_id = u.id AND a.action LIKE 'mod_%') AS actionCount + FROM users u + WHERE u.rank >= ${minStaffRank} ${search} + ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${ + input.order === "desc" ? sql`DESC` : sql`ASC` + }, u.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM users u + WHERE u.rank >= ${minStaffRank} ${search} + `), + ]); + const rows = resultRows<{ + id: number; + username: string; + rank: number; + openCfh: number; + openTickets: number; + actionCount: number; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + username: row.username, + rank: Number(row.rank), + openCfh: Number(row.openCfh), + openTickets: Number(row.openTickets), + actionCount: Number(row.actionCount), + href: peopleUserHref(Number(row.id)), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, +}; + +export const peopleStaffQuery = createPeopleStaffQuery(peopleStaffAdapters); diff --git a/src/features/housekeeping/domains/people/queries/support.ts b/src/features/housekeeping/domains/people/queries/support.ts new file mode 100644 index 00000000..195b0bd8 --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/support.ts @@ -0,0 +1,505 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import { + createPeoplePage, + type ListInput, + normalizePeopleListInput, + type Page, + type PeopleHelpTicketDetail, + type PeopleHelpTicketSummary, + type PeopleQueueSnapshot, + type PeopleTicketDetail, + type PeopleTicketSummary, + type PeopleTicketTemplate, + peopleHelpTicketHref, + peopleTicketHref, + toPeopleIsoDate, +} from "../models"; + +interface SupportRows { + readonly rows: readonly T[]; + readonly total: number; +} + +export interface PeopleSupportAdapters { + loadQueue(): Promise; + loadTickets( + input: ReturnType, + ): Promise>; + loadTicket(id: number): Promise; + loadTemplates( + input: ReturnType, + ): Promise>; + loadHelpTickets( + input: ReturnType, + ): Promise>; + loadHelpTicket(id: number): Promise; +} + +export type PeopleSupportQueryInput = + | { readonly routeId: "people.support.queue" } + | { readonly routeId: "people.support.tickets"; readonly list: ListInput } + | { readonly routeId: "people.support.ticket-desk"; readonly list: ListInput } + | { + readonly routeId: "people.support.ticket-templates"; + readonly list: ListInput; + } + | { readonly routeId: "people.support.ticket-detail"; readonly id: number } + | { + readonly routeId: "people.support.help-tickets"; + readonly list: ListInput; + } + | { + readonly routeId: "people.support.help-ticket-detail"; + readonly id: number; + }; + +export type PeopleSupportQueryData = + | { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot } + | { readonly kind: "tickets"; readonly page: Page } + | { + readonly kind: "ticket-templates"; + readonly page: Page; + } + | { readonly kind: "ticket"; readonly ticket: PeopleTicketDetail } + | { + readonly kind: "help-tickets"; + readonly page: Page; + } + | { + readonly kind: "help-ticket"; + readonly ticket: PeopleHelpTicketDetail; + }; + +const broadCapability = anyCapability( + PERMS.TICKETS_VIEW, + PERMS.MOD_TICKETS_VIEW, + PERMS.TICKETS_EDIT, +); + +function unavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +function invalidId(id: number, correlationId: string) { + return !Number.isSafeInteger(id) || id <= 0 + ? fail("VALIDATION", "errors.housekeeping.validation", correlationId, { + id: ["invalid"], + }) + : null; +} + +export function createPeopleSupportQuery( + adapters: PeopleSupportAdapters, +): HousekeepingQuery { + return { + id: "people.support.query", + owner: "people", + capability: broadCapability, + async run(context, input) { + const requirement = + input.routeId === "people.support.ticket-templates" + ? anyCapability(PERMS.TICKETS_EDIT) + : anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW); + const authorization = authorizeHousekeeping(context, requirement); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + + try { + if (input.routeId === "people.support.queue") { + return ok( + { kind: "queue" as const, queue: await adapters.loadQueue() }, + correlationId, + ); + } + + if ( + input.routeId === "people.support.ticket-detail" || + input.routeId === "people.support.help-ticket-detail" + ) { + const invalid = invalidId(input.id, correlationId); + if (invalid !== null) return invalid; + const ticket = + input.routeId === "people.support.ticket-detail" + ? await adapters.loadTicket(input.id) + : await adapters.loadHelpTicket(input.id); + if (ticket === null) { + return fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + correlationId, + ); + } + return input.routeId === "people.support.ticket-detail" + ? ok( + { + kind: "ticket" as const, + ticket: ticket as PeopleTicketDetail, + }, + correlationId, + ) + : ok( + { + kind: "help-ticket" as const, + ticket: ticket as PeopleHelpTicketDetail, + }, + correlationId, + ); + } + + const list = normalizePeopleListInput( + input.list, + ["id", "subject", "title", "status", "updatedAt", "sortOrder"], + "id", + ); + if (input.routeId === "people.support.ticket-templates") { + const result = await adapters.loadTemplates(list); + return ok( + { + kind: "ticket-templates" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "title" + ? row.title + : list.sort === "sortOrder" + ? row.sortOrder + : row.id, + ), + }, + correlationId, + ); + } + + if (input.routeId === "people.support.help-tickets") { + const result = await adapters.loadHelpTickets(list); + return ok( + { + kind: "help-tickets" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "title" ? row.title : row.id, + ), + }, + correlationId, + ); + } + + const result = await adapters.loadTickets(list); + return ok( + { + kind: "tickets" as const, + page: createPeoplePage(result.rows, result.total, list, (row) => + list.sort === "subject" ? row.subject : row.id, + ), + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + }, + }; +} + +function resultRows(result: unknown): T[] { + if (!Array.isArray(result)) return []; + return Array.isArray(result[0]) ? (result[0] as T[]) : []; +} + +export const peopleSupportAdapters: PeopleSupportAdapters = { + async loadQueue() { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const result = await db.execute(sql` + SELECT + (SELECT COUNT(*) FROM website_tickets WHERE status <> 'closed') AS tickets, + (SELECT COUNT(*) FROM website_help_center_tickets WHERE open = 1) AS helpTickets, + (SELECT COUNT(*) FROM support_tickets WHERE state <> 2) AS cfh, + (SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans + `); + const row = resultRows<{ + tickets: number; + helpTickets: number; + cfh: number; + activeBans: number; + }>(result)[0]; + if (!row) throw new Error("queue counts unavailable"); + return { + tickets: Number(row.tickets), + helpTickets: Number(row.helpTickets), + cfh: Number(row.cfh), + activeBans: Number(row.activeBans), + }; + }, + async loadTickets(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE t.subject LIKE ${pattern} OR t.status LIKE ${pattern} OR u.username LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT t.id, t.subject, t.status, t.priority, t.creator_id AS creatorId, + u.username AS creatorUsername, t.updated_at AS updatedAt + FROM website_tickets t + LEFT JOIN users u ON u.id = t.creator_id + ${where} + ORDER BY ${input.sort === "subject" ? sql`t.subject` : input.sort === "status" ? sql`t.status` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${ + input.order === "asc" ? sql`ASC` : sql`DESC` + }, t.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM website_tickets t + LEFT JOIN users u ON u.id = t.creator_id ${where} + `), + ]); + const rows = resultRows<{ + id: number; + subject: string; + status: string; + priority: string; + creatorId: number; + creatorUsername: string | null; + updatedAt: Date | string | null; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + subject: row.subject, + status: row.status, + priority: row.priority, + creatorId: Number(row.creatorId), + creatorUsername: row.creatorUsername, + updatedAt: toPeopleIsoDate(row.updatedAt), + href: peopleTicketHref(Number(row.id)), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadTicket(id) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const [ticketResult, messagesResult] = await Promise.all([ + db.execute(sql` + SELECT t.id, t.subject, t.category, t.priority, t.status, + t.creator_id AS creatorId, creator.username AS creatorUsername, + t.assignee_id AS assigneeId, t.updated_at AS updatedAt + FROM website_tickets t + LEFT JOIN users creator ON creator.id = t.creator_id + WHERE t.id = ${id} LIMIT 1 + `), + db.execute(sql` + SELECT m.id, m.user_id AS userId, u.username, m.message, + m.is_staff AS isStaff, m.created_at AS createdAt + FROM website_ticket_messages m + LEFT JOIN users u ON u.id = m.user_id + WHERE m.ticket_id = ${id} + ORDER BY m.created_at ASC, m.id ASC + `), + ]); + const row = resultRows<{ + id: number; + subject: string; + category: string; + priority: string; + status: string; + creatorId: number; + creatorUsername: string | null; + assigneeId: number | null; + updatedAt: Date | string | null; + }>(ticketResult)[0]; + if (!row) return null; + return { + id: Number(row.id), + subject: row.subject, + category: row.category, + priority: row.priority, + status: row.status, + creatorId: Number(row.creatorId), + creatorUsername: row.creatorUsername, + assigneeId: row.assigneeId === null ? null : Number(row.assigneeId), + updatedAt: toPeopleIsoDate(row.updatedAt), + href: peopleTicketHref(Number(row.id)), + messages: resultRows<{ + id: number; + userId: number; + username: string | null; + message: string; + isStaff: number | boolean; + createdAt: Date | string | null; + }>(messagesResult).map((message) => ({ + id: Number(message.id), + userId: Number(message.userId), + username: message.username, + message: message.message, + isStaff: Boolean(message.isStaff), + createdAt: toPeopleIsoDate(message.createdAt), + })), + }; + }, + async loadTemplates(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE title LIKE ${pattern} OR category LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT id, title, content, category, sort_order AS sortOrder + FROM website_ticket_templates ${where} + ORDER BY ${input.sort === "title" ? sql`title` : input.sort === "sortOrder" ? sql`sort_order` : sql`id`} ${ + input.order === "desc" ? sql`DESC` : sql`ASC` + }, id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM website_ticket_templates ${where} + `), + ]); + const rows = resultRows(rowsResult).map((row) => ({ + id: Number(row.id), + title: row.title, + content: row.content, + category: row.category, + sortOrder: Number(row.sortOrder), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadHelpTickets(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const pattern = `%${input.search}%`; + const where = input.search + ? sql`WHERE t.title LIKE ${pattern} OR u.username LIKE ${pattern}` + : sql``; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT t.id, t.title, t.open, t.user_id AS userId, u.username, + t.updated_at AS updatedAt + FROM website_help_center_tickets t + LEFT JOIN users u ON u.id = t.user_id + ${where} + ORDER BY ${input.sort === "title" ? sql`t.title` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${ + input.order === "asc" ? sql`ASC` : sql`DESC` + }, t.id ASC + LIMIT ${input.offset + input.pageSize} + `), + db.execute(sql` + SELECT COUNT(*) AS total FROM website_help_center_tickets t + LEFT JOIN users u ON u.id = t.user_id ${where} + `), + ]); + const rows = resultRows<{ + id: bigint | number; + title: string; + open: boolean | number; + userId: number | null; + username: string | null; + updatedAt: Date | string | null; + }>(rowsResult).map((row) => ({ + id: Number(row.id), + title: row.title, + open: Boolean(row.open), + userId: row.userId === null ? null : Number(row.userId), + username: row.username, + updatedAt: toPeopleIsoDate(row.updatedAt), + href: peopleHelpTicketHref(Number(row.id)), + })); + return { + rows, + total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + }; + }, + async loadHelpTicket(id) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const [ticketResult, repliesResult] = await Promise.all([ + db.execute(sql` + SELECT t.id, t.title, t.content, t.open, t.user_id AS userId, + u.username, t.category_id AS categoryId, c.name AS categoryName, + t.updated_at AS updatedAt + FROM website_help_center_tickets t + LEFT JOIN users u ON u.id = t.user_id + LEFT JOIN website_help_center_categories c ON c.id = t.category_id + WHERE t.id = ${id} LIMIT 1 + `), + db.execute(sql` + SELECT r.id, r.user_id AS userId, u.username, r.content, + r.created_at AS createdAt + FROM website_help_center_ticket_replies r + LEFT JOIN users u ON u.id = r.user_id + WHERE r.ticket_id = ${id} + ORDER BY r.created_at ASC, r.id ASC + `), + ]); + const row = resultRows<{ + id: bigint | number; + title: string; + content: string; + open: boolean | number; + userId: number | null; + username: string | null; + categoryId: bigint | number | null; + categoryName: string | null; + updatedAt: Date | string | null; + }>(ticketResult)[0]; + if (!row) return null; + return { + id: Number(row.id), + title: row.title, + content: row.content, + open: Boolean(row.open), + userId: row.userId === null ? null : Number(row.userId), + username: row.username, + categoryId: row.categoryId === null ? null : Number(row.categoryId), + categoryName: row.categoryName, + updatedAt: toPeopleIsoDate(row.updatedAt), + href: peopleHelpTicketHref(Number(row.id)), + replies: resultRows<{ + id: bigint | number; + userId: number; + username: string | null; + content: string; + createdAt: Date | string | null; + }>(repliesResult).map((reply) => ({ + id: Number(reply.id), + userId: Number(reply.userId), + username: reply.username, + content: reply.content, + createdAt: toPeopleIsoDate(reply.createdAt), + })), + }; + }, +}; + +export const peopleSupportQuery = createPeopleSupportQuery( + peopleSupportAdapters, +); diff --git a/src/features/housekeeping/domains/people/queries/users.ts b/src/features/housekeeping/domains/people/queries/users.ts new file mode 100644 index 00000000..1a62f97e --- /dev/null +++ b/src/features/housekeeping/domains/people/queries/users.ts @@ -0,0 +1,424 @@ +import "server-only"; + +import { PERMS } from "@/lib/permission-slugs"; +import { authorizeHousekeeping } from "../../../foundation/authorization"; +import { + anyCapability, + fail, + type HousekeepingQuery, + ok, +} from "../../../foundation/contracts"; +import { + createPeoplePage, + type ListInput, + normalizePeopleListInput, + normalizePeopleUser, + type Page, + type PeopleMultiAccountCluster, + type PeopleSanctionSummary, + type PeopleUserDetail, + type PeopleUserRecord, + type PeopleUserSummary, + toPeopleIsoDate, +} from "../models"; + +interface PeopleUserColumns { + readonly id: unknown; + readonly username: unknown; + readonly rank: unknown; + readonly online: unknown; + readonly mail: unknown; + readonly ipCurrent: unknown; +} + +export function buildPeopleUserSelection( + user: T, + projection: { readonly includeMail: boolean; readonly includeIp: boolean }, +) { + return { + id: user.id, + username: user.username, + rank: user.rank, + online: user.online, + ...(projection.includeMail ? { mail: user.mail } : {}), + ...(projection.includeIp ? { ipCurrent: user.ipCurrent } : {}), + }; +} + +export interface PeopleUserDetailRecord extends PeopleUserRecord { + readonly motto: unknown; + readonly look: unknown; + readonly accountCreated: unknown; + readonly lastLogin: unknown; +} + +export interface PeopleUsersAdapters { + loadUsers( + input: ReturnType, + projection: { readonly includeMail: boolean; readonly includeIp: boolean }, + ): Promise<{ + readonly rows: readonly PeopleUserRecord[]; + readonly total: number; + }>; + loadUser( + id: number, + projection: { readonly includeMail: boolean; readonly includeIp: boolean }, + ): Promise; + loadMultiAccounts( + input: ReturnType, + ): Promise<{ + readonly rows: readonly PeopleMultiAccountCluster[]; + readonly total: number; + }>; + loadSanctions(userId: number): Promise; +} + +export type PeopleUsersQueryInput = + | { readonly routeId: "people.users.list"; readonly list: ListInput } + | { readonly routeId: "people.users.edit"; readonly id: number } + | { readonly routeId: "people.users.detail"; readonly id: number } + | { + readonly routeId: "people.users.multi-accounts"; + readonly list: ListInput; + }; + +export type PeopleUsersQueryData = + | { readonly kind: "users"; readonly page: Page } + | { readonly kind: "user"; readonly user: PeopleUserDetail } + | { + readonly kind: "multi-accounts"; + readonly page: Page; + }; + +const broadCapability = anyCapability( + PERMS.USERS_VIEW, + PERMS.MOD_USERS_VIEW, + PERMS.USERS_EDIT, +); + +function unavailable(correlationId: string) { + return fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + correlationId, + ); +} + +function validId(id: number): boolean { + return Number.isSafeInteger(id) && id > 0; +} + +export function createPeopleUsersQuery( + adapters: PeopleUsersAdapters, +): HousekeepingQuery { + return { + id: "people.users.query", + owner: "people", + capability: broadCapability, + async run(context, input) { + const requirement = + input.routeId === "people.users.edit" + ? anyCapability(PERMS.USERS_EDIT) + : input.routeId === "people.users.multi-accounts" + ? anyCapability(PERMS.USERS_VIEW) + : anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW); + const authorization = authorizeHousekeeping(context, requirement); + if (!authorization.ok) return authorization; + const correlationId = authorization.correlationId; + const projection = { + includeMail: context.has(PERMS.USERS_VIEW), + includeIp: context.has(PERMS.USERS_VIEW), + }; + + if (input.routeId === "people.users.list") { + const list = normalizePeopleListInput( + input.list, + ["id", "username", "rank"], + "id", + ); + try { + const result = await adapters.loadUsers(list, projection); + const rows = result.rows.map((row) => + normalizePeopleUser(row, projection), + ); + const sortValue = (row: PeopleUserSummary) => + list.sort === "username" + ? row.username + : list.sort === "rank" + ? row.rank + : row.id; + return ok( + { + kind: "users" as const, + page: createPeoplePage(rows, result.total, list, sortValue), + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + } + + if (input.routeId === "people.users.multi-accounts") { + const list = normalizePeopleListInput(input.list, ["id"], "id"); + try { + const result = await adapters.loadMultiAccounts(list); + const rows = result.rows.map((row, index) => ({ + ...row, + id: index + 1, + })); + const page = createPeoplePage( + rows, + result.total, + list, + (row) => row.id, + ); + return ok( + { + kind: "multi-accounts" as const, + page: { + ...page, + items: page.items.map(({ id: _id, ...row }) => row), + } as Page, + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + } + + if (!validId(input.id)) { + return fail( + "VALIDATION", + "errors.housekeeping.validation", + correlationId, + { id: ["invalid"] }, + ); + } + + try { + const row = await adapters.loadUser(input.id, projection); + if (row === null) { + return fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + correlationId, + ); + } + const sanctions = await adapters.loadSanctions(input.id); + return ok( + { + kind: "user" as const, + user: { + ...normalizePeopleUser(row, projection), + motto: String(row.motto ?? ""), + look: String(row.look ?? ""), + accountCreated: toPeopleIsoDate(row.accountCreated), + lastLogin: toPeopleIsoDate(row.lastLogin), + sanctions, + }, + }, + correlationId, + ); + } catch { + return unavailable(correlationId); + } + }, + }; +} + +function resultRows(result: unknown): T[] { + if (!Array.isArray(result)) return []; + const rows = result[0]; + return Array.isArray(rows) ? (rows as T[]) : []; +} + +export const peopleUsersAdapters: PeopleUsersAdapters = { + async loadUsers(input, projection) { + const [{ and, asc, count, desc, eq, like, or }, { Ban, db, User }] = + await Promise.all([import("drizzle-orm"), import("@/lib/db")]); + const searchConditions = input.search + ? [ + like(User.username, `%${input.search}%`), + ...(projection.includeMail + ? [like(User.mail, `%${input.search}%`)] + : []), + ...(projection.includeIp + ? [like(User.ipCurrent, `%${input.search}%`)] + : []), + ] + : []; + const numericSearch = Number(input.search); + if ( + input.search && + Number.isSafeInteger(numericSearch) && + numericSearch > 0 + ) { + searchConditions.push(eq(User.id, numericSearch)); + } + const where = + searchConditions.length > 0 ? and(or(...searchConditions)) : undefined; + const sortColumn = + input.sort === "username" + ? User.username + : input.sort === "rank" + ? User.rank + : User.id; + const direction = input.order === "desc" ? desc : asc; + const selection = buildPeopleUserSelection(User, projection) as { + id: typeof User.id; + username: typeof User.username; + rank: typeof User.rank; + online: typeof User.online; + mail: typeof User.mail; + ipCurrent: typeof User.ipCurrent; + }; + const [rows, totals] = await Promise.all([ + db + .select(selection) + .from(User) + .where(where) + .orderBy(direction(sortColumn), asc(User.id)) + .limit(input.offset + input.pageSize), + db.select({ total: count() }).from(User).where(where), + ]); + const ids = (rows as unknown as PeopleUserRecord[]) + .map((row) => Number(row.id)) + .filter((id) => Number.isSafeInteger(id)); + const activeBans = + ids.length === 0 + ? [] + : await db + .select({ userId: Ban.userId, banExpire: Ban.banExpire }) + .from(Ban) + .where(or(...ids.map((id) => eq(Ban.userId, id)))); + const latestBan = new Map(); + for (const ban of activeBans) { + const current = latestBan.get(ban.userId) ?? 0; + if (ban.banExpire === 0 || ban.banExpire > current) { + latestBan.set(ban.userId, ban.banExpire); + } + } + return { + rows: (rows as unknown as PeopleUserRecord[]).map((row) => ({ + ...row, + bannedUntil: latestBan.get(Number(row.id)) ?? null, + })), + total: Number(totals[0]?.total ?? 0), + }; + }, + async loadUser(id, projection) { + const [{ desc, eq }, { Ban, db, User }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const [row] = await db + .select({ + ...(buildPeopleUserSelection(User, projection) as { + id: typeof User.id; + username: typeof User.username; + rank: typeof User.rank; + online: typeof User.online; + mail: typeof User.mail; + ipCurrent: typeof User.ipCurrent; + }), + motto: User.motto, + look: User.look, + accountCreated: User.accountCreated, + lastLogin: User.lastLogin, + }) + .from(User) + .where(eq(User.id, id)) + .limit(1); + if (!row) return null; + const [ban] = await db + .select({ banExpire: Ban.banExpire }) + .from(Ban) + .where(eq(Ban.userId, id)) + .orderBy(desc(Ban.timestamp), desc(Ban.id)) + .limit(1); + return { + ...(row as unknown as PeopleUserDetailRecord), + bannedUntil: ban?.banExpire ?? null, + }; + }, + async loadMultiAccounts(input) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const groupResult = await db.execute(sql` + SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount + FROM users + WHERE ip_current <> '' + GROUP BY ip_current + HAVING COUNT(*) >= 2 + ORDER BY accountCount DESC, ip_current ASC + `); + const groups = resultRows<{ ipCurrent: string; accountCount: number }>( + groupResult, + ); + const filtered = input.search + ? groups.filter((group) => group.ipCurrent.includes(input.search)) + : groups; + const selected = filtered.slice(0, input.offset + input.pageSize); + const rows = await Promise.all( + selected.map(async (group) => { + const usersResult = await db.execute(sql` + SELECT id, username, rank, online + FROM users + WHERE ip_current = ${group.ipCurrent} + ORDER BY id ASC + `); + const accounts = resultRows<{ + id: number; + username: string; + rank: number; + online: string; + }>(usersResult).map((user) => ({ + id: Number(user.id), + username: user.username, + rank: Number(user.rank), + online: user.online === "1", + href: `/ase/people/users/${Number(user.id)}` as const, + })); + return { + key: group.ipCurrent, + accountCount: Number(group.accountCount), + accounts, + }; + }), + ); + return { rows, total: filtered.length }; + }, + async loadSanctions(userId) { + const [{ desc, eq }, { Ban, db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const rows = await db + .select({ + id: Ban.id, + type: Ban.type, + reason: Ban.banReason, + createdAt: Ban.timestamp, + expiresAt: Ban.banExpire, + }) + .from(Ban) + .where(eq(Ban.userId, userId)) + .orderBy(desc(Ban.timestamp), desc(Ban.id)) + .limit(100); + const now = Math.floor(Date.now() / 1000); + return rows.map((row) => ({ + id: row.id, + kind: row.type, + reason: row.reason, + createdAt: toPeopleIsoDate(row.createdAt), + expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt), + active: row.expiresAt === 0 || row.expiresAt > now, + })); + }, +}; + +export const peopleUsersQuery = createPeopleUsersQuery(peopleUsersAdapters); diff --git a/src/features/housekeeping/domains/people/routes.test.ts b/src/features/housekeeping/domains/people/routes.test.ts new file mode 100644 index 00000000..fa02e081 --- /dev/null +++ b/src/features/housekeeping/domains/people/routes.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { peopleMigrationEntries } from "../../migration/people"; +import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes"; + +const expectedRoutes = [ + [ + "people.users.list", + "/ase/people/users", + [PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW], + ], + ["people.users.edit", "/ase/people/users/:id/edit", [PERMS.USERS_EDIT]], + [ + "people.users.multi-accounts", + "/ase/people/users/multi-accounts", + [PERMS.USERS_VIEW], + ], + [ + "people.users.detail", + "/ase/people/users/:id", + [PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW], + ], + [ + "people.community.online", + "/ase/people/community/online", + [PERMS.USERS_VIEW], + ], + [ + "people.community.guilds", + "/ase/people/community/guilds", + [PERMS.USERS_VIEW], + ], + [ + "people.community.guild-detail", + "/ase/people/community/guilds/:id", + [PERMS.USERS_VIEW], + ], + [ + "people.staff.applications", + "/ase/people/staff/applications", + [PERMS.USERS_VIEW], + ], + ["people.staff.teams", "/ase/people/staff/teams", [PERMS.USERS_VIEW]], + [ + "people.moderation.overview", + "/ase/people/moderation", + [ + PERMS.MODERATION_VIEW, + PERMS.MOD_CFH_VIEW, + PERMS.MOD_ACTIONS, + PERMS.MODERATION_EDIT, + PERMS.MOD_BANS_VIEW, + PERMS.BANS_VIEW, + PERMS.MOD_TICKETS_VIEW, + PERMS.TICKETS_VIEW, + PERMS.MOD_TEAM_VIEW, + PERMS.MOD_USERS_VIEW, + PERMS.USERS_VIEW, + ], + ], + [ + "people.moderation.actions", + "/ase/people/moderation/actions", + [PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS], + ], + [ + "people.moderation.cfh", + "/ase/people/moderation/cfh", + [PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW], + ], + [ + "people.moderation.cfh-detail", + "/ase/people/moderation/cfh/:id", + [PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW], + ], + [ + "people.staff.moderation-team", + "/ase/people/staff/moderation-team", + [PERMS.MODERATION_VIEW, PERMS.MOD_TEAM_VIEW], + ], + [ + "people.moderation.bans", + "/ase/people/moderation/bans", + [PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW], + ], + ["people.moderation.ip", "/ase/people/moderation/ip", [PERMS.SETTINGS_VIEW]], + [ + "people.moderation.vpn", + "/ase/people/moderation/vpn", + [PERMS.SETTINGS_VIEW], + ], + [ + "people.moderation.word-filter", + "/ase/people/moderation/word-filter", + [PERMS.WORDFILTER_VIEW], + ], + [ + "people.support.tickets", + "/ase/people/support/tickets", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ], + [ + "people.support.ticket-desk", + "/ase/people/support/tickets/desk", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ], + [ + "people.support.ticket-templates", + "/ase/people/support/tickets/templates", + [PERMS.TICKETS_EDIT], + ], + [ + "people.support.ticket-detail", + "/ase/people/support/tickets/:id", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ], + [ + "people.support.help-tickets", + "/ase/people/support/help-tickets", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ], + [ + "people.support.help-ticket-detail", + "/ase/people/support/help-tickets/:id", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ], +] as const; + +describe("PEOPLE_ROUTES", () => { + it("declares the exact ordered People workflows", () => { + expect(PEOPLE_ROUTE_IDS).toEqual(expectedRoutes.map(([id]) => id)); + expect(PEOPLE_ROUTES.map((route) => route.id)).toEqual(PEOPLE_ROUTE_IDS); + }); + + it("uses canonical People links, stable labels, and exact read capabilities", () => { + expect( + PEOPLE_ROUTES.map((route) => [ + route.id, + route.href, + route.labelKey, + route.capability.mode, + route.capability.slugs, + ]), + ).toEqual( + expectedRoutes.map(([id, href, capabilities]) => [ + id, + href, + `pages.housekeeping.routes.${id}`, + "any", + capabilities, + ]), + ); + }); + + it("covers every distinct matrix-listed People destination exactly once", () => { + const plannedTargets = [ + ...new Set( + peopleMigrationEntries.flatMap((entry) => + entry.targetPath === null ? [] : [entry.targetPath], + ), + ), + ].sort(); + const routeTargets = PEOPLE_ROUTES.map((route) => route.href).sort(); + + expect(routeTargets).toEqual(plannedTargets); + expect(routeTargets).toHaveLength(24); + expect(new Set(routeTargets).size).toBe(routeTargets.length); + }); +}); diff --git a/src/features/housekeeping/domains/people/routes.ts b/src/features/housekeeping/domains/people/routes.ts new file mode 100644 index 00000000..d953033d --- /dev/null +++ b/src/features/housekeeping/domains/people/routes.ts @@ -0,0 +1,159 @@ +import { PERMS } from "@/lib/permission-slugs"; +import { + anyCapability, + type CanonicalHousekeepingHref, + type HousekeepingRouteDefinition, +} from "../../foundation/contracts"; + +export const PEOPLE_ROUTE_IDS = [ + "people.users.list", + "people.users.edit", + "people.users.multi-accounts", + "people.users.detail", + "people.community.online", + "people.community.guilds", + "people.community.guild-detail", + "people.staff.applications", + "people.staff.teams", + "people.moderation.overview", + "people.moderation.actions", + "people.moderation.cfh", + "people.moderation.cfh-detail", + "people.staff.moderation-team", + "people.moderation.bans", + "people.moderation.ip", + "people.moderation.vpn", + "people.moderation.word-filter", + "people.support.tickets", + "people.support.ticket-desk", + "people.support.ticket-templates", + "people.support.ticket-detail", + "people.support.help-tickets", + "people.support.help-ticket-detail", +] as const; + +export type PeopleRouteId = (typeof PEOPLE_ROUTE_IDS)[number]; + +function peopleRoute( + id: PeopleRouteId, + href: CanonicalHousekeepingHref, + capabilities: readonly string[], +): HousekeepingRouteDefinition { + return { + id, + labelKey: `pages.housekeeping.routes.${id}`, + href, + capability: anyCapability(...capabilities), + }; +} + +export const PEOPLE_ROUTES = [ + peopleRoute("people.users.list", "/ase/people/users", [ + PERMS.USERS_VIEW, + PERMS.MOD_USERS_VIEW, + ]), + peopleRoute("people.users.edit", "/ase/people/users/:id/edit", [ + PERMS.USERS_EDIT, + ]), + peopleRoute( + "people.users.multi-accounts", + "/ase/people/users/multi-accounts", + [PERMS.USERS_VIEW], + ), + peopleRoute("people.users.detail", "/ase/people/users/:id", [ + PERMS.USERS_VIEW, + PERMS.MOD_USERS_VIEW, + ]), + peopleRoute("people.community.online", "/ase/people/community/online", [ + PERMS.USERS_VIEW, + ]), + peopleRoute("people.community.guilds", "/ase/people/community/guilds", [ + PERMS.USERS_VIEW, + ]), + peopleRoute( + "people.community.guild-detail", + "/ase/people/community/guilds/:id", + [PERMS.USERS_VIEW], + ), + peopleRoute("people.staff.applications", "/ase/people/staff/applications", [ + PERMS.USERS_VIEW, + ]), + peopleRoute("people.staff.teams", "/ase/people/staff/teams", [ + PERMS.USERS_VIEW, + ]), + peopleRoute("people.moderation.overview", "/ase/people/moderation", [ + PERMS.MODERATION_VIEW, + PERMS.MOD_CFH_VIEW, + PERMS.MOD_ACTIONS, + PERMS.MODERATION_EDIT, + PERMS.MOD_BANS_VIEW, + PERMS.BANS_VIEW, + PERMS.MOD_TICKETS_VIEW, + PERMS.TICKETS_VIEW, + PERMS.MOD_TEAM_VIEW, + PERMS.MOD_USERS_VIEW, + PERMS.USERS_VIEW, + ]), + peopleRoute("people.moderation.actions", "/ase/people/moderation/actions", [ + PERMS.MODERATION_EDIT, + PERMS.MOD_ACTIONS, + ]), + peopleRoute("people.moderation.cfh", "/ase/people/moderation/cfh", [ + PERMS.MODERATION_VIEW, + PERMS.MOD_CFH_VIEW, + ]), + peopleRoute( + "people.moderation.cfh-detail", + "/ase/people/moderation/cfh/:id", + [PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW], + ), + peopleRoute( + "people.staff.moderation-team", + "/ase/people/staff/moderation-team", + [PERMS.MODERATION_VIEW, PERMS.MOD_TEAM_VIEW], + ), + peopleRoute("people.moderation.bans", "/ase/people/moderation/bans", [ + PERMS.BANS_VIEW, + PERMS.MOD_BANS_VIEW, + ]), + peopleRoute("people.moderation.ip", "/ase/people/moderation/ip", [ + PERMS.SETTINGS_VIEW, + ]), + peopleRoute("people.moderation.vpn", "/ase/people/moderation/vpn", [ + PERMS.SETTINGS_VIEW, + ]), + peopleRoute( + "people.moderation.word-filter", + "/ase/people/moderation/word-filter", + [PERMS.WORDFILTER_VIEW], + ), + peopleRoute("people.support.tickets", "/ase/people/support/tickets", [ + PERMS.TICKETS_VIEW, + PERMS.MOD_TICKETS_VIEW, + ]), + peopleRoute( + "people.support.ticket-desk", + "/ase/people/support/tickets/desk", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ), + peopleRoute( + "people.support.ticket-templates", + "/ase/people/support/tickets/templates", + [PERMS.TICKETS_EDIT], + ), + peopleRoute( + "people.support.ticket-detail", + "/ase/people/support/tickets/:id", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ), + peopleRoute( + "people.support.help-tickets", + "/ase/people/support/help-tickets", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ), + peopleRoute( + "people.support.help-ticket-detail", + "/ase/people/support/help-tickets/:id", + [PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW], + ), +] as const satisfies readonly HousekeepingRouteDefinition[]; diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 974b0d48..b5ad1d37 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -59,6 +59,48 @@ const approvedSystemRuntimeImports = new Map>([ "drizzle-orm", ]), ], + [ + "src/features/housekeeping/domains/people/queries/users.ts", + new Set([ + "src/features/housekeeping/domains/people/models", + "src/lib/db", + "drizzle-orm", + ]), + ], + [ + "src/features/housekeeping/domains/people/queries/community.ts", + new Set([ + "src/features/housekeeping/domains/people/models", + "src/lib/db", + "drizzle-orm", + ]), + ], + [ + "src/features/housekeeping/domains/people/queries/staff.ts", + new Set([ + "src/features/housekeeping/domains/people/models", + "src/lib/admin/min-staff-rank", + "src/lib/db", + "drizzle-orm", + ]), + ], + [ + "src/features/housekeeping/domains/people/queries/support.ts", + new Set([ + "src/features/housekeeping/domains/people/models", + "src/lib/db", + "drizzle-orm", + ]), + ], + [ + "src/features/housekeeping/domains/people/queries/moderation.ts", + new Set([ + "src/features/housekeeping/domains/people/models", + "src/lib/admin/min-staff-rank", + "src/lib/db", + "drizzle-orm", + ]), + ], [ "src/features/housekeeping/domains/system/services/mutations.ts", new Set([