fix(housekeeping): harden people read boundaries

This commit is contained in:
Simo committed 2026-08-29 02:13:53 +02:00
1 parent e3f8b51d31
commit d1382c839e
12 files changed
+1635 -446

No files matched your search

@@ -1,6 +1,6 @@
# Task 11 — People workflow read models # Task 11 — People workflow read models
Status: DONE Status: DONE — fix round 1
## Delivered scope ## 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. - 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. - 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`. - 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. - 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 1,000,000. Production list adapters fetch the full prefix required for in-memory stable sorting/pagination, avoiding double-offset truncation. - 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 ## 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 pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
Test Files 1 failed (1) Test Files 1 failed (1)
Tests 2 failed (2) 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: 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"]. 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 ```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts 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. 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 ## Verification
```text ```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 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) 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 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 8 passed (8) Test Files 9 passed (9)
Tests 65 passed (65) Tests 81 passed (81)
pnpm test:housekeeping pnpm test:housekeeping
Test Files 49 passed (49) Test Files 49 passed (49)
Tests 416 passed (416) Tests 430 passed (430)
pnpm typecheck pnpm typecheck
tsc --noEmit tsc --noEmit
Exit 0 Exit 0
pnpm exec biome check --formatter-enabled=false <12 exact Task 11 TypeScript files> pnpm exec biome check --formatter-enabled=false <11 exact changed Task 11 TypeScript files>
Checked 12 files. No fixes applied. Checked 11 files. No fixes applied.
git diff --check git diff --check
Exit 0 Exit 0
@@ -41,9 +41,12 @@ describe("People list normalization", () => {
"id", "id",
), ),
).toMatchObject({ pageSize: 20, offset: 0 }); ).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( const input = normalizePeopleListInput(
{ pageSize: 2, offset: 1, sort: "username", order: "asc" }, { pageSize: 2, offset: 1, sort: "username", order: "asc" },
["id", "username"], ["id", "username"],
@@ -51,20 +54,17 @@ describe("People list normalization", () => {
); );
const page = createPeoplePage( const page = createPeoplePage(
[ [
{ id: 4, username: "Bob" },
{ id: 3, username: "alice" }, { id: 3, username: "alice" },
{ id: 1, username: "Alice" }, { id: 1, username: "Alice" },
{ id: 2, username: "alice" },
], ],
4, 4,
input, input,
(row) => row.username,
); );
expect(page).toEqual({ expect(page).toEqual({
items: [ items: [
{ id: 2, username: "alice" },
{ id: 3, username: "alice" }, { id: 3, username: "alice" },
{ id: 1, username: "Alice" },
], ],
total: 4, total: 4,
pageSize: 2, pageSize: 2,
@@ -116,6 +116,11 @@ describe("People canonical models", () => {
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")), accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
lastLogin: toPeopleIsoDate(1_700_000_000), lastLogin: toPeopleIsoDate(1_700_000_000),
sanctions: [], sanctions: [],
watched: false,
permission: {
rankName: "Moderator",
ranks: [{ id: 5, name: "Moderator" }],
},
}; };
expect(JSON.parse(JSON.stringify(detail))).toEqual({ expect(JSON.parse(JSON.stringify(detail))).toEqual({
@@ -132,6 +137,11 @@ describe("People canonical models", () => {
accountCreated: "2026-08-29T10:20:30.000Z", accountCreated: "2026-08-29T10:20:30.000Z",
lastLogin: "2023-11-14T22:13:20.000Z", lastLogin: "2023-11-14T22:13:20.000Z",
sanctions: [], sanctions: [],
watched: false,
permission: {
rankName: "Moderator",
ranks: [{ id: 5, name: "Moderator" }],
},
}); });
}); });
@@ -150,4 +160,16 @@ describe("People canonical models", () => {
"/ase/people/moderation/cfh/8", "/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();
});
}); });
@@ -49,7 +49,7 @@ export interface PeopleSanctionSummary {
readonly kind: string; readonly kind: string;
readonly reason: string; readonly reason: string;
readonly createdAt: string | null; readonly createdAt: string | null;
readonly expiresAt: string | null; readonly expiresAt: string | 0 | null;
readonly active: boolean; readonly active: boolean;
} }
@@ -59,6 +59,16 @@ export interface PeopleUserDetail extends PeopleUserSummary {
readonly accountCreated: string | null; readonly accountCreated: string | null;
readonly lastLogin: string | null; readonly lastLogin: string | null;
readonly sanctions: readonly PeopleSanctionSummary[]; 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 { export interface PeopleMultiAccountCluster {
@@ -135,15 +145,25 @@ export interface PeopleQueueSnapshot {
readonly activeBans: number; readonly activeBans: number;
} }
export interface PeopleSupportStaff {
readonly id: number;
readonly username: string;
readonly rank: number;
readonly href: `/ase/people/users/${number}`;
}
export interface PeopleTicketSummary { export interface PeopleTicketSummary {
readonly source: "cms" | "help";
readonly id: number; readonly id: number;
readonly subject: string; readonly subject: string;
readonly status: string; readonly status: string;
readonly priority: string; readonly priority: string;
readonly creatorId: number; readonly creatorId: number | null;
readonly creatorUsername: string | null; readonly creatorUsername: string | null;
readonly updatedAt: 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 { export interface PeopleTicketDetail extends PeopleTicketSummary {
@@ -157,6 +177,8 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
readonly isStaff: boolean; readonly isStaff: boolean;
readonly createdAt: string | null; readonly createdAt: string | null;
}[]; }[];
readonly queue: PeopleQueueSnapshot;
readonly staff: readonly PeopleSupportStaff[];
} }
export interface PeopleHelpTicketSummary { export interface PeopleHelpTicketSummary {
@@ -180,6 +202,9 @@ export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
readonly content: string; readonly content: string;
readonly createdAt: string | null; readonly createdAt: string | null;
}[]; }[];
readonly queue: PeopleQueueSnapshot;
readonly staff: readonly PeopleSupportStaff[];
readonly activeBan: PeopleBanSummary | null;
} }
export interface PeopleTicketTemplate { export interface PeopleTicketTemplate {
@@ -217,7 +242,7 @@ export interface PeopleBanSummary {
readonly type: string; readonly type: string;
readonly reason: string; readonly reason: string;
readonly createdAt: string | null; readonly createdAt: string | null;
readonly expiresAt: string | null; readonly expiresAt: string | 0;
readonly active: boolean; readonly active: boolean;
} }
@@ -252,7 +277,7 @@ export interface PeopleQueries {
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100; const MAX_PAGE_SIZE = 100;
const MAX_OFFSET = 1_000_000; const MAX_OFFSET = 10_000;
const MAX_SEARCH_LENGTH = 128; const MAX_SEARCH_LENGTH = 128;
function boundedInteger( function boundedInteger(
@@ -297,34 +322,22 @@ export function normalizePeopleListInput(
}; };
} }
function compareValues(left: string | number, right: string | number): number { export function createPeoplePage<T>(
if (typeof left === "number" && typeof right === "number") {
return left - right;
}
return String(left).localeCompare(String(right), undefined, {
numeric: true,
sensitivity: "base",
});
}
export function createPeoplePage<T extends { readonly id: number }>(
rows: readonly T[], rows: readonly T[],
total: number, total: number,
input: NormalizedListInput, input: NormalizedListInput,
sortValue: (row: T) => string | number,
): Page<T> { ): Page<T> {
const items = [...rows] if (
.sort((left, right) => { !Number.isSafeInteger(total) ||
const primary = compareValues(sortValue(left), sortValue(right)); total < 0 ||
return ( rows.length > input.pageSize
(input.order === "desc" ? -primary : primary) || left.id - right.id ) {
); throw new Error("invalid People page");
}) }
.slice(input.offset, input.offset + input.pageSize); assertPeopleSerializable(rows);
return { return {
items, items: [...rows],
total: boundedInteger(total, rows.length, 0, Number.MAX_SAFE_INTEGER), total,
pageSize: input.pageSize, pageSize: input.pageSize,
offset: input.offset, offset: input.offset,
}; };
@@ -335,6 +348,22 @@ export function toPeopleNumber(value: unknown, fallback = 0): number {
return Number.isSafeInteger(parsed) ? parsed : fallback; 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 { export function toPeopleIsoDate(value: unknown): string | null {
if (value === null || value === undefined || value === "") return null; if (value === null || value === undefined || value === "") return null;
let date: Date; let date: Date;
@@ -348,40 +377,47 @@ export function toPeopleIsoDate(value: unknown): string | null {
) )
: new Date(String(value)); : 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 { 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(); const normalized = value.trim();
return normalized.length > 0 ? normalized : null; return normalized.length > 0 ? normalized : null;
} }
export function peopleUserHref(id: number): `/ase/people/users/${number}` { export function peopleUserHref(id: number): `/ase/people/users/${number}` {
positiveSafeInteger(id);
return `/ase/people/users/${id}`; return `/ase/people/users/${id}`;
} }
export function peopleGuildHref( export function peopleGuildHref(
id: number, id: number,
): `/ase/people/community/guilds/${number}` { ): `/ase/people/community/guilds/${number}` {
positiveSafeInteger(id);
return `/ase/people/community/guilds/${id}`; return `/ase/people/community/guilds/${id}`;
} }
export function peopleTicketHref( export function peopleTicketHref(
id: number, id: number,
): `/ase/people/support/tickets/${number}` { ): `/ase/people/support/tickets/${number}` {
positiveSafeInteger(id);
return `/ase/people/support/tickets/${id}`; return `/ase/people/support/tickets/${id}`;
} }
export function peopleHelpTicketHref( export function peopleHelpTicketHref(
id: number, id: number,
): `/ase/people/support/help-tickets/${number}` { ): `/ase/people/support/help-tickets/${number}` {
positiveSafeInteger(id);
return `/ase/people/support/help-tickets/${id}`; return `/ase/people/support/help-tickets/${id}`;
} }
export function peopleCfhHref( export function peopleCfhHref(
id: number, id: number,
): `/ase/people/moderation/cfh/${number}` { ): `/ase/people/moderation/cfh/${number}` {
positiveSafeInteger(id);
return `/ase/people/moderation/cfh/${id}`; return `/ase/people/moderation/cfh/${id}`;
} }
@@ -389,12 +425,22 @@ export function normalizePeopleUser(
row: PeopleUserRecord, row: PeopleUserRecord,
projection: { readonly includeMail: boolean; readonly includeIp: boolean }, projection: { readonly includeMail: boolean; readonly includeIp: boolean },
): PeopleUserSummary { ): PeopleUserSummary {
const id = toPeopleNumber(row.id); const id = positiveSafeInteger(row.id);
const bannedUntil = toPeopleNumber(row.bannedUntil, 0); 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 { return {
id, id,
username: String(row.username ?? ""), username: row.username,
rank: toPeopleNumber(row.rank), rank,
online: online:
row.online === true || row.online === true ||
row.online === 1 || row.online === 1 ||
@@ -402,7 +448,54 @@ export function normalizePeopleUser(
row.online === "true", row.online === "true",
mail: projection.includeMail ? nullableString(row.mail) : null, mail: projection.includeMail ? nullableString(row.mail) : null,
ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null, ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null,
bannedUntil: bannedUntil > 0 ? bannedUntil : null, bannedUntil,
href: peopleUserHref(id), 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);
}
@@ -9,6 +9,7 @@ import {
ok, ok,
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import { import {
assertPeopleSerializable,
createPeoplePage, createPeoplePage,
type ListInput, type ListInput,
normalizePeopleListInput, normalizePeopleListInput,
@@ -77,9 +78,15 @@ export function createPeopleCommunityQuery(
} }
try { try {
const guild = await adapters.loadGuild(input.id); const guild = await adapters.loadGuild(input.id);
return guild === null if (guild === null) {
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId) return fail(
: ok({ kind: "guild" as const, guild }, correlationId); "NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
assertPeopleSerializable(guild);
return ok({ kind: "guild" as const, guild }, correlationId);
} catch { } catch {
return unavailable(correlationId); return unavailable(correlationId);
} }
@@ -87,7 +94,9 @@ export function createPeopleCommunityQuery(
const list = normalizePeopleListInput( const list = normalizePeopleListInput(
input.list, input.list,
["id", "username", "name"], input.routeId === "people.community.online"
? ["id", "username"]
: ["id", "name"],
"id", "id",
); );
try { try {
@@ -96,9 +105,7 @@ export function createPeopleCommunityQuery(
return ok( return ok(
{ {
kind: "online" as const, kind: "online" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username" ? row.username : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -108,9 +115,7 @@ export function createPeopleCommunityQuery(
return ok( return ok(
{ {
kind: "guilds" as const, kind: "guilds" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "name" ? row.name : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -122,11 +127,13 @@ export function createPeopleCommunityQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result) || !Array.isArray(result[0])) {
return Array.isArray(result[0]) ? (result[0] as T[]) : []; throw new Error("invalid People driver result");
}
return result[0] as T[];
} }
export const peopleCommunityAdapters: PeopleCommunityAdapters = { const peopleCommunityAdapters: PeopleCommunityAdapters = {
async loadOnline(input) { async loadOnline(input) {
const [{ sql }, { db }] = await Promise.all([ const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"), import("drizzle-orm"),
@@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${ ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, id 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}`), 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`} ${ ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, g.id ASC }, g.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
@@ -9,6 +9,7 @@ import {
ok, ok,
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import { import {
assertPeopleSerializable,
createPeoplePage, createPeoplePage,
type ListInput, type ListInput,
normalizePeopleListInput, normalizePeopleListInput,
@@ -79,10 +80,15 @@ export type PeopleModerationQueryData =
const broadCapability = anyCapability( const broadCapability = anyCapability(
PERMS.MODERATION_VIEW, PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW, PERMS.MOD_CFH_VIEW,
PERMS.BANS_VIEW, PERMS.MOD_ACTIONS,
PERMS.MODERATION_EDIT,
PERMS.MOD_BANS_VIEW, PERMS.MOD_BANS_VIEW,
PERMS.SETTINGS_VIEW, PERMS.BANS_VIEW,
PERMS.WORDFILTER_VIEW, PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.USERS_VIEW,
); );
function routeCapability(routeId: PeopleModerationQueryInput["routeId"]) { function routeCapability(routeId: PeopleModerationQueryInput["routeId"]) {
@@ -136,10 +142,12 @@ export function createPeopleModerationQuery(
try { try {
if (input.routeId === "people.moderation.overview") { if (input.routeId === "people.moderation.overview") {
const snapshot = await adapters.loadOverview();
assertPeopleSerializable(snapshot);
return ok( return ok(
{ {
kind: "overview" as const, kind: "overview" as const,
snapshot: await adapters.loadOverview(), snapshot,
}, },
correlationId, correlationId,
); );
@@ -154,39 +162,44 @@ export function createPeopleModerationQuery(
); );
} }
const ticket = await adapters.loadCfhDetail(input.id); const ticket = await adapters.loadCfhDetail(input.id);
return ticket === null if (ticket === null) {
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId) return fail(
: ok({ kind: "cfh-detail" as const, ticket }, correlationId); "NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
assertPeopleSerializable(ticket);
return ok({ kind: "cfh-detail" as const, ticket }, correlationId);
} }
if (input.routeId === "people.moderation.ip") { if (input.routeId === "people.moderation.ip") {
const rules = await adapters.loadIpRules(); const rules = await adapters.loadIpRules();
assertPeopleSerializable(rules);
return ok({ kind: "ip-rules" as const, ...rules }, correlationId); return ok({ kind: "ip-rules" as const, ...rules }, correlationId);
} }
if (input.routeId === "people.moderation.vpn") { if (input.routeId === "people.moderation.vpn") {
const settings = await adapters.loadVpnSettings();
assertPeopleSerializable(settings);
return ok( return ok(
{ {
kind: "vpn" as const, kind: "vpn" as const,
settings: await adapters.loadVpnSettings(), settings,
}, },
correlationId, correlationId,
); );
} }
const list = normalizePeopleListInput( const allowedSorts =
input.list, input.routeId === "people.moderation.word-filter"
["id", "username", "createdAt", "word"], ? ["id", "word"]
"id", : ["id", "username", "createdAt"];
); const list = normalizePeopleListInput(input.list, allowedSorts, "id");
if (input.routeId === "people.moderation.cfh") { if (input.routeId === "people.moderation.cfh") {
const result = await adapters.loadCfh(list); const result = await adapters.loadCfh(list);
return ok( return ok(
{ {
kind: "cfh" as const, kind: "cfh" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username"
? (row.reportedUsername ?? "")
: row.id,
),
}, },
correlationId, correlationId,
); );
@@ -196,9 +209,7 @@ export function createPeopleModerationQuery(
return ok( return ok(
{ {
kind: "bans" as const, kind: "bans" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username" ? (row.username ?? "") : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -207,9 +218,7 @@ export function createPeopleModerationQuery(
return ok( return ok(
{ {
kind: "word-filter" as const, kind: "word-filter" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "word" ? row.word : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -225,11 +234,13 @@ export function createPeopleModerationQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result) || !Array.isArray(result[0])) {
return Array.isArray(result[0]) ? (result[0] as T[]) : []; throw new Error("invalid People driver result");
}
return result[0] as T[];
} }
export const peopleModerationAdapters: PeopleModerationAdapters = { const peopleModerationAdapters: PeopleModerationAdapters = {
async loadOverview() { async loadOverview() {
const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([ const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([
import("drizzle-orm"), import("drizzle-orm"),
@@ -276,10 +287,10 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
LEFT JOIN users reported ON reported.id = c.reported_id LEFT JOIN users reported ON reported.id = c.reported_id
LEFT JOIN users moderator ON moderator.id = c.mod_id LEFT JOIN users moderator ON moderator.id = c.mod_id
${where} ${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` input.order === "asc" ? sql`ASC` : sql`DESC`
}, c.id ASC }, c.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total FROM support_tickets c 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 LEFT JOIN bans b ON b.user_id = c.reported_id
AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP())
WHERE c.id = ${id} 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 LIMIT 1
`); `);
const row = resultRows<{ const row = resultRows<{
@@ -382,8 +393,8 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
createdAt: toPeopleIsoDate(row.banCreatedAt), createdAt: toPeopleIsoDate(row.banCreatedAt),
expiresAt: expiresAt:
row.banExpiresAt === 0 row.banExpiresAt === 0
? null ? 0
: toPeopleIsoDate(row.banExpiresAt), : (toPeopleIsoDate(row.banExpiresAt) as string),
active: true, 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`} ${ ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`b.timestamp` : sql`b.id`} ${
input.order === "asc" ? sql`ASC` : sql`DESC` input.order === "asc" ? sql`ASC` : sql`DESC`
}, b.id ASC }, b.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total FROM bans b SELECT COUNT(*) AS total FROM bans b
@@ -438,7 +449,10 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
type: row.type, type: row.type,
reason: row.reason, reason: row.reason,
createdAt: toPeopleIsoDate(row.createdAt), 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, active: true,
})); }));
return { return {
@@ -508,7 +522,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
ORDER BY ${input.sort === "word" ? sql`word` : sql`id`} ${ ORDER BY ${input.sort === "word" ? sql`word` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC }, id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute( db.execute(
sql`SELECT COUNT(*) AS total FROM website_wordfilter ${where}`, sql`SELECT COUNT(*) AS total FROM website_wordfilter ${where}`,
@@ -1,148 +1,337 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { peopleCommunityAdapters } from "./community"; import { PERMS } from "@/lib/permission-slugs";
import { peopleModerationAdapters } from "./moderation"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { peopleStaffAdapters } from "./staff";
import { peopleSupportAdapters } from "./support";
import { buildPeopleUserSelection, peopleUsersAdapters } from "./users";
describe("People production adapter contracts", () => { const publicRuntimeExports = new Map([
it("keeps each matrix-backed source behind a narrow server adapter", () => { ["./users", ["createPeopleUsersQuery", "peopleUsersQuery"]],
expect(Object.keys(peopleUsersAdapters).sort()).toEqual([ ["./community", ["createPeopleCommunityQuery", "peopleCommunityQuery"]],
"loadMultiAccounts", ["./staff", ["createPeopleStaffQuery", "peopleStaffQuery"]],
"loadSanctions", ["./support", ["createPeopleSupportQuery", "peopleSupportQuery"]],
"loadUser", ["./moderation", ["createPeopleModerationQuery", "peopleModerationQuery"]],
"loadUsers", ] as const);
]);
expect(Object.keys(peopleCommunityAdapters).sort()).toEqual([ describe("People production authorization boundary", () => {
"loadGuild", it("exports only context-authorized query factories and singleton surfaces", async () => {
"loadGuilds", for (const [modulePath, expected] of publicRuntimeExports) {
"loadOnline", const runtime = await import(modulePath);
]); expect(Object.keys(runtime).sort(), modulePath).toEqual(
expect(Object.keys(peopleStaffAdapters).sort()).toEqual([ [...expected].sort(),
"loadApplications", );
"loadModerationTeam", }
"loadTeams",
]);
expect(Object.keys(peopleSupportAdapters).sort()).toEqual([
"loadHelpTicket",
"loadHelpTickets",
"loadQueue",
"loadTemplates",
"loadTicket",
"loadTickets",
]);
expect(Object.keys(peopleModerationAdapters).sort()).toEqual([
"loadBans",
"loadCfh",
"loadCfhDetail",
"loadIpRules",
"loadOverview",
"loadVpnSettings",
"loadWordFilter",
]);
}); });
it("selects mail and current IP only when their independent projections allow it", () => { it("keeps the PII selection builder private and excludes secret columns", () => {
const user = { const source = readFileSync(new URL("./users.ts", import.meta.url), "utf8");
id: "id", expect(source).toContain("function buildPeopleUserSelection");
username: "username", expect(source).not.toContain("export function buildPeopleUserSelection");
rank: "rank", for (const forbidden of [
online: "online", "password",
mail: "mail", "authTicket",
ipCurrent: "ipCurrent", "secretKey",
password: "password", "twoFactorSecret",
authTicket: "authTicket", ]) {
secretKey: "secretKey", expect(source).not.toContain(forbidden);
twoFactorSecret: "twoFactorSecret", }
}; expect(source).toContain('import("@/lib/services/watch")');
expect(source).toMatch(/FROM permissions ORDER BY id ASC/);
});
expect( it("uses a strict DB-paged unified support boundary without changing legacy tolerance", () => {
buildPeopleUserSelection(user, { const supportSource = readFileSync(
includeMail: false, new URL("./support.ts", import.meta.url),
includeIp: false, "utf8",
}),
).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",
]),
); );
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 () => { it("pages multi-account groups and loads their accounts in one bounded batch", async () => {
const execute = vi vi.resetModules();
.fn() const groups = [
.mockResolvedValueOnce([ { ipCurrent: "203.0.113.1", accountCount: 2 },
[ { ipCurrent: "203.0.113.2", accountCount: 2 },
{ ipCurrent: "203.0.113.1", accountCount: 2 }, { ipCurrent: "203.0.113.3", accountCount: 2 },
{ ipCurrent: "203.0.113.2", accountCount: 2 }, { ipCurrent: "203.0.113.4", accountCount: 2 },
{ ipCurrent: "203.0.113.3", accountCount: 2 }, ];
], const execute = vi.fn(
]) async (query: {
.mockResolvedValue([ strings: readonly string[];
[ values: readonly unknown[];
{ id: 1, username: "one", rank: 1, online: "0" }, }) => {
{ id: 2, username: "two", rank: 1, online: "0" }, 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[]) => ({ const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings, strings: [...strings],
values, values,
}); });
vi.doMock("drizzle-orm", () => ({ sql })); vi.doMock("drizzle-orm", () => ({ sql }));
vi.doMock("@/lib/db", () => ({ db: { execute } })); vi.doMock("@/lib/db", () => ({ db: { execute } }));
const result = await peopleUsersAdapters.loadMultiAccounts({ const permissions = new Set<string>([PERMS.USERS_VIEW]);
search: "", const capability: HousekeepingCapabilityContext = {
pageSize: 1, actor: { id: 42, username: "operator", rank: 99 },
offset: 1, isSuperAdmin: false,
sort: "id", has: (slug) => permissions.has(slug),
order: "asc", 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([ expect(result).toMatchObject({
"203.0.113.1", ok: true,
"203.0.113.2", 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<string>([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<string>([
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<string>([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" }],
},
},
});
}); });
}); });
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts"; import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { PEOPLE_ROUTES } from "../routes";
import { createPeopleCommunityQuery } from "./community"; import { createPeopleCommunityQuery } from "./community";
import { createPeopleModerationQuery } from "./moderation"; import { createPeopleModerationQuery } from "./moderation";
import { createPeopleStaffQuery } from "./staff"; import { createPeopleStaffQuery } from "./staff";
@@ -29,9 +30,75 @@ const rawUser = (id: number, username: string) => ({
}); });
describe("People users query", () => { 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 () => ({ 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, total: 3,
})); }));
const query = createPeopleUsersQuery({ const query = createPeopleUsersQuery({
@@ -39,6 +106,8 @@ describe("People users query", () => {
loadUser: async () => null, loadUser: async () => null,
loadMultiAccounts: async () => ({ rows: [], total: 0 }), loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [], loadSanctions: async () => [],
loadWatchedUserIds: async () => new Set(),
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
}); });
const result = await query.run(context([PERMS.USERS_VIEW]), { const result = await query.run(context([PERMS.USERS_VIEW]), {
@@ -94,6 +163,8 @@ describe("People users query", () => {
loadUser: async () => null, loadUser: async () => null,
loadMultiAccounts: async () => ({ rows: [], total: 0 }), loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [], loadSanctions: async () => [],
loadWatchedUserIds: async () => new Set(),
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
}); });
const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), { const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), {
@@ -132,6 +203,8 @@ describe("People users query", () => {
loadUser, loadUser,
loadMultiAccounts: async () => ({ rows: [], total: 0 }), loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [], loadSanctions: async () => [],
loadWatchedUserIds: async () => new Set(),
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
}); });
const invalid = await query.run(context([PERMS.USERS_VIEW]), { const invalid = await query.run(context([PERMS.USERS_VIEW]), {
@@ -184,6 +257,8 @@ describe("People users query", () => {
active: true, active: true,
}, },
], ],
loadWatchedUserIds: async () => new Set(),
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
}); });
const result = await query.run(context([PERMS.USERS_VIEW]), { const result = await query.run(context([PERMS.USERS_VIEW]), {
@@ -208,6 +283,35 @@ describe("People users query", () => {
}); });
describe("People community and staff queries", () => { 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 () => { it("loads online, guild list, and guild detail workflows through narrow adapters", async () => {
const loadOnline = vi.fn(async () => ({ rows: [], total: 0 })); const loadOnline = vi.fn(async () => ({ rows: [], total: 0 }));
const loadGuilds = vi.fn(async () => ({ const loadGuilds = vi.fn(async () => ({
@@ -317,6 +421,161 @@ describe("People community and staff queries", () => {
}); });
describe("People support query", () => { 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 () => { it("returns the four-source queue snapshot and fails closed when a count fails", async () => {
const loadQueue = vi const loadQueue = vi
.fn() .fn()
@@ -334,6 +593,8 @@ describe("People support query", () => {
loadTemplates: async () => ({ rows: [], total: 0 }), loadTemplates: async () => ({ rows: [], total: 0 }),
loadHelpTickets: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }),
loadHelpTicket: async () => null, loadHelpTicket: async () => null,
loadSupportStaff: async () => [],
loadActiveBan: async () => null,
}); });
expect( expect(
@@ -370,6 +631,8 @@ describe("People support query", () => {
loadTemplates: async () => ({ rows: [], total: 0 }), loadTemplates: async () => ({ rows: [], total: 0 }),
loadHelpTickets: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }),
loadHelpTicket: async () => null, loadHelpTicket: async () => null,
loadSupportStaff: async () => [],
loadActiveBan: async () => null,
}); });
for (const routeId of [ for (const routeId of [
@@ -402,6 +665,59 @@ describe("People support query", () => {
}); });
describe("People moderation 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 () => { it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => {
const query = createPeopleModerationQuery({ const query = createPeopleModerationQuery({
loadOverview: async () => ({ loadOverview: async () => ({
@@ -75,11 +75,13 @@ export function createPeopleStaffQuery(
const authorization = authorizeHousekeeping(context, requirement); const authorization = authorizeHousekeeping(context, requirement);
if (!authorization.ok) return authorization; if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId; const correlationId = authorization.correlationId;
const list = normalizePeopleListInput( const allowedSorts =
input.list, input.routeId === "people.staff.applications"
["id", "name", "username", "createdAt"], ? ["id", "username", "createdAt"]
"id", : input.routeId === "people.staff.teams"
); ? ["id", "name"]
: ["id", "username"];
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
try { try {
if (input.routeId === "people.staff.applications") { if (input.routeId === "people.staff.applications") {
@@ -87,13 +89,7 @@ export function createPeopleStaffQuery(
return ok( return ok(
{ {
kind: "applications" as const, kind: "applications" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username"
? (row.username ?? "")
: list.sort === "createdAt"
? (row.createdAt ?? "")
: row.id,
),
}, },
correlationId, correlationId,
); );
@@ -104,9 +100,7 @@ export function createPeopleStaffQuery(
return ok( return ok(
{ {
kind: "teams" as const, kind: "teams" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "name" ? row.name : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -116,9 +110,7 @@ export function createPeopleStaffQuery(
return ok( return ok(
{ {
kind: "moderation-team" as const, kind: "moderation-team" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "username" ? row.username : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -134,11 +126,13 @@ export function createPeopleStaffQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result) || !Array.isArray(result[0])) {
return Array.isArray(result[0]) ? (result[0] as T[]) : []; throw new Error("invalid People driver result");
}
return result[0] as T[];
} }
export const peopleStaffAdapters: PeopleStaffAdapters = { const peopleStaffAdapters: PeopleStaffAdapters = {
async loadApplications(input) { async loadApplications(input) {
const [{ sql }, { db }] = await Promise.all([ const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"), 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`} ${ 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` input.order === "asc" ? sql`ASC` : sql`DESC`
}, a.id ASC }, a.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
@@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${ ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, id 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}`), 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`} ${ ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, u.id ASC }, u.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total FROM users u SELECT COUNT(*) AS total FROM users u
@@ -9,13 +9,16 @@ import {
ok, ok,
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import { import {
assertPeopleSerializable,
createPeoplePage, createPeoplePage,
type ListInput, type ListInput,
normalizePeopleListInput, normalizePeopleListInput,
type Page, type Page,
type PeopleBanSummary,
type PeopleHelpTicketDetail, type PeopleHelpTicketDetail,
type PeopleHelpTicketSummary, type PeopleHelpTicketSummary,
type PeopleQueueSnapshot, type PeopleQueueSnapshot,
type PeopleSupportStaff,
type PeopleTicketDetail, type PeopleTicketDetail,
type PeopleTicketSummary, type PeopleTicketSummary,
type PeopleTicketTemplate, type PeopleTicketTemplate,
@@ -29,19 +32,27 @@ interface SupportRows<T> {
readonly total: number; readonly total: number;
} }
type PeopleTicketRecord = Omit<PeopleTicketDetail, "queue" | "staff">;
type PeopleHelpTicketRecord = Omit<
PeopleHelpTicketDetail,
"queue" | "staff" | "activeBan"
>;
export interface PeopleSupportAdapters { export interface PeopleSupportAdapters {
loadQueue(): Promise<PeopleQueueSnapshot>; loadQueue(): Promise<PeopleQueueSnapshot>;
loadTickets( loadTickets(
input: ReturnType<typeof normalizePeopleListInput>, input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleTicketSummary>>; ): Promise<SupportRows<PeopleTicketSummary>>;
loadTicket(id: number): Promise<PeopleTicketDetail | null>; loadTicket(id: number): Promise<PeopleTicketRecord | null>;
loadTemplates( loadTemplates(
input: ReturnType<typeof normalizePeopleListInput>, input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleTicketTemplate>>; ): Promise<SupportRows<PeopleTicketTemplate>>;
loadHelpTickets( loadHelpTickets(
input: ReturnType<typeof normalizePeopleListInput>, input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleHelpTicketSummary>>; ): Promise<SupportRows<PeopleHelpTicketSummary>>;
loadHelpTicket(id: number): Promise<PeopleHelpTicketDetail | null>; loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
} }
export type PeopleSupportQueryInput = export type PeopleSupportQueryInput =
@@ -65,6 +76,12 @@ export type PeopleSupportQueryInput =
export type PeopleSupportQueryData = export type PeopleSupportQueryData =
| { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot } | { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot }
| { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> } | { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> }
| {
readonly kind: "ticket-desk";
readonly page: Page<PeopleTicketSummary>;
readonly queue: PeopleQueueSnapshot;
readonly staff: readonly PeopleSupportStaff[];
}
| { | {
readonly kind: "ticket-templates"; readonly kind: "ticket-templates";
readonly page: Page<PeopleTicketTemplate>; readonly page: Page<PeopleTicketTemplate>;
@@ -119,10 +136,9 @@ export function createPeopleSupportQuery(
try { try {
if (input.routeId === "people.support.queue") { if (input.routeId === "people.support.queue") {
return ok( const queue = await adapters.loadQueue();
{ kind: "queue" as const, queue: await adapters.loadQueue() }, assertPeopleSerializable(queue);
correlationId, return ok({ kind: "queue" as const, queue }, correlationId);
);
} }
if ( if (
@@ -142,40 +158,52 @@ export function createPeopleSupportQuery(
correlationId, 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" return input.routeId === "people.support.ticket-detail"
? ok( ? ok(
{ {
kind: "ticket" as const, kind: "ticket" as const,
ticket: ticket as PeopleTicketDetail, ticket: hydrated as PeopleTicketDetail,
}, },
correlationId, correlationId,
) )
: ok( : ok(
{ {
kind: "help-ticket" as const, kind: "help-ticket" as const,
ticket: ticket as PeopleHelpTicketDetail, ticket: hydrated as PeopleHelpTicketDetail,
}, },
correlationId, correlationId,
); );
} }
const list = normalizePeopleListInput( const allowedSorts =
input.list, input.routeId === "people.support.ticket-templates"
["id", "subject", "title", "status", "updatedAt", "sortOrder"], ? ["id", "title", "sortOrder"]
"id", : 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") { if (input.routeId === "people.support.ticket-templates") {
const result = await adapters.loadTemplates(list); const result = await adapters.loadTemplates(list);
return ok( return ok(
{ {
kind: "ticket-templates" as const, kind: "ticket-templates" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "title"
? row.title
: list.sort === "sortOrder"
? row.sortOrder
: row.id,
),
}, },
correlationId, correlationId,
); );
@@ -186,9 +214,25 @@ export function createPeopleSupportQuery(
return ok( return ok(
{ {
kind: "help-tickets" as const, kind: "help-tickets" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "title" ? row.title : row.id, },
), 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, correlationId,
); );
@@ -198,9 +242,7 @@ export function createPeopleSupportQuery(
return ok( return ok(
{ {
kind: "tickets" as const, kind: "tickets" as const,
page: createPeoplePage(result.rows, result.total, list, (row) => page: createPeoplePage(result.rows, result.total, list),
list.sort === "subject" ? row.subject : row.id,
),
}, },
correlationId, correlationId,
); );
@@ -212,84 +254,74 @@ export function createPeopleSupportQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result) || !Array.isArray(result[0])) {
return Array.isArray(result[0]) ? (result[0] as T[]) : []; throw new Error("invalid People driver result");
}
return result[0] as T[];
} }
export const peopleSupportAdapters: PeopleSupportAdapters = { const peopleSupportAdapters: PeopleSupportAdapters = {
async loadQueue() { async loadQueue() {
const [{ sql }, { db }] = await Promise.all([ const [{ sql }, { db }, { fetchTicketQueueOpenCounts }] = await Promise.all(
import("drizzle-orm"), [
import("@/lib/db"), import("drizzle-orm"),
]); import("@/lib/db"),
const result = await db.execute(sql` import("@/lib/admin/ticket-queue-counts"),
],
);
const [ticketCounts, result] = await Promise.all([
fetchTicketQueueOpenCounts({ strict: true }),
db.execute(sql`
SELECT 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 support_tickets WHERE state <> 2) AS cfh,
(SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans (SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans
`); `),
]);
const row = resultRows<{ const row = resultRows<{
tickets: number;
helpTickets: number;
cfh: number; cfh: number;
activeBans: number; activeBans: number;
}>(result)[0]; }>(result)[0];
if (!row) throw new Error("queue counts unavailable"); if (!row) throw new Error("queue counts unavailable");
return { return {
tickets: Number(row.tickets), tickets: ticketCounts.cmsOpen,
helpTickets: Number(row.helpTickets), helpTickets: ticketCounts.helpOpen,
cfh: Number(row.cfh), cfh: Number(row.cfh),
activeBans: Number(row.activeBans), activeBans: Number(row.activeBans),
}; };
}, },
async loadTickets(input) { async loadTickets(input) {
const [{ sql }, { db }] = await Promise.all([ const { fetchUnifiedTicketInbox } = await import(
import("drizzle-orm"), "@/lib/admin/ticket-inbox"
import("@/lib/db"), );
]); const result = await fetchUnifiedTicketInbox({
const pattern = `%${input.search}%`; strict: true,
const where = input.search page: Math.floor(input.offset / input.pageSize) + 1,
? sql`WHERE t.subject LIKE ${pattern} OR t.status LIKE ${pattern} OR u.username LIKE ${pattern}` perPage: input.pageSize,
: sql``; offset: input.offset,
const [rowsResult, countResult] = await Promise.all([ search: input.search,
db.execute(sql` openOnly: false,
SELECT t.id, t.subject, t.status, t.priority, t.creator_id AS creatorId, base: "admin",
u.username AS creatorUsername, t.updated_at AS updatedAt sort: input.sort as "id" | "subject" | "status" | "updatedAt",
FROM website_tickets t order: input.order,
LEFT JOIN users u ON u.id = t.creator_id });
${where} const rows = result.rows.map((row) => {
ORDER BY ${input.sort === "subject" ? sql`t.subject` : input.sort === "status" ? sql`t.status` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${ const id = Number(row.id);
input.order === "asc" ? sql`ASC` : sql`DESC` return {
}, t.id ASC source: row.kind,
LIMIT ${input.offset + input.pageSize} id,
`), subject: row.title,
db.execute(sql` status: row.status,
SELECT COUNT(*) AS total FROM website_tickets t priority: "normal",
LEFT JOIN users u ON u.id = t.creator_id ${where} creatorId: row.userId,
`), creatorUsername: row.user === "—" ? null : row.user,
]); updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
const rows = resultRows<{ href:
id: number; row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id),
subject: string; };
status: string; });
priority: string;
creatorId: number;
creatorUsername: string | null;
updatedAt: Date | string | null;
}>(rowsResult).map((row) => ({
id: Number(row.id),
subject: row.subject,
status: row.status,
priority: row.priority,
creatorId: Number(row.creatorId),
creatorUsername: row.creatorUsername,
updatedAt: toPeopleIsoDate(row.updatedAt),
href: peopleTicketHref(Number(row.id)),
}));
return { return {
rows, rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0), total: result.total,
}; };
}, },
async loadTicket(id) { async loadTicket(id) {
@@ -313,6 +345,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
LEFT JOIN users u ON u.id = m.user_id LEFT JOIN users u ON u.id = m.user_id
WHERE m.ticket_id = ${id} WHERE m.ticket_id = ${id}
ORDER BY m.created_at ASC, m.id ASC ORDER BY m.created_at ASC, m.id ASC
LIMIT 500
`), `),
]); ]);
const row = resultRows<{ const row = resultRows<{
@@ -328,6 +361,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
}>(ticketResult)[0]; }>(ticketResult)[0];
if (!row) return null; if (!row) return null;
return { return {
source: "cms",
id: Number(row.id), id: Number(row.id),
subject: row.subject, subject: row.subject,
category: row.category, 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`} ${ ORDER BY ${input.sort === "title" ? sql`title` : input.sort === "sortOrder" ? sql`sort_order` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC` input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC }, id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total FROM website_ticket_templates ${where} 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`} ${ 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` input.order === "asc" ? sql`ASC` : sql`DESC`
}, t.id ASC }, t.id ASC
LIMIT ${input.offset + input.pageSize} LIMIT ${input.pageSize} OFFSET ${input.offset}
`), `),
db.execute(sql` db.execute(sql`
SELECT COUNT(*) AS total FROM website_help_center_tickets t 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 LEFT JOIN users u ON u.id = r.user_id
WHERE r.ticket_id = ${id} WHERE r.ticket_id = ${id}
ORDER BY r.created_at ASC, r.id ASC ORDER BY r.created_at ASC, r.id ASC
LIMIT 500
`), `),
]); ]);
const row = resultRows<{ 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( export const peopleSupportQuery = createPeopleSupportQuery(
@@ -9,12 +9,14 @@ import {
ok, ok,
} from "../../../foundation/contracts"; } from "../../../foundation/contracts";
import { import {
assertPeopleSerializable,
createPeoplePage, createPeoplePage,
type ListInput, type ListInput,
normalizePeopleListInput, normalizePeopleListInput,
normalizePeopleUser, normalizePeopleUser,
type Page, type Page,
type PeopleMultiAccountCluster, type PeopleMultiAccountCluster,
type PeoplePermissionRank,
type PeopleSanctionSummary, type PeopleSanctionSummary,
type PeopleUserDetail, type PeopleUserDetail,
type PeopleUserRecord, type PeopleUserRecord,
@@ -31,7 +33,7 @@ interface PeopleUserColumns {
readonly ipCurrent: unknown; readonly ipCurrent: unknown;
} }
export function buildPeopleUserSelection<T extends PeopleUserColumns>( function buildPeopleUserSelection<T extends PeopleUserColumns>(
user: T, user: T,
projection: { readonly includeMail: boolean; readonly includeIp: boolean }, projection: { readonly includeMail: boolean; readonly includeIp: boolean },
) { ) {
@@ -71,6 +73,8 @@ export interface PeopleUsersAdapters {
readonly total: number; readonly total: number;
}>; }>;
loadSanctions(userId: number): Promise<readonly PeopleSanctionSummary[]>; loadSanctions(userId: number): Promise<readonly PeopleSanctionSummary[]>;
loadWatchedUserIds(staffId: number): Promise<ReadonlySet<number>>;
loadPermissionRanks(): Promise<readonly PeoplePermissionRank[]>;
} }
export type PeopleUsersQueryInput = export type PeopleUsersQueryInput =
@@ -141,16 +145,10 @@ export function createPeopleUsersQuery(
const rows = result.rows.map((row) => const rows = result.rows.map((row) =>
normalizePeopleUser(row, projection), normalizePeopleUser(row, projection),
); );
const sortValue = (row: PeopleUserSummary) =>
list.sort === "username"
? row.username
: list.sort === "rank"
? row.rank
: row.id;
return ok( return ok(
{ {
kind: "users" as const, kind: "users" as const,
page: createPeoplePage(rows, result.total, list, sortValue), page: createPeoplePage(rows, result.total, list),
}, },
correlationId, correlationId,
); );
@@ -160,26 +158,17 @@ export function createPeopleUsersQuery(
} }
if (input.routeId === "people.users.multi-accounts") { if (input.routeId === "people.users.multi-accounts") {
const list = normalizePeopleListInput(input.list, ["id"], "id"); const list = normalizePeopleListInput(
input.list,
["key", "accountCount"],
"key",
);
try { try {
const result = await adapters.loadMultiAccounts(list); 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( return ok(
{ {
kind: "multi-accounts" as const, kind: "multi-accounts" as const,
page: { page: createPeoplePage(result.rows, result.total, list),
...page,
items: page.items.map(({ id: _id, ...row }) => row),
} as Page<PeopleMultiAccountCluster>,
}, },
correlationId, correlationId,
); );
@@ -206,18 +195,29 @@ export function createPeopleUsersQuery(
correlationId, 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( return ok(
{ {
kind: "user" as const, kind: "user" as const,
user: { user,
...normalizePeopleUser(row, projection),
motto: String(row.motto ?? ""),
look: String(row.look ?? ""),
accountCreated: toPeopleIsoDate(row.accountCreated),
lastLogin: toPeopleIsoDate(row.lastLogin),
sanctions,
},
}, },
correlationId, correlationId,
); );
@@ -229,14 +229,15 @@ export function createPeopleUsersQuery(
} }
function resultRows<T>(result: unknown): T[] { function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return []; if (!Array.isArray(result)) throw new Error("invalid People driver result");
const rows = result[0]; 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) { 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")]); await Promise.all([import("drizzle-orm"), import("@/lib/db")]);
const searchConditions = input.search const searchConditions = input.search
? [ ? [
@@ -279,8 +280,9 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
.select(selection) .select(selection)
.from(User) .from(User)
.where(where) .where(where)
.orderBy(direction(sortColumn), asc(User.id)) .orderBy(direction(sortColumn), direction(User.id))
.limit(input.offset + input.pageSize), .limit(input.pageSize)
.offset(input.offset),
db.select({ total: count() }).from(User).where(where), db.select({ total: count() }).from(User).where(where),
]); ]);
const ids = (rows as unknown as PeopleUserRecord[]) const ids = (rows as unknown as PeopleUserRecord[])
@@ -292,11 +294,24 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
: await db : await db
.select({ userId: Ban.userId, banExpire: Ban.banExpire }) .select({ userId: Ban.userId, banExpire: Ban.banExpire })
.from(Ban) .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<number, number>(); const latestBan = new Map<number, number>();
for (const ban of activeBans) { for (const ban of activeBans) {
const current = latestBan.get(ban.userId) ?? 0; const hasCurrent = latestBan.has(ban.userId);
if (ban.banExpire === 0 || ban.banExpire > current) { const current = latestBan.get(ban.userId);
if (
!hasCurrent ||
(current !== 0 &&
(ban.banExpire === 0 || ban.banExpire > (current ?? 0)))
) {
latestBan.set(ban.userId, ban.banExpire); latestBan.set(ban.userId, ban.banExpire);
} }
} }
@@ -309,7 +324,7 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
}; };
}, },
async loadUser(id, projection) { 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("drizzle-orm"),
import("@/lib/db"), import("@/lib/db"),
]); ]);
@@ -332,15 +347,31 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
.where(eq(User.id, id)) .where(eq(User.id, id))
.limit(1); .limit(1);
if (!row) return null; if (!row) return null;
const [ban] = await db const now = Math.floor(Date.now() / 1000);
const bans = await db
.select({ banExpire: Ban.banExpire }) .select({ banExpire: Ban.banExpire })
.from(Ban) .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)) .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 { return {
...(row as unknown as PeopleUserDetailRecord), ...(row as unknown as PeopleUserDetailRecord),
bannedUntil: ban?.banExpire ?? null, bannedUntil,
}; };
}, },
async loadMultiAccounts(input) { async loadMultiAccounts(input) {
@@ -348,49 +379,71 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
import("drizzle-orm"), import("drizzle-orm"),
import("@/lib/db"), import("@/lib/db"),
]); ]);
const groupResult = await db.execute(sql` const search = input.search
SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount ? sql`AND ip_current LIKE ${`%${input.search}%`}`
FROM users : sql``;
WHERE ip_current <> '' const sortColumn =
GROUP BY ip_current input.sort === "accountCount" ? sql`accountCount` : sql`ipCurrent`;
HAVING COUNT(*) >= 2 const direction = input.order === "desc" ? sql`DESC` : sql`ASC`;
ORDER BY accountCount DESC, ip_current 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 }>( const records = resultRows<{
groupResult, ipCurrent: string;
); accountCount: number | bigint;
const filtered = input.search total: number | bigint;
? groups.filter((group) => group.ipCurrent.includes(input.search)) id: number;
: groups; username: string;
const selected = filtered.slice(0, input.offset + input.pageSize); rank: number;
const rows = await Promise.all( online: string;
selected.map(async (group) => { }>(result);
const usersResult = await db.execute(sql` const clusters = new Map<string, PeopleMultiAccountCluster>();
SELECT id, username, rank, online for (const record of records) {
FROM users const existing = clusters.get(record.ipCurrent);
WHERE ip_current = ${group.ipCurrent} const account = {
ORDER BY id ASC id: Number(record.id),
`); username: record.username,
const accounts = resultRows<{ rank: Number(record.rank),
id: number; online: record.online === "1",
username: string; href: `/ase/people/users/${Number(record.id)}` as const,
rank: number; };
online: string; if (existing) {
}>(usersResult).map((user) => ({ (existing.accounts as (typeof account)[]).push(account);
id: Number(user.id), } else {
username: user.username, clusters.set(record.ipCurrent, {
rank: Number(user.rank), key: record.ipCurrent,
online: user.online === "1", accountCount: Number(record.accountCount),
href: `/ase/people/users/${Number(user.id)}` as const, accounts: [account],
})); });
return { }
key: group.ipCurrent, }
accountCount: Number(group.accountCount), return {
accounts, rows: [...clusters.values()],
}; total: Number(records[0]?.total ?? 0),
}), };
);
return { rows, total: filtered.length };
}, },
async loadSanctions(userId) { async loadSanctions(userId) {
const [{ desc, eq }, { Ban, db }] = await Promise.all([ const [{ desc, eq }, { Ban, db }] = await Promise.all([
@@ -415,10 +468,31 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
kind: row.type, kind: row.type,
reason: row.reason, reason: row.reason,
createdAt: toPeopleIsoDate(row.createdAt), 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, 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); export const peopleUsersQuery = createPeopleUsersQuery(peopleUsersAdapters);
+215
View File
@@ -10,6 +10,7 @@ import {
ne, ne,
or, or,
type SQL, type SQL,
sql,
} from "drizzle-orm"; } from "drizzle-orm";
import { alias } from "drizzle-orm/mysql-core"; import { alias } from "drizzle-orm/mysql-core";
import { calcPagination } from "@/lib/admin-helpers"; import { calcPagination } from "@/lib/admin-helpers";
@@ -36,11 +37,16 @@ export interface TicketInboxRow {
export interface FetchTicketInboxOptions { export interface FetchTicketInboxOptions {
page: number; page: number;
perPage: number; perPage: number;
offset?: number;
search?: string; search?: string;
type?: TicketInboxTypeFilter; type?: TicketInboxTypeFilter;
/** Default true: only non-closed CMS + open help-center. */ /** Default true: only non-closed CMS + open help-center. */
openOnly?: boolean; openOnly?: boolean;
base?: "admin" | "mod"; 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 { function toSortMs(value: Date | string | null | undefined): number {
@@ -267,6 +273,214 @@ async function countHelp(search: string, openOnly: boolean): Promise<number> {
return Number(rows[0]?.total ?? 0); return Number(rows[0]?.total ?? 0);
} }
function strictRows<T>(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). */ /** Merged read-model over CMS desk + help-center queues (no DB merge). */
export async function fetchUnifiedTicketInbox( export async function fetchUnifiedTicketInbox(
options: FetchTicketInboxOptions, options: FetchTicketInboxOptions,
@@ -279,6 +493,7 @@ export async function fetchUnifiedTicketInbox(
cmsTotal: number; cmsTotal: number;
helpTotal: number; helpTotal: number;
}> { }> {
if (options.strict) return fetchStrictUnifiedTicketInbox(options);
const type = options.type ?? "all"; const type = options.type ?? "all";
const openOnly = options.openOnly !== false; const openOnly = options.openOnly !== false;
const search = options.search?.trim() ?? ""; const search = options.search?.trim() ?? "";
+23 -13
View File
@@ -4,23 +4,33 @@ import { count, eq, ne } from "drizzle-orm";
import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db"; import { db, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
/** Open-queue sizes for dual ticket products (CMS desk vs help-center). */ /** 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; cmsOpen: number;
helpOpen: 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<number> => {
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([ const [cmsOpen, helpOpen] = await Promise.all([
db options?.strict ? readCount(cms) : readCount(cms).catch(() => 0),
.select({ total: count() }) options?.strict ? readCount(help) : readCount(help).catch(() => 0),
.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),
]); ]);
return { cmsOpen, helpOpen }; return { cmsOpen, helpOpen };
} }