fix(housekeeping): harden people read boundaries
This commit is contained in:
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}`,
|
||||
|
||||
+318
-129
@@ -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);
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user