From d1382c839e49341e77fab62362078ec5a9023dc1 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 29 Aug 2026 02:13:53 +0200 Subject: [PATCH] fix(housekeeping): harden people read boundaries --- .../task-11-report.md | 175 ++++++- .../domains/people/models.test.ts | 32 +- .../housekeeping/domains/people/models.ts | 163 +++++-- .../domains/people/queries/community.ts | 37 +- .../domains/people/queries/moderation.ts | 84 ++-- .../people-adapters-production.test.ts | 447 +++++++++++++----- .../people/queries/people-queries.test.ts | 320 ++++++++++++- .../domains/people/queries/staff.ts | 42 +- .../domains/people/queries/support.ts | 278 +++++++---- .../domains/people/queries/users.ts | 252 ++++++---- src/lib/admin/ticket-inbox.ts | 215 +++++++++ src/lib/admin/ticket-queue-counts.ts | 36 +- 12 files changed, 1635 insertions(+), 446 deletions(-) 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 index 695d39d3..227ea01c 100644 --- a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md @@ -1,6 +1,6 @@ # Task 11 — People workflow read models -Status: DONE +Status: DONE — fix round 1 ## Delivered scope @@ -15,8 +15,25 @@ Status: DONE - 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. +- Adapters fail closed. Malformed driver envelopes, invalid or non-positive identifiers, corrupt links, non-serializable DTO values, and count failures map to `DEPENDENCY_UNAVAILABLE`. Missing valid detail entities map to `NOT_FOUND`; invalid request identifiers map to `VALIDATION`. No partial-result shape is returned because no People DTO explicitly names failed sources. +- Pagination clamps page size to 100 and offset to 10,000. Deterministic primary sorting, numeric-ID tie breaking, and `LIMIT`/`OFFSET` now execute in the database; no list query fetches a prefix for locale re-sorting or second slicing. +- Raw production adapters and `buildPeopleUserSelection` are module-private. Runtime exports expose only context-authorized query factories and singleton query surfaces. +- Multi-account clusters use one bounded CTE/window query, cap accounts per cluster at 100, and never issue one query per IP cluster. +- User detail/edit now includes the operator's watched state and canonical permission-rank data. Support ticket reads use the existing unified inbox through a strict, fail-closed, database-paged mode that includes CMS and help-center rows while leaving the legacy tolerant mode unchanged. +- Support desk/detail DTOs explicitly include queue counts, bounded staff, and the relevant active ban. Ticket messages/replies and staff rows are bounded. +- Active bans are filtered before sorting. Expiry `0` remains the permanent-active sentinel; expired rows cannot hide permanent or future-active bans in lists or details. + +## Official fix round 1 findings + +1. **Authorization boundary:** fixed by making all raw production adapters and the user selection builder module-private and testing only guarded public query surfaces plus source/runtime export contracts. +2. **Pagination and sorting:** fixed by moving declared sort fields, deterministic tie ordering, and bounded `LIMIT`/`OFFSET` to production adapters. The exact `user10`/`user2` and support `status`/`updatedAt` page regressions are covered. +3. **Resource bounds:** fixed by replacing multi-account prefix loading plus per-row `Promise.all` with one bounded batched CTE/window query. No million-row prefix and no N+1 cluster query remain. +4. **Fail-closed database validation:** fixed across People models and community/support/moderation query boundaries. Invalid driver shapes and invalid identifiers cannot become empty lists, `NOT_FOUND`, ID `0`, or corrupt canonical links. +5. **Canonical dependencies:** fixed watched and permission-rank data for user detail/edit; `/support/tickets` now calls strict `fetchUnifiedTicketInbox`; support queue/staff/active-ban data is explicit in canonical query DTOs. +6. **Moderation capability equality:** fixed after direct user authorization. `moderationQuery.capability` now exactly equals the existing eleven-slug overview union already used by the route and `run`; no route, mutation, rank threshold, or new slug changed. +7. **Active bans:** fixed list/detail selection so permanent `ban_expire = 0` and future-active bans are deterministic and expired rows cannot hide them. + +The two official Minor findings remain parked and unchanged as instructed. ## Strict TDD evidence @@ -80,7 +97,7 @@ Production-source contract RED: 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. +Reason: raw production adapters and buildPeopleUserSelection were exported as bypassable runtime internals. ``` Pagination regression RED after adding the production contract fixture: @@ -92,7 +109,7 @@ 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: +GREEN for the original baseline implementation: ```text pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts @@ -102,27 +119,161 @@ 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. +## Fix round 1 strict behavioral TDD evidence + +### Authorization boundary + +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) +Observed runtime exports: buildPeopleUserSelection and peopleUsersAdapters. +``` + +GREEN: + +```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) +``` + +### Database pagination and declared sorting + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts +Test Files 2 failed (2) +Tests 4 failed | 14 passed (18) +Failures: offset 999999 was not capped; DB pages were sliced a second time for user10/user2 and support status/updatedAt. +``` + +GREEN: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts +Test Files 2 passed (2) +Tests 18 passed (18) +``` + +### Multi-account resource bounds + +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 1 failed | 2 passed (3) +Observed prefix result plus one query per IP cluster. +``` + +GREEN: + +```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) +``` + +### Fail-closed validation + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false 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 3 failed (3) +Tests 5 failed | 21 passed (26) +Failures covered ID 0, corrupt links/dates, corrupt detail shapes, and malformed driver envelopes. +``` + +GREEN: + +```text +same command +Test Files 3 passed (3) +Tests 26 passed (26) +``` + +### Canonical dependencies and unified support inbox + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "watched state|hydrates desk|strict unified" +Test Files 2 failed (2) +Tests 3 failed | 22 skipped (25) +``` + +GREEN: + +```text +pnpm exec vitest run --coverage.enabled=false 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 -t "watched state|hydrates desk|strict unified|normalizes BigInt" +Test Files 3 passed (3) +Tests 4 passed | 27 skipped (31) +``` + +### Moderation capability equality + +RED captured before direct authorization: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts -t "eleven-permission" +Test Files 1 failed (1) +Tests 1 failed | 18 skipped (19) +Expected the route/run eleven-slug union; query metadata still contains six slugs. +``` + +GREEN after the user directly authorized only this isolated metadata hunk: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts -t "eleven-permission" +Test Files 1 passed (1) +Tests 1 passed | 18 skipped (19) +``` + +### Active-ban semantics + +RED: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "permanent" +Test Files 1 failed (1) +Tests 1 failed | 4 skipped (5) +Expected permanent expiresAt 0; received null. +``` + +GREEN: + +```text +same command +Test Files 1 passed (1) +Tests 1 passed | 4 skipped (5) +``` + ## 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) +Tests 35 passed (35) -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 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/server-capability-context.test.ts src/features/housekeeping/foundation/contracts/contracts.test.ts +Test Files 9 passed (9) +Tests 81 passed (81) pnpm test:housekeeping Test Files 49 passed (49) -Tests 416 passed (416) +Tests 430 passed (430) 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. +pnpm exec biome check --formatter-enabled=false <11 exact changed Task 11 TypeScript files> +Checked 11 files. No fixes applied. git diff --check Exit 0 diff --git a/src/features/housekeeping/domains/people/models.test.ts b/src/features/housekeeping/domains/people/models.test.ts index 477ed358..93ea66e7 100644 --- a/src/features/housekeeping/domains/people/models.test.ts +++ b/src/features/housekeeping/domains/people/models.test.ts @@ -41,9 +41,12 @@ describe("People list normalization", () => { "id", ), ).toMatchObject({ pageSize: 20, offset: 0 }); + expect( + normalizePeopleListInput({ offset: 999_999 }, ["id"], "id").offset, + ).toBe(10_000); }); - it("sorts a page deterministically with an id tie breaker", () => { + it("wraps rows already sorted and paged by the adapter", () => { const input = normalizePeopleListInput( { pageSize: 2, offset: 1, sort: "username", order: "asc" }, ["id", "username"], @@ -51,20 +54,17 @@ describe("People list normalization", () => { ); 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" }, + { id: 1, username: "Alice" }, ], total: 4, pageSize: 2, @@ -116,6 +116,11 @@ describe("People canonical models", () => { accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")), lastLogin: toPeopleIsoDate(1_700_000_000), sanctions: [], + watched: false, + permission: { + rankName: "Moderator", + ranks: [{ id: 5, name: "Moderator" }], + }, }; expect(JSON.parse(JSON.stringify(detail))).toEqual({ @@ -132,6 +137,11 @@ describe("People canonical models", () => { accountCreated: "2026-08-29T10:20:30.000Z", lastLogin: "2023-11-14T22:13:20.000Z", sanctions: [], + watched: false, + permission: { + rankName: "Moderator", + ranks: [{ id: 5, name: "Moderator" }], + }, }); }); @@ -150,4 +160,16 @@ describe("People canonical models", () => { "/ase/people/moderation/cfh/8", ]); }); + + it("rejects corrupt identifiers and non-serializable date values", () => { + expect(() => + normalizePeopleUser( + { ...rawUser, id: 0 }, + { includeMail: false, includeIp: false }, + ), + ).toThrow(); + expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow(); + expect(() => peopleGuildHref(-4)).toThrow(); + expect(() => toPeopleIsoDate("not-a-date")).toThrow(); + }); }); diff --git a/src/features/housekeeping/domains/people/models.ts b/src/features/housekeeping/domains/people/models.ts index 1fee726e..0091405b 100644 --- a/src/features/housekeeping/domains/people/models.ts +++ b/src/features/housekeeping/domains/people/models.ts @@ -49,7 +49,7 @@ export interface PeopleSanctionSummary { readonly kind: string; readonly reason: string; readonly createdAt: string | null; - readonly expiresAt: string | null; + readonly expiresAt: string | 0 | null; readonly active: boolean; } @@ -59,6 +59,16 @@ export interface PeopleUserDetail extends PeopleUserSummary { readonly accountCreated: string | null; readonly lastLogin: string | null; readonly sanctions: readonly PeopleSanctionSummary[]; + readonly watched: boolean; + readonly permission: { + readonly rankName: string; + readonly ranks: readonly PeoplePermissionRank[]; + }; +} + +export interface PeoplePermissionRank { + readonly id: number; + readonly name: string; } export interface PeopleMultiAccountCluster { @@ -135,15 +145,25 @@ export interface PeopleQueueSnapshot { readonly activeBans: number; } +export interface PeopleSupportStaff { + readonly id: number; + readonly username: string; + readonly rank: number; + readonly href: `/ase/people/users/${number}`; +} + export interface PeopleTicketSummary { + readonly source: "cms" | "help"; readonly id: number; readonly subject: string; readonly status: string; readonly priority: string; - readonly creatorId: number; + readonly creatorId: number | null; readonly creatorUsername: string | null; readonly updatedAt: string | null; - readonly href: `/ase/people/support/tickets/${number}`; + readonly href: + | `/ase/people/support/tickets/${number}` + | `/ase/people/support/help-tickets/${number}`; } export interface PeopleTicketDetail extends PeopleTicketSummary { @@ -157,6 +177,8 @@ export interface PeopleTicketDetail extends PeopleTicketSummary { readonly isStaff: boolean; readonly createdAt: string | null; }[]; + readonly queue: PeopleQueueSnapshot; + readonly staff: readonly PeopleSupportStaff[]; } export interface PeopleHelpTicketSummary { @@ -180,6 +202,9 @@ export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary { readonly content: string; readonly createdAt: string | null; }[]; + readonly queue: PeopleQueueSnapshot; + readonly staff: readonly PeopleSupportStaff[]; + readonly activeBan: PeopleBanSummary | null; } export interface PeopleTicketTemplate { @@ -217,7 +242,7 @@ export interface PeopleBanSummary { readonly type: string; readonly reason: string; readonly createdAt: string | null; - readonly expiresAt: string | null; + readonly expiresAt: string | 0; readonly active: boolean; } @@ -252,7 +277,7 @@ export interface PeopleQueries { const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; -const MAX_OFFSET = 1_000_000; +const MAX_OFFSET = 10_000; const MAX_SEARCH_LENGTH = 128; function boundedInteger( @@ -297,34 +322,22 @@ export function normalizePeopleListInput( }; } -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( +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); - + if ( + !Number.isSafeInteger(total) || + total < 0 || + rows.length > input.pageSize + ) { + throw new Error("invalid People page"); + } + assertPeopleSerializable(rows); return { - items, - total: boundedInteger(total, rows.length, 0, Number.MAX_SAFE_INTEGER), + items: [...rows], + total, pageSize: input.pageSize, offset: input.offset, }; @@ -335,6 +348,22 @@ export function toPeopleNumber(value: unknown, fallback = 0): number { return Number.isSafeInteger(parsed) ? parsed : fallback; } +function positiveSafeInteger(value: unknown): number { + const parsed = typeof value === "bigint" ? Number(value) : Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error("invalid People identifier"); + } + return parsed; +} + +function nonNegativeSafeInteger(value: unknown): number { + const parsed = typeof value === "bigint" ? Number(value) : Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error("invalid People number"); + } + return parsed; +} + export function toPeopleIsoDate(value: unknown): string | null { if (value === null || value === undefined || value === "") return null; let date: Date; @@ -348,40 +377,47 @@ export function toPeopleIsoDate(value: unknown): string | null { ) : new Date(String(value)); } - return Number.isFinite(date.getTime()) ? date.toISOString() : null; + if (!Number.isFinite(date.getTime())) throw new Error("invalid People date"); + return date.toISOString(); } function nullableString(value: unknown): string | null { - if (typeof value !== "string") return null; + if (value === null || value === undefined) return null; + if (typeof value !== "string") throw new Error("invalid People string"); const normalized = value.trim(); return normalized.length > 0 ? normalized : null; } export function peopleUserHref(id: number): `/ase/people/users/${number}` { + positiveSafeInteger(id); return `/ase/people/users/${id}`; } export function peopleGuildHref( id: number, ): `/ase/people/community/guilds/${number}` { + positiveSafeInteger(id); return `/ase/people/community/guilds/${id}`; } export function peopleTicketHref( id: number, ): `/ase/people/support/tickets/${number}` { + positiveSafeInteger(id); return `/ase/people/support/tickets/${id}`; } export function peopleHelpTicketHref( id: number, ): `/ase/people/support/help-tickets/${number}` { + positiveSafeInteger(id); return `/ase/people/support/help-tickets/${id}`; } export function peopleCfhHref( id: number, ): `/ase/people/moderation/cfh/${number}` { + positiveSafeInteger(id); return `/ase/people/moderation/cfh/${id}`; } @@ -389,12 +425,22 @@ export function normalizePeopleUser( row: PeopleUserRecord, projection: { readonly includeMail: boolean; readonly includeIp: boolean }, ): PeopleUserSummary { - const id = toPeopleNumber(row.id); - const bannedUntil = toPeopleNumber(row.bannedUntil, 0); + const id = positiveSafeInteger(row.id); + if (typeof row.username !== "string" || row.username.trim().length === 0) { + throw new Error("invalid People username"); + } + const rank = nonNegativeSafeInteger(row.rank); + const bannedUntil = + row.bannedUntil === null || row.bannedUntil === undefined + ? null + : nonNegativeSafeInteger(row.bannedUntil); + const onlineValues = new Set([true, false, 0, 1, "0", "1", "true", "false"]); + if (!onlineValues.has(row.online as never)) + throw new Error("invalid People online value"); return { id, - username: String(row.username ?? ""), - rank: toPeopleNumber(row.rank), + username: row.username, + rank, online: row.online === true || row.online === 1 || @@ -402,7 +448,54 @@ export function normalizePeopleUser( row.online === "true", mail: projection.includeMail ? nullableString(row.mail) : null, ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null, - bannedUntil: bannedUntil > 0 ? bannedUntil : null, + bannedUntil, href: peopleUserHref(id), }; } + +const DATE_KEYS = new Set([ + "accountCreated", + "createdAt", + "expiresAt", + "lastLogin", + "updatedAt", +]); + +export function assertPeopleSerializable(value: unknown): void { + function visit(current: unknown, key = ""): void { + if ( + current === null || + typeof current === "string" || + typeof current === "boolean" + ) { + if ( + typeof current === "string" && + DATE_KEYS.has(key) && + !Number.isFinite(Date.parse(current)) + ) { + throw new Error("invalid People date string"); + } + return; + } + if (typeof current === "number") { + if (!Number.isSafeInteger(current)) + throw new Error("invalid People number"); + if ((key === "id" || key.endsWith("Id")) && current <= 0) { + throw new Error("invalid People identifier"); + } + return; + } + if (Array.isArray(current)) { + for (const item of current) visit(item); + return; + } + if (typeof current !== "object" || current instanceof Date) { + throw new Error("non-serializable People value"); + } + for (const [childKey, child] of Object.entries(current)) { + if (child === undefined) throw new Error("undefined People value"); + visit(child, childKey); + } + } + visit(value); +} diff --git a/src/features/housekeeping/domains/people/queries/community.ts b/src/features/housekeeping/domains/people/queries/community.ts index ce2d3a16..11c912d0 100644 --- a/src/features/housekeeping/domains/people/queries/community.ts +++ b/src/features/housekeeping/domains/people/queries/community.ts @@ -9,6 +9,7 @@ import { ok, } from "../../../foundation/contracts"; import { + assertPeopleSerializable, createPeoplePage, type ListInput, normalizePeopleListInput, @@ -77,9 +78,15 @@ export function createPeopleCommunityQuery( } 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); + if (guild === null) { + return fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + correlationId, + ); + } + assertPeopleSerializable(guild); + return ok({ kind: "guild" as const, guild }, correlationId); } catch { return unavailable(correlationId); } @@ -87,7 +94,9 @@ export function createPeopleCommunityQuery( const list = normalizePeopleListInput( input.list, - ["id", "username", "name"], + input.routeId === "people.community.online" + ? ["id", "username"] + : ["id", "name"], "id", ); try { @@ -96,9 +105,7 @@ export function createPeopleCommunityQuery( return ok( { kind: "online" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "username" ? row.username : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -108,9 +115,7 @@ export function createPeopleCommunityQuery( return ok( { kind: "guilds" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "name" ? row.name : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -122,11 +127,13 @@ export function createPeopleCommunityQuery( } function resultRows(result: unknown): T[] { - if (!Array.isArray(result)) return []; - return Array.isArray(result[0]) ? (result[0] as T[]) : []; + if (!Array.isArray(result) || !Array.isArray(result[0])) { + throw new Error("invalid People driver result"); + } + return result[0] as T[]; } -export const peopleCommunityAdapters: PeopleCommunityAdapters = { +const peopleCommunityAdapters: PeopleCommunityAdapters = { async loadOnline(input) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), @@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = { ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${ input.order === "desc" ? sql`DESC` : sql`ASC` }, id ASC - LIMIT ${input.offset + input.pageSize} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`), ]); @@ -187,7 +194,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = { 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} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total diff --git a/src/features/housekeeping/domains/people/queries/moderation.ts b/src/features/housekeeping/domains/people/queries/moderation.ts index 6369daab..aeb26646 100644 --- a/src/features/housekeeping/domains/people/queries/moderation.ts +++ b/src/features/housekeeping/domains/people/queries/moderation.ts @@ -9,6 +9,7 @@ import { ok, } from "../../../foundation/contracts"; import { + assertPeopleSerializable, createPeoplePage, type ListInput, normalizePeopleListInput, @@ -79,10 +80,15 @@ export type PeopleModerationQueryData = const broadCapability = anyCapability( PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW, - PERMS.BANS_VIEW, + PERMS.MOD_ACTIONS, + PERMS.MODERATION_EDIT, PERMS.MOD_BANS_VIEW, - PERMS.SETTINGS_VIEW, - PERMS.WORDFILTER_VIEW, + PERMS.BANS_VIEW, + PERMS.MOD_TICKETS_VIEW, + PERMS.TICKETS_VIEW, + PERMS.MOD_TEAM_VIEW, + PERMS.MOD_USERS_VIEW, + PERMS.USERS_VIEW, ); function routeCapability(routeId: PeopleModerationQueryInput["routeId"]) { @@ -136,10 +142,12 @@ export function createPeopleModerationQuery( try { if (input.routeId === "people.moderation.overview") { + const snapshot = await adapters.loadOverview(); + assertPeopleSerializable(snapshot); return ok( { kind: "overview" as const, - snapshot: await adapters.loadOverview(), + snapshot, }, correlationId, ); @@ -154,39 +162,44 @@ export function createPeopleModerationQuery( ); } 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 (ticket === null) { + return fail( + "NOT_FOUND", + "errors.housekeeping.notFound", + correlationId, + ); + } + assertPeopleSerializable(ticket); + return ok({ kind: "cfh-detail" as const, ticket }, correlationId); } if (input.routeId === "people.moderation.ip") { const rules = await adapters.loadIpRules(); + assertPeopleSerializable(rules); return ok({ kind: "ip-rules" as const, ...rules }, correlationId); } if (input.routeId === "people.moderation.vpn") { + const settings = await adapters.loadVpnSettings(); + assertPeopleSerializable(settings); return ok( { kind: "vpn" as const, - settings: await adapters.loadVpnSettings(), + settings, }, correlationId, ); } - const list = normalizePeopleListInput( - input.list, - ["id", "username", "createdAt", "word"], - "id", - ); + const allowedSorts = + input.routeId === "people.moderation.word-filter" + ? ["id", "word"] + : ["id", "username", "createdAt"]; + const list = normalizePeopleListInput(input.list, allowedSorts, "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, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -196,9 +209,7 @@ export function createPeopleModerationQuery( return ok( { kind: "bans" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "username" ? (row.username ?? "") : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -207,9 +218,7 @@ export function createPeopleModerationQuery( return ok( { kind: "word-filter" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "word" ? row.word : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -225,11 +234,13 @@ export function createPeopleModerationQuery( } function resultRows(result: unknown): T[] { - if (!Array.isArray(result)) return []; - return Array.isArray(result[0]) ? (result[0] as T[]) : []; + if (!Array.isArray(result) || !Array.isArray(result[0])) { + throw new Error("invalid People driver result"); + } + return result[0] as T[]; } -export const peopleModerationAdapters: PeopleModerationAdapters = { +const peopleModerationAdapters: PeopleModerationAdapters = { async loadOverview() { const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([ import("drizzle-orm"), @@ -276,10 +287,10 @@ export const peopleModerationAdapters: PeopleModerationAdapters = { 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`} ${ + ORDER BY ${input.sort === "username" ? sql`reported.username` : input.sort === "createdAt" ? sql`c.timestamp` : sql`c.id`} ${ input.order === "asc" ? sql`ASC` : sql`DESC` }, c.id ASC - LIMIT ${input.offset + input.pageSize} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM support_tickets c @@ -333,7 +344,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = { 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 + ORDER BY (b.ban_expire = 0) DESC, b.ban_expire DESC, b.timestamp DESC, b.id DESC LIMIT 1 `); const row = resultRows<{ @@ -382,8 +393,8 @@ export const peopleModerationAdapters: PeopleModerationAdapters = { createdAt: toPeopleIsoDate(row.banCreatedAt), expiresAt: row.banExpiresAt === 0 - ? null - : toPeopleIsoDate(row.banExpiresAt), + ? 0 + : (toPeopleIsoDate(row.banExpiresAt) as string), active: true, }, }; @@ -410,7 +421,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = { 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} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM bans b @@ -438,7 +449,10 @@ export const peopleModerationAdapters: PeopleModerationAdapters = { type: row.type, reason: row.reason, createdAt: toPeopleIsoDate(row.createdAt), - expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt), + expiresAt: + row.expiresAt === 0 + ? (0 as const) + : (toPeopleIsoDate(row.expiresAt) as string), active: true, })); return { @@ -508,7 +522,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = { ORDER BY ${input.sort === "word" ? sql`word` : sql`id`} ${ input.order === "desc" ? sql`DESC` : sql`ASC` }, id ASC - LIMIT ${input.offset + input.pageSize} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute( sql`SELECT COUNT(*) AS total FROM website_wordfilter ${where}`, 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 index 045f64cd..bb34e0f2 100644 --- a/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +++ b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts @@ -1,148 +1,337 @@ +import { readFileSync } from "node:fs"; 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"; +import { PERMS } from "@/lib/permission-slugs"; +import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; -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", - ]); +const publicRuntimeExports = new Map([ + ["./users", ["createPeopleUsersQuery", "peopleUsersQuery"]], + ["./community", ["createPeopleCommunityQuery", "peopleCommunityQuery"]], + ["./staff", ["createPeopleStaffQuery", "peopleStaffQuery"]], + ["./support", ["createPeopleSupportQuery", "peopleSupportQuery"]], + ["./moderation", ["createPeopleModerationQuery", "peopleModerationQuery"]], +] as const); + +describe("People production authorization boundary", () => { + it("exports only context-authorized query factories and singleton surfaces", async () => { + for (const [modulePath, expected] of publicRuntimeExports) { + const runtime = await import(modulePath); + expect(Object.keys(runtime).sort(), modulePath).toEqual( + [...expected].sort(), + ); + } }); - 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", - }; + it("keeps the PII selection builder private and excludes secret columns", () => { + const source = readFileSync(new URL("./users.ts", import.meta.url), "utf8"); + expect(source).toContain("function buildPeopleUserSelection"); + expect(source).not.toContain("export function buildPeopleUserSelection"); + for (const forbidden of [ + "password", + "authTicket", + "secretKey", + "twoFactorSecret", + ]) { + expect(source).not.toContain(forbidden); + } + expect(source).toContain('import("@/lib/services/watch")'); + expect(source).toMatch(/FROM permissions ORDER BY id ASC/); + }); - 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("uses a strict DB-paged unified support boundary without changing legacy tolerance", () => { + const supportSource = readFileSync( + new URL("./support.ts", import.meta.url), + "utf8", ); + const inboxSource = readFileSync( + new URL("../../../../../lib/admin/ticket-inbox.ts", import.meta.url), + "utf8", + ); + expect(supportSource).toContain("fetchUnifiedTicketInbox"); + expect(supportSource).toContain("strict: true"); + expect(inboxSource).toContain("if (options.strict)"); + expect(inboxSource).toContain("UNION ALL"); + expect(inboxSource).toMatch(/LIMIT \$\{perPage\} OFFSET \$\{offset\}/); + expect(inboxSource).toContain(".catch(() => [])"); }); - 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" }, - ], - ]); + it("pages multi-account groups and loads their accounts in one bounded batch", async () => { + vi.resetModules(); + const groups = [ + { ipCurrent: "203.0.113.1", accountCount: 2 }, + { ipCurrent: "203.0.113.2", accountCount: 2 }, + { ipCurrent: "203.0.113.3", accountCount: 2 }, + { ipCurrent: "203.0.113.4", accountCount: 2 }, + ]; + const execute = vi.fn( + async (query: { + strings: readonly string[]; + values: readonly unknown[]; + }) => { + const text = query.strings.join("?"); + if (text.includes("ROW_NUMBER() OVER")) { + return [ + [ + { + ...groups[1], + total: groups.length, + id: 3, + username: "three", + rank: 1, + online: "0", + }, + { + ...groups[1], + total: groups.length, + id: 5, + username: "five", + rank: 1, + online: "0", + }, + { + ...groups[2], + total: groups.length, + id: 4, + username: "four", + rank: 1, + online: "0", + }, + ], + ]; + } + if (text.includes("COUNT(*) AS total") && text.includes("GROUP BY")) { + return [[{ total: groups.length }]]; + } + if (text.includes("GROUP BY ip_current")) { + return [text.includes("LIMIT") ? groups.slice(1, 3) : groups]; + } + if (text.includes("ip_current IN")) { + return [ + [ + { + id: 3, + username: "three", + rank: 1, + online: "0", + ipCurrent: groups[1].ipCurrent, + }, + { + id: 4, + username: "four", + rank: 1, + online: "0", + ipCurrent: groups[2].ipCurrent, + }, + ], + ]; + } + return [[{ id: 1, username: "one", rank: 1, online: "0" }]]; + }, + ); const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({ - strings, + strings: [...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", + const permissions = new Set([PERMS.USERS_VIEW]); + const capability: HousekeepingCapabilityContext = { + 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 { peopleUsersQuery } = await import("./users"); + const result = await peopleUsersQuery.run(capability, { + routeId: "people.users.multi-accounts", + list: { offset: 1, pageSize: 2 }, }); - expect(result.rows.map((row) => row.key)).toEqual([ - "203.0.113.1", - "203.0.113.2", + expect(result).toMatchObject({ + ok: true, + data: { + page: { + items: [{ key: groups[1].ipCurrent }, { key: groups[2].ipCurrent }], + }, + }, + }); + expect(execute.mock.calls.length).toBeLessThanOrEqual(3); + expect( + execute.mock.calls.map(([query]) => query.strings.join("?")).join("\n"), + ).not.toContain("WHERE ip_current = ?"); + }); + + it("maps a malformed driver result to dependency unavailable", async () => { + vi.resetModules(); + const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings: [...strings], + values, + }); + vi.doMock("drizzle-orm", () => ({ sql })); + vi.doMock("@/lib/db", () => ({ + db: { execute: vi.fn(async () => ({ malformed: true })) }, + })); + const permissions = new Set([PERMS.USERS_VIEW]); + const capability: HousekeepingCapabilityContext = { + 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 { peopleCommunityQuery } = await import("./community"); + const result = await peopleCommunityQuery.run(capability, { + routeId: "people.community.online", + list: {}, + }); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + + it("preserves permanent active bans as zero in list and detail DTOs", async () => { + vi.resetModules(); + const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings: [...strings], + values, + }); + const execute = vi.fn(async (query: { strings: readonly string[] }) => { + const text = query.strings.join("?"); + if (text.includes("SELECT COUNT(*) AS total FROM bans")) + return [[{ total: 1 }]]; + if (text.includes("FROM bans b")) { + return [ + [ + { + id: 71, + userId: 7, + username: "Seven", + staffId: 9, + staffUsername: "Moderator", + type: "account", + reason: "permanent", + createdAt: 1_700_000_000, + expiresAt: 0, + }, + ], + ]; + } + return [ + [ + { + id: 41, + state: 1, + senderId: 2, + senderUsername: "Sender", + reportedId: 7, + reportedUsername: "Seven", + moderatorId: 9, + issue: "issue", + roomId: 3, + createdAt: 1_700_000_000, + banId: 71, + banUserId: 7, + banStaffId: 9, + banType: "account", + banReason: "permanent", + banCreatedAt: 1_700_000_000, + banExpiresAt: 0, + }, + ], + ]; + }); + vi.doMock("drizzle-orm", () => ({ sql })); + vi.doMock("@/lib/db", () => ({ db: { execute } })); + const permissions = new Set([ + PERMS.MOD_BANS_VIEW, + PERMS.MOD_CFH_VIEW, ]); - expect(result.total).toBe(3); + const capability: HousekeepingCapabilityContext = { + 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 { peopleModerationQuery } = await import("./moderation"); + const list = await peopleModerationQuery.run(capability, { + routeId: "people.moderation.bans", + list: {}, + }); + const detail = await peopleModerationQuery.run(capability, { + routeId: "people.moderation.cfh-detail", + id: 41, + }); + expect(list).toMatchObject({ + ok: true, + data: { page: { items: [{ expiresAt: 0, active: true }] } }, + }); + expect(detail).toMatchObject({ + ok: true, + data: { ticket: { activeBan: { expiresAt: 0, active: true } } }, + }); + }); + + it("uses the strict unified ticket inbox and includes help-center rows", async () => { + vi.resetModules(); + const fetchUnifiedTicketInbox = vi.fn(async () => ({ + rows: [ + { + key: "help-12", + kind: "help" as const, + id: "12", + title: "Help ticket", + user: "Seven", + userId: 7, + status: "open", + open: true, + sortAt: Date.parse("2026-08-20T00:00:00.000Z"), + date: "2026-08-20", + href: "/admin/help-tickets/12", + }, + ], + total: 1, + page: 1, + perPage: 20, + lastPage: 1, + cmsTotal: 0, + helpTotal: 1, + })); + vi.doMock("@/lib/admin/ticket-inbox", () => ({ fetchUnifiedTicketInbox })); + vi.doMock("drizzle-orm", () => ({ + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + strings: [...strings], + values, + }), + })); + vi.doMock("@/lib/db", () => ({ + db: { execute: vi.fn(async () => ({ malformed: true })) }, + })); + const permissions = new Set([PERMS.TICKETS_VIEW]); + const capability: HousekeepingCapabilityContext = { + 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 { peopleSupportQuery } = await import("./support"); + const result = await peopleSupportQuery.run(capability, { + routeId: "people.support.tickets", + list: { sort: "updatedAt", order: "desc" }, + }); + expect(fetchUnifiedTicketInbox).toHaveBeenCalledWith( + expect.objectContaining({ strict: true, page: 1, perPage: 20 }), + ); + expect(result).toMatchObject({ + ok: true, + data: { + page: { + items: [{ id: 12, href: "/ase/people/support/help-tickets/12" }], + }, + }, + }); }); }); diff --git a/src/features/housekeeping/domains/people/queries/people-queries.test.ts b/src/features/housekeeping/domains/people/queries/people-queries.test.ts index 5570a995..d35f995d 100644 --- a/src/features/housekeeping/domains/people/queries/people-queries.test.ts +++ b/src/features/housekeeping/domains/people/queries/people-queries.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; +import { PEOPLE_ROUTES } from "../routes"; import { createPeopleCommunityQuery } from "./community"; import { createPeopleModerationQuery } from "./moderation"; import { createPeopleStaffQuery } from "./staff"; @@ -29,9 +30,75 @@ const rawUser = (id: number, username: string) => ({ }); describe("People users query", () => { - it("normalizes list input, sorts stable ties, and projects PII for admin users view", async () => { + it("includes watched state and permission-rank data required by detail and edit", async () => { + const query = createPeopleUsersQuery({ + loadUsers: async () => ({ rows: [], total: 0 }), + loadUser: async () => ({ + ...rawUser(9, "Nine"), + motto: "", + look: "hd-180-1", + accountCreated: 1_700_000_000, + lastLogin: 1_700_000_100, + }), + loadMultiAccounts: async () => ({ rows: [], total: 0 }), + loadSanctions: async () => [], + loadWatchedUserIds: async () => new Set([9]), + loadPermissionRanks: async () => [ + { id: 1, name: "User" }, + { id: 5, name: "Moderator" }, + ], + } as never); + const result = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.detail", + id: 9, + }); + expect(result).toMatchObject({ + ok: true, + data: { + user: { + watched: true, + permission: { + rankName: "Moderator", + ranks: [ + { id: 1, name: "User" }, + { id: 5, name: "Moderator" }, + ], + }, + }, + }, + }); + }); + + it("preserves a DB-paged username window without locale re-sort or offset slicing", async () => { + const query = createPeopleUsersQuery({ + loadUsers: async () => ({ + rows: [rawUser(10, "user10"), rawUser(2, "user2")], + total: 20, + }), + loadUser: async () => null, + loadMultiAccounts: async () => ({ rows: [], total: 0 }), + loadSanctions: async () => [], + loadWatchedUserIds: async () => new Set(), + loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }], + }); + + const result = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.users.list", + list: { sort: "username", order: "asc", offset: 2, pageSize: 2 }, + }); + + expect(result).toMatchObject({ + ok: true, + data: { + kind: "users", + page: { items: [{ id: 10 }, { id: 2 }], offset: 2, pageSize: 2 }, + }, + }); + }); + + it("normalizes list input and projects PII for admin users view", async () => { const loadUsers = vi.fn(async () => ({ - rows: [rawUser(3, "alice"), rawUser(1, "Alice"), rawUser(2, "alice")], + rows: [rawUser(1, "Alice"), rawUser(2, "alice"), rawUser(3, "alice")], total: 3, })); const query = createPeopleUsersQuery({ @@ -39,6 +106,8 @@ describe("People users query", () => { loadUser: async () => null, loadMultiAccounts: async () => ({ rows: [], total: 0 }), loadSanctions: async () => [], + loadWatchedUserIds: async () => new Set(), + loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }], }); const result = await query.run(context([PERMS.USERS_VIEW]), { @@ -94,6 +163,8 @@ describe("People users query", () => { loadUser: async () => null, loadMultiAccounts: async () => ({ rows: [], total: 0 }), loadSanctions: async () => [], + loadWatchedUserIds: async () => new Set(), + loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }], }); const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), { @@ -132,6 +203,8 @@ describe("People users query", () => { loadUser, loadMultiAccounts: async () => ({ rows: [], total: 0 }), loadSanctions: async () => [], + loadWatchedUserIds: async () => new Set(), + loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }], }); const invalid = await query.run(context([PERMS.USERS_VIEW]), { @@ -184,6 +257,8 @@ describe("People users query", () => { active: true, }, ], + loadWatchedUserIds: async () => new Set(), + loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }], }); const result = await query.run(context([PERMS.USERS_VIEW]), { @@ -208,6 +283,35 @@ describe("People users query", () => { }); describe("People community and staff queries", () => { + it("fails closed when a community adapter returns a corrupt entity id", async () => { + const query = createPeopleCommunityQuery({ + loadOnline: async () => ({ rows: [], total: 0 }), + loadGuilds: async () => ({ rows: [], total: 0 }), + loadGuild: async () => + ({ + id: 0, + name: "Corrupt", + description: "", + ownerId: -1, + ownerUsername: null, + memberCount: 0, + createdAt: null, + href: "/ase/people/community/guilds/0", + roomId: 0, + threadCount: 0, + members: [], + }) as never, + }); + const result = await query.run(context([PERMS.USERS_VIEW]), { + routeId: "people.community.guild-detail", + id: 1, + }); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + 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 () => ({ @@ -317,6 +421,161 @@ describe("People community and staff queries", () => { }); describe("People support query", () => { + it("hydrates desk/detail support context and the help-ticket active ban", async () => { + const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 }; + const staff = [ + { + id: 8, + username: "Helper", + rank: 5, + href: "/ase/people/users/8" as const, + }, + ]; + const activeBan = { + id: 31, + userId: 7, + username: "Seven", + staffId: 8, + staffUsername: "Helper", + type: "account", + reason: "permanent", + createdAt: "2026-08-01T00:00:00.000Z", + expiresAt: 0 as const, + active: true, + }; + const query = createPeopleSupportQuery({ + loadQueue: async () => queue, + loadTickets: async () => ({ rows: [], total: 0 }), + loadTicket: async () => ({ + id: 11, + subject: "CMS ticket", + status: "open", + priority: "normal", + creatorId: 7, + creatorUsername: "Seven", + updatedAt: "2026-08-20T00:00:00.000Z", + href: "/ase/people/support/tickets/11", + category: "general", + assigneeId: null, + messages: [], + }), + loadTemplates: async () => ({ rows: [], total: 0 }), + loadHelpTickets: async () => ({ rows: [], total: 0 }), + loadHelpTicket: async () => ({ + id: 12, + title: "Help ticket", + open: true, + userId: 7, + username: "Seven", + updatedAt: "2026-08-20T00:00:00.000Z", + href: "/ase/people/support/help-tickets/12", + categoryId: 2, + categoryName: "Help", + content: "Body", + replies: [], + }), + loadSupportStaff: async () => staff, + loadActiveBan: async () => activeBan, + } as never); + + const desk = await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.ticket-desk", + list: {}, + }); + const detail = await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.ticket-detail", + id: 11, + }); + const help = await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.help-ticket-detail", + id: 12, + }); + expect(desk).toMatchObject({ + ok: true, + data: { kind: "ticket-desk", queue, staff }, + }); + expect(detail).toMatchObject({ + ok: true, + data: { ticket: { queue, staff } }, + }); + expect(help).toMatchObject({ + ok: true, + data: { ticket: { queue, staff, activeBan } }, + }); + }); + + it("fails closed when support returns a corrupt detail shape", async () => { + const query = createPeopleSupportQuery({ + loadQueue: async () => ({ + tickets: 0, + helpTickets: 0, + cfh: 0, + activeBans: 0, + }), + loadTickets: async () => ({ rows: [], total: 0 }), + loadTicket: async () => + ({ id: 0, href: "/ase/people/support/tickets/0" }) as never, + loadTemplates: async () => ({ rows: [], total: 0 }), + loadHelpTickets: async () => ({ rows: [], total: 0 }), + loadHelpTicket: async () => null, + loadSupportStaff: async () => [], + loadActiveBan: async () => null, + }); + const result = await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.ticket-detail", + id: 1, + }); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + + it.each([ + ["status", [7, 3]], + ["updatedAt", [8, 4]], + ] as const)( + "preserves the DB-paged ticket window for %s ordering", + async (sort, ids) => { + const rows = ids.map((id, index) => ({ + source: "cms" as const, + id, + subject: `Ticket ${id}`, + status: index === 0 ? "open" : "pending", + priority: "normal", + creatorId: 1, + creatorUsername: "Reporter", + updatedAt: `2026-08-${20 - index}T00:00:00.000Z`, + href: `/ase/people/support/tickets/${id}` as const, + })); + const query = createPeopleSupportQuery({ + loadQueue: async () => ({ + tickets: 0, + helpTickets: 0, + cfh: 0, + activeBans: 0, + }), + loadTickets: async () => ({ rows, total: 40 }), + loadTicket: async () => null, + loadTemplates: async () => ({ rows: [], total: 0 }), + loadHelpTickets: async () => ({ rows: [], total: 0 }), + loadHelpTicket: async () => null, + loadSupportStaff: async () => [], + loadActiveBan: async () => null, + }); + + const result = await query.run(context([PERMS.TICKETS_VIEW]), { + routeId: "people.support.ticket-desk", + list: { sort, order: "desc", offset: 10, pageSize: 2 }, + }); + + expect(result).toMatchObject({ + ok: true, + data: { page: { items: ids.map((id) => ({ id })), offset: 10 } }, + }); + }, + ); + it("returns the four-source queue snapshot and fails closed when a count fails", async () => { const loadQueue = vi .fn() @@ -334,6 +593,8 @@ describe("People support query", () => { loadTemplates: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }), loadHelpTicket: async () => null, + loadSupportStaff: async () => [], + loadActiveBan: async () => null, }); expect( @@ -370,6 +631,8 @@ describe("People support query", () => { loadTemplates: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }), loadHelpTicket: async () => null, + loadSupportStaff: async () => [], + loadActiveBan: async () => null, }); for (const routeId of [ @@ -402,6 +665,59 @@ describe("People support query", () => { }); describe("People moderation query", () => { + it("declares exactly the same eleven-permission union as the moderation overview route", () => { + const query = createPeopleModerationQuery({ + loadOverview: async () => ({ + tickets: 0, + helpTickets: 0, + cfh: 0, + activeBans: 0, + staffOnline: 0, + recentActions: 0, + }), + loadCfh: async () => ({ rows: [], total: 0 }), + loadCfhDetail: async () => null, + loadBans: async () => ({ rows: [], total: 0 }), + loadIpRules: async () => ({ blacklist: [], whitelist: [] }), + loadVpnSettings: async () => [], + loadWordFilter: async () => ({ rows: [], total: 0 }), + }); + const route = PEOPLE_ROUTES.find( + (candidate) => candidate.id === "people.moderation.overview", + ); + expect(route).toBeDefined(); + expect(query.capability).toEqual(route?.capability); + expect(query.capability.slugs).toHaveLength(11); + }); + + it("fails closed when moderation returns a corrupt detail shape", async () => { + const query = createPeopleModerationQuery({ + loadOverview: async () => ({ + tickets: 0, + helpTickets: 0, + cfh: 0, + activeBans: 0, + staffOnline: 0, + recentActions: 0, + }), + loadCfh: async () => ({ rows: [], total: 0 }), + loadCfhDetail: async () => + ({ id: -1, href: "/ase/people/moderation/cfh/-1" }) as never, + loadBans: async () => ({ rows: [], total: 0 }), + loadIpRules: async () => ({ blacklist: [], whitelist: [] }), + loadVpnSettings: async () => [], + loadWordFilter: async () => ({ rows: [], total: 0 }), + }); + const result = await query.run(context([PERMS.MOD_CFH_VIEW]), { + routeId: "people.moderation.cfh-detail", + id: 1, + }); + expect(result).toMatchObject({ + ok: false, + error: { code: "DEPENDENCY_UNAVAILABLE" }, + }); + }); + it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => { const query = createPeopleModerationQuery({ loadOverview: async () => ({ diff --git a/src/features/housekeeping/domains/people/queries/staff.ts b/src/features/housekeeping/domains/people/queries/staff.ts index 5547c451..f579300e 100644 --- a/src/features/housekeeping/domains/people/queries/staff.ts +++ b/src/features/housekeeping/domains/people/queries/staff.ts @@ -75,11 +75,13 @@ export function createPeopleStaffQuery( const authorization = authorizeHousekeeping(context, requirement); if (!authorization.ok) return authorization; const correlationId = authorization.correlationId; - const list = normalizePeopleListInput( - input.list, - ["id", "name", "username", "createdAt"], - "id", - ); + const allowedSorts = + input.routeId === "people.staff.applications" + ? ["id", "username", "createdAt"] + : input.routeId === "people.staff.teams" + ? ["id", "name"] + : ["id", "username"]; + const list = normalizePeopleListInput(input.list, allowedSorts, "id"); try { if (input.routeId === "people.staff.applications") { @@ -87,13 +89,7 @@ export function createPeopleStaffQuery( 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, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -104,9 +100,7 @@ export function createPeopleStaffQuery( return ok( { kind: "teams" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "name" ? row.name : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -116,9 +110,7 @@ export function createPeopleStaffQuery( return ok( { kind: "moderation-team" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "username" ? row.username : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -134,11 +126,13 @@ export function createPeopleStaffQuery( } function resultRows(result: unknown): T[] { - if (!Array.isArray(result)) return []; - return Array.isArray(result[0]) ? (result[0] as T[]) : []; + if (!Array.isArray(result) || !Array.isArray(result[0])) { + throw new Error("invalid People driver result"); + } + return result[0] as T[]; } -export const peopleStaffAdapters: PeopleStaffAdapters = { +const peopleStaffAdapters: PeopleStaffAdapters = { async loadApplications(input) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), @@ -158,7 +152,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = { 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} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total @@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = { ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${ input.order === "desc" ? sql`DESC` : sql`ASC` }, id ASC - LIMIT ${input.offset + input.pageSize} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`), ]); @@ -248,7 +242,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = { 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} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM users u diff --git a/src/features/housekeeping/domains/people/queries/support.ts b/src/features/housekeeping/domains/people/queries/support.ts index 195b0bd8..0d8d55d9 100644 --- a/src/features/housekeeping/domains/people/queries/support.ts +++ b/src/features/housekeeping/domains/people/queries/support.ts @@ -9,13 +9,16 @@ import { ok, } from "../../../foundation/contracts"; import { + assertPeopleSerializable, createPeoplePage, type ListInput, normalizePeopleListInput, type Page, + type PeopleBanSummary, type PeopleHelpTicketDetail, type PeopleHelpTicketSummary, type PeopleQueueSnapshot, + type PeopleSupportStaff, type PeopleTicketDetail, type PeopleTicketSummary, type PeopleTicketTemplate, @@ -29,19 +32,27 @@ interface SupportRows { readonly total: number; } +type PeopleTicketRecord = Omit; +type PeopleHelpTicketRecord = Omit< + PeopleHelpTicketDetail, + "queue" | "staff" | "activeBan" +>; + export interface PeopleSupportAdapters { loadQueue(): Promise; loadTickets( input: ReturnType, ): Promise>; - loadTicket(id: number): Promise; + loadTicket(id: number): Promise; loadTemplates( input: ReturnType, ): Promise>; loadHelpTickets( input: ReturnType, ): Promise>; - loadHelpTicket(id: number): Promise; + loadHelpTicket(id: number): Promise; + loadSupportStaff(): Promise; + loadActiveBan(userId: number): Promise; } export type PeopleSupportQueryInput = @@ -65,6 +76,12 @@ export type PeopleSupportQueryInput = export type PeopleSupportQueryData = | { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot } | { readonly kind: "tickets"; readonly page: Page } + | { + readonly kind: "ticket-desk"; + readonly page: Page; + readonly queue: PeopleQueueSnapshot; + readonly staff: readonly PeopleSupportStaff[]; + } | { readonly kind: "ticket-templates"; readonly page: Page; @@ -119,10 +136,9 @@ export function createPeopleSupportQuery( try { if (input.routeId === "people.support.queue") { - return ok( - { kind: "queue" as const, queue: await adapters.loadQueue() }, - correlationId, - ); + const queue = await adapters.loadQueue(); + assertPeopleSerializable(queue); + return ok({ kind: "queue" as const, queue }, correlationId); } if ( @@ -142,40 +158,52 @@ export function createPeopleSupportQuery( correlationId, ); } + const helpTicket = + input.routeId === "people.support.help-ticket-detail" + ? (ticket as PeopleHelpTicketRecord) + : null; + const [queue, staff, activeBan] = await Promise.all([ + adapters.loadQueue(), + adapters.loadSupportStaff(), + helpTicket?.userId !== null && helpTicket?.userId !== undefined + ? adapters.loadActiveBan(helpTicket.userId) + : Promise.resolve(null), + ]); + const hydrated = + input.routeId === "people.support.ticket-detail" + ? { ...ticket, queue, staff } + : { ...ticket, queue, staff, activeBan }; + assertPeopleSerializable(hydrated); return input.routeId === "people.support.ticket-detail" ? ok( { kind: "ticket" as const, - ticket: ticket as PeopleTicketDetail, + ticket: hydrated as PeopleTicketDetail, }, correlationId, ) : ok( { kind: "help-ticket" as const, - ticket: ticket as PeopleHelpTicketDetail, + ticket: hydrated as PeopleHelpTicketDetail, }, correlationId, ); } - const list = normalizePeopleListInput( - input.list, - ["id", "subject", "title", "status", "updatedAt", "sortOrder"], - "id", - ); + const allowedSorts = + input.routeId === "people.support.ticket-templates" + ? ["id", "title", "sortOrder"] + : input.routeId === "people.support.help-tickets" + ? ["id", "title", "updatedAt"] + : ["id", "subject", "status", "updatedAt"]; + const list = normalizePeopleListInput(input.list, allowedSorts, "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, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -186,9 +214,25 @@ export function createPeopleSupportQuery( return ok( { kind: "help-tickets" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "title" ? row.title : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), + }, + correlationId, + ); + } + + if (input.routeId === "people.support.ticket-desk") { + const [result, queue, staff] = await Promise.all([ + adapters.loadTickets(list), + adapters.loadQueue(), + adapters.loadSupportStaff(), + ]); + assertPeopleSerializable({ queue, staff }); + return ok( + { + kind: "ticket-desk" as const, + page: createPeoplePage(result.rows, result.total, list), + queue, + staff, }, correlationId, ); @@ -198,9 +242,7 @@ export function createPeopleSupportQuery( return ok( { kind: "tickets" as const, - page: createPeoplePage(result.rows, result.total, list, (row) => - list.sort === "subject" ? row.subject : row.id, - ), + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -212,84 +254,74 @@ export function createPeopleSupportQuery( } function resultRows(result: unknown): T[] { - if (!Array.isArray(result)) return []; - return Array.isArray(result[0]) ? (result[0] as T[]) : []; + if (!Array.isArray(result) || !Array.isArray(result[0])) { + throw new Error("invalid People driver result"); + } + return result[0] as T[]; } -export const peopleSupportAdapters: PeopleSupportAdapters = { +const peopleSupportAdapters: PeopleSupportAdapters = { async loadQueue() { - const [{ sql }, { db }] = await Promise.all([ - import("drizzle-orm"), - import("@/lib/db"), - ]); - const result = await db.execute(sql` + const [{ sql }, { db }, { fetchTicketQueueOpenCounts }] = await Promise.all( + [ + import("drizzle-orm"), + import("@/lib/db"), + import("@/lib/admin/ticket-queue-counts"), + ], + ); + const [ticketCounts, result] = await Promise.all([ + fetchTicketQueueOpenCounts({ strict: true }), + 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), + tickets: ticketCounts.cmsOpen, + helpTickets: ticketCounts.helpOpen, 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)), - })); + const { fetchUnifiedTicketInbox } = await import( + "@/lib/admin/ticket-inbox" + ); + const result = await fetchUnifiedTicketInbox({ + strict: true, + page: Math.floor(input.offset / input.pageSize) + 1, + perPage: input.pageSize, + offset: input.offset, + search: input.search, + openOnly: false, + base: "admin", + sort: input.sort as "id" | "subject" | "status" | "updatedAt", + order: input.order, + }); + const rows = result.rows.map((row) => { + const id = Number(row.id); + return { + source: row.kind, + id, + subject: row.title, + status: row.status, + priority: "normal", + creatorId: row.userId, + creatorUsername: row.user === "—" ? null : row.user, + updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt), + href: + row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id), + }; + }); return { rows, - total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), + total: result.total, }; }, async loadTicket(id) { @@ -313,6 +345,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = { LEFT JOIN users u ON u.id = m.user_id WHERE m.ticket_id = ${id} ORDER BY m.created_at ASC, m.id ASC + LIMIT 500 `), ]); const row = resultRows<{ @@ -328,6 +361,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = { }>(ticketResult)[0]; if (!row) return null; return { + source: "cms", id: Number(row.id), subject: row.subject, category: row.category, @@ -371,7 +405,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = { 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} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM website_ticket_templates ${where} @@ -408,7 +442,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = { 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} + LIMIT ${input.pageSize} OFFSET ${input.offset} `), db.execute(sql` SELECT COUNT(*) AS total FROM website_help_center_tickets t @@ -458,6 +492,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = { LEFT JOIN users u ON u.id = r.user_id WHERE r.ticket_id = ${id} ORDER BY r.created_at ASC, r.id ASC + LIMIT 500 `), ]); const row = resultRows<{ @@ -498,6 +533,75 @@ export const peopleSupportAdapters: PeopleSupportAdapters = { })), }; }, + async loadSupportStaff() { + 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 id, username, rank FROM users + WHERE rank >= ${minStaffRank} + ORDER BY username ASC, id ASC + LIMIT 200 + `); + return resultRows<{ id: number; username: string; rank: number }>( + result, + ).map((row) => ({ + id: Number(row.id), + username: row.username, + rank: Number(row.rank), + href: `/ase/people/users/${Number(row.id)}` as const, + })); + }, + async loadActiveBan(userId) { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const result = await db.execute(sql` + SELECT b.id, b.user_id AS userId, target.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 target ON target.id = b.user_id + LEFT JOIN users staff ON staff.id = b.user_staff_id + WHERE b.user_id = ${userId} + AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) + ORDER BY (b.ban_expire = 0) DESC, b.ban_expire DESC, + b.timestamp DESC, b.id DESC + LIMIT 1 + `); + const row = resultRows<{ + id: number; + userId: number; + username: string | null; + staffId: number; + staffUsername: string | null; + type: string; + reason: string; + createdAt: Date | string | number | null; + expiresAt: number; + }>(result)[0]; + if (!row) return null; + return { + 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: + Number(row.expiresAt) === 0 + ? 0 + : (toPeopleIsoDate(row.expiresAt) as string), + active: true, + }; + }, }; export const peopleSupportQuery = createPeopleSupportQuery( diff --git a/src/features/housekeeping/domains/people/queries/users.ts b/src/features/housekeeping/domains/people/queries/users.ts index 1a62f97e..83d20cd3 100644 --- a/src/features/housekeeping/domains/people/queries/users.ts +++ b/src/features/housekeeping/domains/people/queries/users.ts @@ -9,12 +9,14 @@ import { ok, } from "../../../foundation/contracts"; import { + assertPeopleSerializable, createPeoplePage, type ListInput, normalizePeopleListInput, normalizePeopleUser, type Page, type PeopleMultiAccountCluster, + type PeoplePermissionRank, type PeopleSanctionSummary, type PeopleUserDetail, type PeopleUserRecord, @@ -31,7 +33,7 @@ interface PeopleUserColumns { readonly ipCurrent: unknown; } -export function buildPeopleUserSelection( +function buildPeopleUserSelection( user: T, projection: { readonly includeMail: boolean; readonly includeIp: boolean }, ) { @@ -71,6 +73,8 @@ export interface PeopleUsersAdapters { readonly total: number; }>; loadSanctions(userId: number): Promise; + loadWatchedUserIds(staffId: number): Promise>; + loadPermissionRanks(): Promise; } export type PeopleUsersQueryInput = @@ -141,16 +145,10 @@ export function createPeopleUsersQuery( 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), + page: createPeoplePage(rows, result.total, list), }, correlationId, ); @@ -160,26 +158,17 @@ export function createPeopleUsersQuery( } if (input.routeId === "people.users.multi-accounts") { - const list = normalizePeopleListInput(input.list, ["id"], "id"); + const list = normalizePeopleListInput( + input.list, + ["key", "accountCount"], + "key", + ); 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, + page: createPeoplePage(result.rows, result.total, list), }, correlationId, ); @@ -206,18 +195,29 @@ export function createPeopleUsersQuery( correlationId, ); } - const sanctions = await adapters.loadSanctions(input.id); + const [sanctions, watchedUserIds, permissionRanks] = await Promise.all([ + adapters.loadSanctions(input.id), + adapters.loadWatchedUserIds(context.actor.id), + adapters.loadPermissionRanks(), + ]); + const rank = Number(row.rank); + const rankName = permissionRanks.find((item) => item.id === rank)?.name; + if (!rankName) throw new Error("People permission rank unavailable"); + const user = { + ...normalizePeopleUser(row, projection), + motto: String(row.motto ?? ""), + look: String(row.look ?? ""), + accountCreated: toPeopleIsoDate(row.accountCreated), + lastLogin: toPeopleIsoDate(row.lastLogin), + sanctions, + watched: watchedUserIds.has(input.id), + permission: { rankName, ranks: permissionRanks }, + }; + assertPeopleSerializable(user); 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, - }, + user, }, correlationId, ); @@ -229,14 +229,15 @@ export function createPeopleUsersQuery( } function resultRows(result: unknown): T[] { - if (!Array.isArray(result)) return []; + if (!Array.isArray(result)) throw new Error("invalid People driver result"); const rows = result[0]; - return Array.isArray(rows) ? (rows as T[]) : []; + if (!Array.isArray(rows)) throw new Error("invalid People driver rows"); + return rows as T[]; } -export const peopleUsersAdapters: PeopleUsersAdapters = { +const peopleUsersAdapters: PeopleUsersAdapters = { async loadUsers(input, projection) { - const [{ and, asc, count, desc, eq, like, or }, { Ban, db, User }] = + const [{ and, asc, count, desc, eq, gt, like, or }, { Ban, db, User }] = await Promise.all([import("drizzle-orm"), import("@/lib/db")]); const searchConditions = input.search ? [ @@ -279,8 +280,9 @@ export const peopleUsersAdapters: PeopleUsersAdapters = { .select(selection) .from(User) .where(where) - .orderBy(direction(sortColumn), asc(User.id)) - .limit(input.offset + input.pageSize), + .orderBy(direction(sortColumn), direction(User.id)) + .limit(input.pageSize) + .offset(input.offset), db.select({ total: count() }).from(User).where(where), ]); const ids = (rows as unknown as PeopleUserRecord[]) @@ -292,11 +294,24 @@ export const peopleUsersAdapters: PeopleUsersAdapters = { : await db .select({ userId: Ban.userId, banExpire: Ban.banExpire }) .from(Ban) - .where(or(...ids.map((id) => eq(Ban.userId, id)))); + .where( + and( + or(...ids.map((id) => eq(Ban.userId, id))), + or( + eq(Ban.banExpire, 0), + gt(Ban.banExpire, Math.floor(Date.now() / 1000)), + ), + ), + ); const latestBan = new Map(); for (const ban of activeBans) { - const current = latestBan.get(ban.userId) ?? 0; - if (ban.banExpire === 0 || ban.banExpire > current) { + const hasCurrent = latestBan.has(ban.userId); + const current = latestBan.get(ban.userId); + if ( + !hasCurrent || + (current !== 0 && + (ban.banExpire === 0 || ban.banExpire > (current ?? 0))) + ) { latestBan.set(ban.userId, ban.banExpire); } } @@ -309,7 +324,7 @@ export const peopleUsersAdapters: PeopleUsersAdapters = { }; }, async loadUser(id, projection) { - const [{ desc, eq }, { Ban, db, User }] = await Promise.all([ + const [{ and, desc, eq, gt, or }, { Ban, db, User }] = await Promise.all([ import("drizzle-orm"), import("@/lib/db"), ]); @@ -332,15 +347,31 @@ export const peopleUsersAdapters: PeopleUsersAdapters = { .where(eq(User.id, id)) .limit(1); if (!row) return null; - const [ban] = await db + const now = Math.floor(Date.now() / 1000); + const bans = await db .select({ banExpire: Ban.banExpire }) .from(Ban) - .where(eq(Ban.userId, id)) + .where( + and( + eq(Ban.userId, id), + or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)), + ), + ) .orderBy(desc(Ban.timestamp), desc(Ban.id)) - .limit(1); + .limit(100); + let bannedUntil: number | null = null; + for (const ban of bans) { + if (ban.banExpire === 0) { + bannedUntil = 0; + break; + } + if (bannedUntil === null || ban.banExpire > bannedUntil) { + bannedUntil = ban.banExpire; + } + } return { ...(row as unknown as PeopleUserDetailRecord), - bannedUntil: ban?.banExpire ?? null, + bannedUntil, }; }, async loadMultiAccounts(input) { @@ -348,49 +379,71 @@ export const peopleUsersAdapters: PeopleUsersAdapters = { 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 search = input.search + ? sql`AND ip_current LIKE ${`%${input.search}%`}` + : sql``; + const sortColumn = + input.sort === "accountCount" ? sql`accountCount` : sql`ipCurrent`; + const direction = input.order === "desc" ? sql`DESC` : sql`ASC`; + const result = await db.execute(sql` + WITH grouped AS ( + SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount, + COUNT(*) OVER () AS total + FROM users + WHERE ip_current <> '' ${search} + GROUP BY ip_current + HAVING COUNT(*) >= 2 + ), paged AS ( + SELECT ipCurrent, accountCount, total + FROM grouped + ORDER BY ${sortColumn} ${direction}, ipCurrent ASC + LIMIT ${input.pageSize} OFFSET ${input.offset} + ), ranked_accounts AS ( + SELECT u.id, u.username, u.rank, u.online, u.ip_current AS ipCurrent, + ROW_NUMBER() OVER (PARTITION BY u.ip_current ORDER BY u.id ASC) AS accountPosition + FROM users u + INNER JOIN paged p ON p.ipCurrent = u.ip_current + ) + SELECT p.ipCurrent, p.accountCount, p.total, + a.id, a.username, a.rank, a.online + FROM paged p + INNER JOIN ranked_accounts a ON a.ipCurrent = p.ipCurrent + WHERE a.accountPosition <= 100 + ORDER BY ${sortColumn} ${direction}, p.ipCurrent ASC, a.id 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 }; + const records = resultRows<{ + ipCurrent: string; + accountCount: number | bigint; + total: number | bigint; + id: number; + username: string; + rank: number; + online: string; + }>(result); + const clusters = new Map(); + for (const record of records) { + const existing = clusters.get(record.ipCurrent); + const account = { + id: Number(record.id), + username: record.username, + rank: Number(record.rank), + online: record.online === "1", + href: `/ase/people/users/${Number(record.id)}` as const, + }; + if (existing) { + (existing.accounts as (typeof account)[]).push(account); + } else { + clusters.set(record.ipCurrent, { + key: record.ipCurrent, + accountCount: Number(record.accountCount), + accounts: [account], + }); + } + } + return { + rows: [...clusters.values()], + total: Number(records[0]?.total ?? 0), + }; }, async loadSanctions(userId) { const [{ desc, eq }, { Ban, db }] = await Promise.all([ @@ -415,10 +468,31 @@ export const peopleUsersAdapters: PeopleUsersAdapters = { kind: row.type, reason: row.reason, createdAt: toPeopleIsoDate(row.createdAt), - expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt), + expiresAt: row.expiresAt === 0 ? 0 : toPeopleIsoDate(row.expiresAt), active: row.expiresAt === 0 || row.expiresAt > now, })); }, + async loadWatchedUserIds(staffId) { + const { getWatchedUserIds } = await import("@/lib/services/watch"); + return getWatchedUserIds(staffId); + }, + async loadPermissionRanks() { + const [{ sql }, { db }] = await Promise.all([ + import("drizzle-orm"), + import("@/lib/db"), + ]); + const result = await db.execute(sql` + SELECT id, rank_name AS name FROM permissions ORDER BY id ASC + `); + const rows = resultRows<{ id: number | bigint; name: string }>(result); + return rows.map((row) => { + const id = Number(row.id); + if (!Number.isSafeInteger(id) || id <= 0 || !row.name.trim()) { + throw new Error("invalid People permission rank"); + } + return { id, name: row.name }; + }); + }, }; export const peopleUsersQuery = createPeopleUsersQuery(peopleUsersAdapters); diff --git a/src/lib/admin/ticket-inbox.ts b/src/lib/admin/ticket-inbox.ts index 2cac45b7..df7894df 100644 --- a/src/lib/admin/ticket-inbox.ts +++ b/src/lib/admin/ticket-inbox.ts @@ -10,6 +10,7 @@ import { ne, or, type SQL, + sql, } from "drizzle-orm"; import { alias } from "drizzle-orm/mysql-core"; import { calcPagination } from "@/lib/admin-helpers"; @@ -36,11 +37,16 @@ export interface TicketInboxRow { export interface FetchTicketInboxOptions { page: number; perPage: number; + offset?: number; search?: string; type?: TicketInboxTypeFilter; /** Default true: only non-closed CMS + open help-center. */ openOnly?: boolean; base?: "admin" | "mod"; + sort?: "id" | "subject" | "status" | "updatedAt"; + order?: "asc" | "desc"; + /** Fail-closed, DB-paged boundary for security-sensitive read models. */ + strict?: boolean; } function toSortMs(value: Date | string | null | undefined): number { @@ -267,6 +273,214 @@ async function countHelp(search: string, openOnly: boolean): Promise { return Number(rows[0]?.total ?? 0); } +function strictRows(result: unknown): T[] { + if (!Array.isArray(result) || !Array.isArray(result[0])) { + throw new Error("invalid ticket inbox driver result"); + } + return result[0] as T[]; +} + +async function fetchStrictUnifiedTicketInbox( + options: FetchTicketInboxOptions, +): Promise<{ + rows: TicketInboxRow[]; + total: number; + page: number; + perPage: number; + lastPage: number; + cmsTotal: number; + helpTotal: number; +}> { + const type = options.type ?? "all"; + const openOnly = options.openOnly !== false; + const search = options.search?.trim() ?? ""; + const pattern = `%${search}%`; + const numericCandidate = /^\d+$/.test(search) ? Number(search) : null; + const numericSearch = + numericCandidate !== null && + Number.isSafeInteger(numericCandidate) && + numericCandidate > 0 + ? numericCandidate + : null; + const base = options.base ?? "admin"; + if ( + !Number.isFinite(options.perPage) || + !Number.isFinite(options.page) || + (options.offset !== undefined && !Number.isFinite(options.offset)) + ) { + throw new Error("invalid strict ticket inbox pagination"); + } + const perPage = Math.min(Math.max(Math.trunc(options.perPage), 1), 100); + const requestedPage = Math.max(Math.trunc(options.page), 1); + const offset = Math.min( + Math.max(Math.trunc(options.offset ?? (requestedPage - 1) * perPage), 0), + 10_000, + ); + const page = Math.floor(offset / perPage) + 1; + + const cmsConditions: SQL[] = []; + if (openOnly) cmsConditions.push(sql`t.status <> 'closed'`); + if (search) { + cmsConditions.push(sql`( + t.subject LIKE ${pattern} + OR t.category LIKE ${pattern} + OR creator.username LIKE ${pattern} + ${numericSearch !== null ? sql`OR t.id = ${numericSearch}` : sql``} + )`); + } + const helpConditions: SQL[] = []; + if (openOnly) helpConditions.push(sql`h.open = 1`); + if (search) { + helpConditions.push(sql`( + h.title LIKE ${pattern} + OR h.content LIKE ${pattern} + OR help_user.username LIKE ${pattern} + ${numericSearch !== null ? sql`OR h.id = ${numericSearch}` : sql``} + )`); + } + const cmsWhere = + cmsConditions.length > 0 + ? sql`WHERE ${sql.join(cmsConditions, sql` AND `)}` + : sql``; + const helpWhere = + helpConditions.length > 0 + ? sql`WHERE ${sql.join(helpConditions, sql` AND `)}` + : sql``; + const cmsSelect = sql` + SELECT 'cms' AS kind, t.id AS numericId, t.subject AS title, + COALESCE(creator.username, '—') AS user, t.creator_id AS userId, + t.status AS status, (t.status <> 'closed') AS open, + COALESCE(UNIX_TIMESTAMP(t.updated_at), UNIX_TIMESTAMP(t.created_at), 0) * 1000 AS sortAt, + COALESCE(t.updated_at, t.created_at) AS dateValue + FROM website_tickets t + LEFT JOIN users creator ON creator.id = t.creator_id + ${cmsWhere} + `; + const helpSelect = sql` + SELECT 'help' AS kind, h.id AS numericId, h.title AS title, + COALESCE(help_user.username, '—') AS user, h.user_id AS userId, + IF(h.open = 1, 'open', 'closed') AS status, h.open AS open, + COALESCE(UNIX_TIMESTAMP(h.updated_at), UNIX_TIMESTAMP(h.created_at), 0) * 1000 AS sortAt, + COALESCE(h.updated_at, h.created_at) AS dateValue + FROM website_help_center_tickets h + LEFT JOIN users help_user ON help_user.id = h.user_id + ${helpWhere} + `; + const unified = + type === "cms" + ? cmsSelect + : type === "help" + ? helpSelect + : sql`${cmsSelect} UNION ALL ${helpSelect}`; + const sortColumn = + options.sort === "id" + ? sql`ticket_rows.numericId` + : options.sort === "subject" + ? sql`ticket_rows.title` + : options.sort === "status" + ? sql`ticket_rows.status` + : sql`ticket_rows.sortAt`; + const direction = options.order === "asc" ? sql`ASC` : sql`DESC`; + const [rowsResult, countResult] = await Promise.all([ + db.execute(sql` + SELECT * FROM (${unified}) AS ticket_rows + ORDER BY ${sortColumn} ${direction}, ticket_rows.kind ASC, + ticket_rows.numericId ${direction} + LIMIT ${perPage} OFFSET ${offset} + `), + db.execute(sql` + SELECT COUNT(*) AS total, + SUM(ticket_rows.kind = 'cms') AS cmsTotal, + SUM(ticket_rows.kind = 'help') AS helpTotal + FROM (${unified}) AS ticket_rows + `), + ]); + const counts = strictRows<{ + total: number | bigint; + cmsTotal: number | bigint | null; + helpTotal: number | bigint | null; + }>(countResult)[0]; + if (!counts) throw new Error("ticket inbox counts unavailable"); + const total = Number(counts.total); + const cmsTotal = Number(counts.cmsTotal ?? 0); + const helpTotal = Number(counts.helpTotal ?? 0); + if ( + !Number.isSafeInteger(total) || + total < 0 || + !Number.isSafeInteger(cmsTotal) || + cmsTotal < 0 || + !Number.isSafeInteger(helpTotal) || + helpTotal < 0 + ) { + throw new Error("invalid ticket inbox counts"); + } + const rows = strictRows<{ + kind: TicketInboxKind; + numericId: number | bigint; + title: string; + user: string; + userId: number | null; + status: string; + open: number | boolean; + sortAt: number | bigint; + dateValue: Date | string | null; + }>(rowsResult).map((row) => { + const id = Number(row.numericId); + const sortAt = Number(row.sortAt); + if ( + (row.kind !== "cms" && row.kind !== "help") || + !Number.isSafeInteger(id) || + id <= 0 || + !Number.isSafeInteger(sortAt) || + sortAt < 0 || + typeof row.title !== "string" || + typeof row.user !== "string" || + typeof row.status !== "string" + ) { + throw new Error("invalid ticket inbox row"); + } + const prefix = + row.kind === "cms" + ? base === "mod" + ? "/mod/tickets" + : "/admin/tickets" + : base === "mod" + ? "/mod/help-tickets" + : "/admin/help-tickets"; + const dateValue = + row.dateValue === null + ? null + : row.dateValue instanceof Date + ? row.dateValue + : new Date(row.dateValue); + if (dateValue !== null && !Number.isFinite(dateValue.getTime())) { + throw new Error("invalid ticket inbox date"); + } + return { + key: `${row.kind}-${id}`, + kind: row.kind, + id: String(id), + title: row.title, + user: row.user, + userId: row.userId === null ? null : Number(row.userId), + status: row.status, + open: Boolean(row.open), + sortAt, + date: formatDate(dateValue, "date"), + href: `${prefix}/${id}`, + }; + }); + return { + rows, + total, + page, + perPage, + lastPage: Math.max(1, Math.ceil(total / perPage)), + cmsTotal, + helpTotal, + }; +} + /** Merged read-model over CMS desk + help-center queues (no DB merge). */ export async function fetchUnifiedTicketInbox( options: FetchTicketInboxOptions, @@ -279,6 +493,7 @@ export async function fetchUnifiedTicketInbox( cmsTotal: number; helpTotal: number; }> { + if (options.strict) return fetchStrictUnifiedTicketInbox(options); const type = options.type ?? "all"; const openOnly = options.openOnly !== false; const search = options.search?.trim() ?? ""; diff --git a/src/lib/admin/ticket-queue-counts.ts b/src/lib/admin/ticket-queue-counts.ts index dc2a37cd..5e75c232 100644 --- a/src/lib/admin/ticket-queue-counts.ts +++ b/src/lib/admin/ticket-queue-counts.ts @@ -4,23 +4,33 @@ import { count, eq, ne } from "drizzle-orm"; import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db"; /** Open-queue sizes for dual ticket products (CMS desk vs help-center). */ -export async function fetchTicketQueueOpenCounts(): Promise<{ +export async function fetchTicketQueueOpenCounts(options?: { + readonly strict?: boolean; +}): Promise<{ cmsOpen: number; helpOpen: number; }> { + const cms = db + .select({ total: count() }) + .from(WebsiteTicket) + .where(ne(WebsiteTicket.status, "closed")); + const help = db + .select({ total: count() }) + .from(WebsiteHelpCenterTickets) + .where(eq(WebsiteHelpCenterTickets.open, true)); + const readCount = async ( + query: typeof cms | typeof help, + ): Promise => { + const rows = await query; + const value = Number(rows[0]?.total); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error("invalid ticket queue count"); + } + return value; + }; const [cmsOpen, helpOpen] = await Promise.all([ - db - .select({ total: count() }) - .from(WebsiteTicket) - .where(ne(WebsiteTicket.status, "closed")) - .then((rows) => rows[0]?.total ?? 0) - .catch(() => 0), - db - .select({ total: count() }) - .from(WebsiteHelpCenterTickets) - .where(eq(WebsiteHelpCenterTickets.open, true)) - .then((rows) => rows[0]?.total ?? 0) - .catch(() => 0), + options?.strict ? readCount(cms) : readCount(cms).catch(() => 0), + options?.strict ? readCount(help) : readCount(help).catch(() => 0), ]); return { cmsOpen, helpOpen }; }