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