fix(housekeeping): complete people read fidelity
This commit is contained in:
1 parent
d1382c839e
commit
7920d4f46c
8 files changed
+564
-37
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# Task 11 — People workflow read models
|
# Task 11 — People workflow read models
|
||||||
|
|
||||||
Status: DONE — fix round 1
|
Status: DONE — fix round 2
|
||||||
|
|
||||||
## Delivered scope
|
## Delivered scope
|
||||||
|
|
||||||
@@ -15,12 +15,12 @@ Status: DONE — fix round 1
|
|||||||
- User mail and current IP remain independently nullable fields. Each is projected only when the capability context contains the existing `PERMS.USERS_VIEW`; `PERMS.MOD_USERS_VIEW` alone receives the safe base projection with both values set to `null`, and a context with neither permission is forbidden.
|
- User mail and current IP remain independently nullable fields. Each is projected only when the capability context contains the existing `PERMS.USERS_VIEW`; `PERMS.MOD_USERS_VIEW` alone receives the safe base projection with both values set to `null`, and a context with neither permission is forbidden.
|
||||||
- No new ACL slug or rank threshold was introduced. Staff filtering reuses the existing `getMinStaffRank()` source.
|
- No new ACL slug or rank threshold was introduced. Staff filtering reuses the existing `getMinStaffRank()` source.
|
||||||
- The production user selection is explicit and excludes passwords, authentication tickets, secrets, and two-factor material. VPN settings intentionally exclude `vpn_api_key`.
|
- The production user selection is explicit and excludes passwords, authentication tickets, secrets, and two-factor material. VPN settings intentionally exclude `vpn_api_key`.
|
||||||
- Adapters fail closed. 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.
|
- Adapters fail closed. Malformed driver envelopes, invalid identifiers, corrupt links, non-serializable DTO values, and count failures map to `DEPENDENCY_UNAVAILABLE`. Primary/entity identifiers remain positive safe integers; zero is accepted only for the schema-declared guild `userId`/`roomId` and CFH `senderId`/`reportedId`/`roomId`/`moderatorId` sentinels. Missing valid detail entities map to `NOT_FOUND`; invalid request identifiers map to `VALIDATION`.
|
||||||
- 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.
|
- 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.
|
- 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.
|
- Multi-account clusters use one bounded CTE/window page query plus one independent matching-cluster count 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.
|
- 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.
|
- The unified `/support/tickets` inbox still merges CMS and help-center rows. The ticket desk now has its own strict CMS-only page loader preserving priority, category, assignee, and message count; help summaries include reply count. Support desk/detail DTOs explicitly include queue counts, bounded staff, and the relevant active ban.
|
||||||
- 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.
|
- 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
|
## Official fix round 1 findings
|
||||||
@@ -35,6 +35,12 @@ Status: DONE — fix round 1
|
|||||||
|
|
||||||
The two official Minor findings remain parked and unchanged as instructed.
|
The two official Minor findings remain parked and unchanged as instructed.
|
||||||
|
|
||||||
|
## Official fix round 2 findings
|
||||||
|
|
||||||
|
1. **Entity-aware sentinels:** canonical guild DTOs now use the real schema names `userId` and `roomId`. Serialization permits zero only on those two guild fields and the four named CFH fields when the containing DTO has the matching canonical entity href. Generic `*Id` zero values, negatives, unsafe integers, and primary ID zero remain unavailable failures.
|
||||||
|
2. **Support source fidelity:** `people.support.tickets` remains on strict `fetchUnifiedTicketInbox`. `people.support.ticket-desk` now dispatches to a distinct strict `website_tickets` loader with message aggregation and no help-center source. Real priority/category/assignee/message count and help reply count are present in canonical DTOs; malformed driver rows fail closed.
|
||||||
|
3. **Multi-account total:** the page CTE and matching-cluster count run as two bounded parallel queries. Empty pages retain the correct total without prefix loading or N+1 queries.
|
||||||
|
|
||||||
## Strict TDD evidence
|
## Strict TDD evidence
|
||||||
|
|
||||||
### Cycle 1 — exact route catalog
|
### Cycle 1 — exact route catalog
|
||||||
@@ -253,27 +259,86 @@ Test Files 1 passed (1)
|
|||||||
Tests 1 passed | 4 skipped (5)
|
Tests 1 passed | 4 skipped (5)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fix round 2 strict behavioral TDD evidence
|
||||||
|
|
||||||
|
### Entity-aware schema sentinels
|
||||||
|
|
||||||
|
RED:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts -t "zero sentinels"
|
||||||
|
Test Files 1 failed (1)
|
||||||
|
Tests 2 failed | 19 skipped (21)
|
||||||
|
Valid guild userId/roomId zero and CFH senderId/reportedId/moderatorId/roomId zero were rejected by generic identifier validation.
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
same command
|
||||||
|
Test Files 1 passed (1)
|
||||||
|
Tests 2 passed | 19 skipped (21)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CMS-only ticket desk and help reply counts
|
||||||
|
|
||||||
|
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 "CMS-only context loader|reply counts"
|
||||||
|
Test Files 2 failed (2)
|
||||||
|
Tests 2 failed | 28 skipped (30)
|
||||||
|
The desk received a help row with synthetic normal priority; help summary omitted replyCount.
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
same command
|
||||||
|
Test Files 2 passed (2)
|
||||||
|
Tests 2 passed | 28 skipped (30)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Independent multi-account total
|
||||||
|
|
||||||
|
RED:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts -t "beyond the last page"
|
||||||
|
Test Files 1 failed (1)
|
||||||
|
Tests 1 failed | 8 skipped (9)
|
||||||
|
Expected total 4 on the empty page; received 0.
|
||||||
|
```
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
same command
|
||||||
|
Test Files 1 passed (1)
|
||||||
|
Tests 1 passed | 8 skipped (9)
|
||||||
|
```
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
```text
|
```text
|
||||||
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
|
||||||
Test Files 4 passed (4)
|
Test Files 4 passed (4)
|
||||||
Tests 35 passed (35)
|
Tests 40 passed (40)
|
||||||
|
|
||||||
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
|
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)
|
Test Files 9 passed (9)
|
||||||
Tests 81 passed (81)
|
Tests 86 passed (86)
|
||||||
|
|
||||||
pnpm test:housekeeping
|
pnpm test:housekeeping
|
||||||
Test Files 49 passed (49)
|
Test Files 49 passed (49)
|
||||||
Tests 430 passed (430)
|
Tests 435 passed (435)
|
||||||
|
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
tsc --noEmit
|
tsc --noEmit
|
||||||
Exit 0
|
Exit 0
|
||||||
|
|
||||||
pnpm exec biome check --formatter-enabled=false <11 exact changed Task 11 TypeScript files>
|
pnpm exec biome check --formatter-enabled=false <7 exact changed Task 11 TypeScript files>
|
||||||
Checked 11 files. No fixes applied.
|
Checked 7 files. No fixes applied.
|
||||||
|
|
||||||
git diff --check
|
git diff --check
|
||||||
Exit 0
|
Exit 0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
assertPeopleSerializable,
|
||||||
createPeoplePage,
|
createPeoplePage,
|
||||||
normalizePeopleListInput,
|
normalizePeopleListInput,
|
||||||
normalizePeopleUser,
|
normalizePeopleUser,
|
||||||
@@ -171,5 +172,12 @@ describe("People canonical models", () => {
|
|||||||
expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow();
|
expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow();
|
||||||
expect(() => peopleGuildHref(-4)).toThrow();
|
expect(() => peopleGuildHref(-4)).toThrow();
|
||||||
expect(() => toPeopleIsoDate("not-a-date")).toThrow();
|
expect(() => toPeopleIsoDate("not-a-date")).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
assertPeopleSerializable({
|
||||||
|
id: 7,
|
||||||
|
userId: 0,
|
||||||
|
href: "/ase/people/users/7",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -92,15 +92,15 @@ export interface PeopleGuildSummary {
|
|||||||
readonly id: number;
|
readonly id: number;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly description: string;
|
readonly description: string;
|
||||||
readonly ownerId: number;
|
readonly userId: number;
|
||||||
readonly ownerUsername: string | null;
|
readonly ownerUsername: string | null;
|
||||||
|
readonly roomId: number;
|
||||||
readonly memberCount: number;
|
readonly memberCount: number;
|
||||||
readonly createdAt: string | null;
|
readonly createdAt: string | null;
|
||||||
readonly href: `/ase/people/community/guilds/${number}`;
|
readonly href: `/ase/people/community/guilds/${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PeopleGuildDetail extends PeopleGuildSummary {
|
export interface PeopleGuildDetail extends PeopleGuildSummary {
|
||||||
readonly roomId: number;
|
|
||||||
readonly threadCount: number;
|
readonly threadCount: number;
|
||||||
readonly members: readonly {
|
readonly members: readonly {
|
||||||
readonly id: number;
|
readonly id: number;
|
||||||
@@ -166,6 +166,13 @@ export interface PeopleTicketSummary {
|
|||||||
| `/ase/people/support/help-tickets/${number}`;
|
| `/ase/people/support/help-tickets/${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PeopleTicketDeskSummary extends PeopleTicketSummary {
|
||||||
|
readonly source: "cms";
|
||||||
|
readonly category: string;
|
||||||
|
readonly assigneeId: number | null;
|
||||||
|
readonly messageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PeopleTicketDetail extends PeopleTicketSummary {
|
export interface PeopleTicketDetail extends PeopleTicketSummary {
|
||||||
readonly category: string;
|
readonly category: string;
|
||||||
readonly assigneeId: number | null;
|
readonly assigneeId: number | null;
|
||||||
@@ -188,6 +195,7 @@ export interface PeopleHelpTicketSummary {
|
|||||||
readonly userId: number | null;
|
readonly userId: number | null;
|
||||||
readonly username: string | null;
|
readonly username: string | null;
|
||||||
readonly updatedAt: string | null;
|
readonly updatedAt: string | null;
|
||||||
|
readonly replyCount: number;
|
||||||
readonly href: `/ase/people/support/help-tickets/${number}`;
|
readonly href: `/ase/people/support/help-tickets/${number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +470,25 @@ const DATE_KEYS = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export function assertPeopleSerializable(value: unknown): void {
|
export function assertPeopleSerializable(value: unknown): void {
|
||||||
function visit(current: unknown, key = ""): void {
|
function permitsZeroSentinel(parent: unknown, key: string): boolean {
|
||||||
|
if (typeof parent !== "object" || parent === null) return false;
|
||||||
|
const href = Reflect.get(parent, "href");
|
||||||
|
if (typeof href !== "string") return false;
|
||||||
|
if (href.startsWith("/ase/people/community/guilds/")) {
|
||||||
|
return key === "userId" || key === "roomId";
|
||||||
|
}
|
||||||
|
if (href.startsWith("/ase/people/moderation/cfh/")) {
|
||||||
|
return (
|
||||||
|
key === "senderId" ||
|
||||||
|
key === "reportedId" ||
|
||||||
|
key === "roomId" ||
|
||||||
|
key === "moderatorId"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(current: unknown, key = "", parent?: unknown): void {
|
||||||
if (
|
if (
|
||||||
current === null ||
|
current === null ||
|
||||||
typeof current === "string" ||
|
typeof current === "string" ||
|
||||||
@@ -480,13 +506,17 @@ export function assertPeopleSerializable(value: unknown): void {
|
|||||||
if (typeof current === "number") {
|
if (typeof current === "number") {
|
||||||
if (!Number.isSafeInteger(current))
|
if (!Number.isSafeInteger(current))
|
||||||
throw new Error("invalid People number");
|
throw new Error("invalid People number");
|
||||||
if ((key === "id" || key.endsWith("Id")) && current <= 0) {
|
if (
|
||||||
|
(key === "id" || key.endsWith("Id")) &&
|
||||||
|
current <= 0 &&
|
||||||
|
!(current === 0 && permitsZeroSentinel(parent, key))
|
||||||
|
) {
|
||||||
throw new Error("invalid People identifier");
|
throw new Error("invalid People identifier");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (Array.isArray(current)) {
|
if (Array.isArray(current)) {
|
||||||
for (const item of current) visit(item);
|
for (const item of current) visit(item, "", current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof current !== "object" || current instanceof Date) {
|
if (typeof current !== "object" || current instanceof Date) {
|
||||||
@@ -494,7 +524,7 @@ export function assertPeopleSerializable(value: unknown): void {
|
|||||||
}
|
}
|
||||||
for (const [childKey, child] of Object.entries(current)) {
|
for (const [childKey, child] of Object.entries(current)) {
|
||||||
if (child === undefined) throw new Error("undefined People value");
|
if (child === undefined) throw new Error("undefined People value");
|
||||||
visit(child, childKey);
|
visit(child, childKey, current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(value);
|
visit(value);
|
||||||
|
|||||||
@@ -183,14 +183,16 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
|||||||
: sql``;
|
: sql``;
|
||||||
const [rowsResult, countResult] = await Promise.all([
|
const [rowsResult, countResult] = await Promise.all([
|
||||||
db.execute(sql`
|
db.execute(sql`
|
||||||
SELECT g.id, g.name, g.description, g.user_id AS ownerId,
|
SELECT g.id, g.name, g.description, g.user_id AS userId,
|
||||||
u.username AS ownerUsername, g.date_created AS createdAt,
|
u.username AS ownerUsername, g.room_id AS roomId,
|
||||||
|
g.date_created AS createdAt,
|
||||||
COUNT(gm.id) AS memberCount
|
COUNT(gm.id) AS memberCount
|
||||||
FROM guilds g
|
FROM guilds g
|
||||||
LEFT JOIN users u ON u.id = g.user_id
|
LEFT JOIN users u ON u.id = g.user_id
|
||||||
LEFT JOIN guilds_members gm ON gm.guild_id = g.id
|
LEFT JOIN guilds_members gm ON gm.guild_id = g.id
|
||||||
${where}
|
${where}
|
||||||
GROUP BY g.id, g.name, g.description, g.user_id, u.username, g.date_created
|
GROUP BY g.id, g.name, g.description, g.user_id, u.username,
|
||||||
|
g.room_id, g.date_created
|
||||||
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
|
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
|
||||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||||
}, g.id ASC
|
}, g.id ASC
|
||||||
@@ -206,16 +208,18 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
ownerId: number;
|
userId: number;
|
||||||
ownerUsername: string | null;
|
ownerUsername: string | null;
|
||||||
|
roomId: number;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}>(rowsResult).map((row) => ({
|
}>(rowsResult).map((row) => ({
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
ownerId: Number(row.ownerId),
|
userId: Number(row.userId),
|
||||||
ownerUsername: row.ownerUsername,
|
ownerUsername: row.ownerUsername,
|
||||||
|
roomId: Number(row.roomId),
|
||||||
memberCount: Number(row.memberCount),
|
memberCount: Number(row.memberCount),
|
||||||
createdAt: toPeopleIsoDate(row.createdAt),
|
createdAt: toPeopleIsoDate(row.createdAt),
|
||||||
href: peopleGuildHref(Number(row.id)),
|
href: peopleGuildHref(Number(row.id)),
|
||||||
@@ -232,7 +236,7 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
|||||||
]);
|
]);
|
||||||
const [guildResult, membersResult, threadsResult] = await Promise.all([
|
const [guildResult, membersResult, threadsResult] = await Promise.all([
|
||||||
db.execute(sql`
|
db.execute(sql`
|
||||||
SELECT g.id, g.name, g.description, g.user_id AS ownerId,
|
SELECT g.id, g.name, g.description, g.user_id AS userId,
|
||||||
u.username AS ownerUsername, g.room_id AS roomId,
|
u.username AS ownerUsername, g.room_id AS roomId,
|
||||||
g.date_created AS createdAt, COUNT(gm.id) AS memberCount
|
g.date_created AS createdAt, COUNT(gm.id) AS memberCount
|
||||||
FROM guilds g
|
FROM guilds g
|
||||||
@@ -257,7 +261,7 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
ownerId: number;
|
userId: number;
|
||||||
ownerUsername: string | null;
|
ownerUsername: string | null;
|
||||||
roomId: number;
|
roomId: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -268,7 +272,7 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
|||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
ownerId: Number(row.ownerId),
|
userId: Number(row.userId),
|
||||||
ownerUsername: row.ownerUsername,
|
ownerUsername: row.ownerUsername,
|
||||||
memberCount: Number(row.memberCount),
|
memberCount: Number(row.memberCount),
|
||||||
createdAt: toPeopleIsoDate(row.createdAt),
|
createdAt: toPeopleIsoDate(row.createdAt),
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ describe("People production authorization boundary", () => {
|
|||||||
expect(inboxSource).toContain("UNION ALL");
|
expect(inboxSource).toContain("UNION ALL");
|
||||||
expect(inboxSource).toMatch(/LIMIT \$\{perPage\} OFFSET \$\{offset\}/);
|
expect(inboxSource).toMatch(/LIMIT \$\{perPage\} OFFSET \$\{offset\}/);
|
||||||
expect(inboxSource).toContain(".catch(() => [])");
|
expect(inboxSource).toContain(".catch(() => [])");
|
||||||
|
const deskStart = supportSource.indexOf("async loadTicketDesk");
|
||||||
|
const deskEnd = supportSource.indexOf("async loadTicket(", deskStart);
|
||||||
|
const deskSource = supportSource.slice(deskStart, deskEnd);
|
||||||
|
expect(deskStart).toBeGreaterThan(0);
|
||||||
|
expect(deskSource).toContain("FROM website_tickets t");
|
||||||
|
expect(deskSource).toContain("COUNT(m.id) AS messageCount");
|
||||||
|
expect(deskSource).toContain("t.category");
|
||||||
|
expect(deskSource).toContain("t.priority");
|
||||||
|
expect(deskSource).toContain("t.assignee_id AS assigneeId");
|
||||||
|
expect(deskSource).not.toContain("website_help_center_tickets");
|
||||||
|
expect(supportSource).toContain("COUNT(r.id) AS replyCount");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pages multi-account groups and loads their accounts in one bounded batch", async () => {
|
it("pages multi-account groups and loads their accounts in one bounded batch", async () => {
|
||||||
@@ -162,6 +173,45 @@ describe("People production authorization boundary", () => {
|
|||||||
).not.toContain("WHERE ip_current = ?");
|
).not.toContain("WHERE ip_current = ?");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns the independent multi-account total beyond the last page", 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 (")) {
|
||||||
|
return [[{ total: 4 }]];
|
||||||
|
}
|
||||||
|
if (text.includes("ROW_NUMBER() OVER")) return [[]];
|
||||||
|
return { malformed: true };
|
||||||
|
});
|
||||||
|
vi.doMock("drizzle-orm", () => ({ sql }));
|
||||||
|
vi.doMock("@/lib/db", () => ({ db: { execute } }));
|
||||||
|
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: 100, pageSize: 20 },
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { page: { items: [], total: 4, offset: 100 } },
|
||||||
|
});
|
||||||
|
expect(execute).toHaveBeenCalledTimes(2);
|
||||||
|
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 () => {
|
it("maps a malformed driver result to dependency unavailable", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||||
@@ -334,4 +384,65 @@ describe("People production authorization boundary", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("populates help-ticket reply counts and fails closed on malformed rows", 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("COUNT(*) AS total FROM website_help_center_tickets")) {
|
||||||
|
return [[{ total: 1 }]];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
title: "Help ticket",
|
||||||
|
open: 1,
|
||||||
|
userId: 7,
|
||||||
|
username: "Seven",
|
||||||
|
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
replyCount: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
vi.doMock("drizzle-orm", () => ({ sql }));
|
||||||
|
vi.doMock("@/lib/db", () => ({ db: { execute } }));
|
||||||
|
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.help-tickets",
|
||||||
|
list: {},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { page: { items: [{ id: 12, replyCount: 3 }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("drizzle-orm", () => ({ sql }));
|
||||||
|
vi.doMock("@/lib/db", () => ({
|
||||||
|
db: { execute: vi.fn(async () => ({ malformed: true })) },
|
||||||
|
}));
|
||||||
|
const { peopleSupportQuery: malformedQuery } = await import("./support");
|
||||||
|
const malformed = await malformedQuery.run(capability, {
|
||||||
|
routeId: "people.support.help-tickets",
|
||||||
|
list: {},
|
||||||
|
});
|
||||||
|
expect(malformed).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -283,6 +283,52 @@ describe("People users query", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("People community and staff queries", () => {
|
describe("People community and staff queries", () => {
|
||||||
|
it("accepts only the guild user and room zero sentinels", async () => {
|
||||||
|
const guild = {
|
||||||
|
id: 12,
|
||||||
|
name: "Unassigned guild",
|
||||||
|
description: "",
|
||||||
|
userId: 0,
|
||||||
|
ownerUsername: null,
|
||||||
|
roomId: 0,
|
||||||
|
memberCount: 0,
|
||||||
|
threadCount: 0,
|
||||||
|
createdAt: null,
|
||||||
|
href: "/ase/people/community/guilds/12" as const,
|
||||||
|
members: [],
|
||||||
|
};
|
||||||
|
const query = createPeopleCommunityQuery({
|
||||||
|
loadOnline: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadGuilds: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadGuild: async () => guild as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await query.run(context([PERMS.USERS_VIEW]), {
|
||||||
|
routeId: "people.community.guild-detail",
|
||||||
|
id: 12,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { guild: { id: 12, userId: 0, roomId: 0 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidQuery = createPeopleCommunityQuery({
|
||||||
|
loadOnline: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadGuilds: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadGuild: async () => ({ ...guild, userId: -1 }) as never,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await invalidQuery.run(context([PERMS.USERS_VIEW]), {
|
||||||
|
routeId: "people.community.guild-detail",
|
||||||
|
id: 12,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fails closed when a community adapter returns a corrupt entity id", async () => {
|
it("fails closed when a community adapter returns a corrupt entity id", async () => {
|
||||||
const query = createPeopleCommunityQuery({
|
const query = createPeopleCommunityQuery({
|
||||||
loadOnline: async () => ({ rows: [], total: 0 }),
|
loadOnline: async () => ({ rows: [], total: 0 }),
|
||||||
@@ -292,7 +338,7 @@ describe("People community and staff queries", () => {
|
|||||||
id: 0,
|
id: 0,
|
||||||
name: "Corrupt",
|
name: "Corrupt",
|
||||||
description: "",
|
description: "",
|
||||||
ownerId: -1,
|
userId: -1,
|
||||||
ownerUsername: null,
|
ownerUsername: null,
|
||||||
memberCount: 0,
|
memberCount: 0,
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
@@ -320,8 +366,9 @@ describe("People community and staff queries", () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
name: "Builders",
|
name: "Builders",
|
||||||
description: "Build",
|
description: "Build",
|
||||||
ownerId: 4,
|
userId: 4,
|
||||||
ownerUsername: "Owner",
|
ownerUsername: "Owner",
|
||||||
|
roomId: 7,
|
||||||
memberCount: 3,
|
memberCount: 3,
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
href: "/ase/people/community/guilds/2" as const,
|
href: "/ase/people/community/guilds/2" as const,
|
||||||
@@ -335,7 +382,7 @@ describe("People community and staff queries", () => {
|
|||||||
id: 2,
|
id: 2,
|
||||||
name: "Builders",
|
name: "Builders",
|
||||||
description: "Build",
|
description: "Build",
|
||||||
ownerId: 4,
|
userId: 4,
|
||||||
ownerUsername: "Owner",
|
ownerUsername: "Owner",
|
||||||
memberCount: 3,
|
memberCount: 3,
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
@@ -421,6 +468,103 @@ describe("People community and staff queries", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("People support query", () => {
|
describe("People support query", () => {
|
||||||
|
it("routes the ticket desk to its CMS-only context loader", async () => {
|
||||||
|
const loadTickets = vi.fn(async () => ({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
source: "help" as const,
|
||||||
|
id: 12,
|
||||||
|
subject: "Help row",
|
||||||
|
status: "open",
|
||||||
|
priority: "normal",
|
||||||
|
creatorId: 7,
|
||||||
|
creatorUsername: "Seven",
|
||||||
|
updatedAt: null,
|
||||||
|
href: "/ase/people/support/help-tickets/12" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
}));
|
||||||
|
const loadTicketDesk = vi.fn(async () => ({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
source: "cms" as const,
|
||||||
|
id: 91,
|
||||||
|
subject: "Urgent CMS ticket",
|
||||||
|
status: "open",
|
||||||
|
priority: "urgent",
|
||||||
|
creatorId: 7,
|
||||||
|
creatorUsername: "Seven",
|
||||||
|
updatedAt: null,
|
||||||
|
href: "/ase/people/support/tickets/91" as const,
|
||||||
|
category: "safety",
|
||||||
|
assigneeId: 8,
|
||||||
|
messageCount: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "cms" as const,
|
||||||
|
id: 92,
|
||||||
|
subject: "High-priority CMS ticket",
|
||||||
|
status: "open",
|
||||||
|
priority: "high",
|
||||||
|
creatorId: 9,
|
||||||
|
creatorUsername: "Nine",
|
||||||
|
updatedAt: null,
|
||||||
|
href: "/ase/people/support/tickets/92" as const,
|
||||||
|
category: "account",
|
||||||
|
assigneeId: null,
|
||||||
|
messageCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
}));
|
||||||
|
const query = createPeopleSupportQuery({
|
||||||
|
loadQueue: async () => ({
|
||||||
|
tickets: 1,
|
||||||
|
helpTickets: 1,
|
||||||
|
cfh: 0,
|
||||||
|
activeBans: 0,
|
||||||
|
}),
|
||||||
|
loadTickets,
|
||||||
|
loadTicketDesk,
|
||||||
|
loadTicket: async () => null,
|
||||||
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadHelpTicket: async () => null,
|
||||||
|
loadSupportStaff: async () => [],
|
||||||
|
loadActiveBan: async () => null,
|
||||||
|
} as never);
|
||||||
|
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||||
|
routeId: "people.support.ticket-desk",
|
||||||
|
list: {},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
page: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 91,
|
||||||
|
priority: "urgent",
|
||||||
|
category: "safety",
|
||||||
|
assigneeId: 8,
|
||||||
|
messageCount: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 92,
|
||||||
|
priority: "high",
|
||||||
|
category: "account",
|
||||||
|
assigneeId: null,
|
||||||
|
messageCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loadTicketDesk).toHaveBeenCalledOnce();
|
||||||
|
expect(loadTickets).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("hydrates desk/detail support context and the help-ticket active ban", async () => {
|
it("hydrates desk/detail support context and the help-ticket active ban", async () => {
|
||||||
const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 };
|
const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 };
|
||||||
const staff = [
|
const staff = [
|
||||||
@@ -446,6 +590,7 @@ describe("People support query", () => {
|
|||||||
const query = createPeopleSupportQuery({
|
const query = createPeopleSupportQuery({
|
||||||
loadQueue: async () => queue,
|
loadQueue: async () => queue,
|
||||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
||||||
loadTicket: async () => ({
|
loadTicket: async () => ({
|
||||||
id: 11,
|
id: 11,
|
||||||
subject: "CMS ticket",
|
subject: "CMS ticket",
|
||||||
@@ -454,6 +599,7 @@ describe("People support query", () => {
|
|||||||
creatorId: 7,
|
creatorId: 7,
|
||||||
creatorUsername: "Seven",
|
creatorUsername: "Seven",
|
||||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||||
|
replyCount: 0,
|
||||||
href: "/ase/people/support/tickets/11",
|
href: "/ase/people/support/tickets/11",
|
||||||
category: "general",
|
category: "general",
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
@@ -513,6 +659,7 @@ describe("People support query", () => {
|
|||||||
activeBans: 0,
|
activeBans: 0,
|
||||||
}),
|
}),
|
||||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
||||||
loadTicket: async () =>
|
loadTicket: async () =>
|
||||||
({ id: 0, href: "/ase/people/support/tickets/0" }) as never,
|
({ id: 0, href: "/ase/people/support/tickets/0" }) as never,
|
||||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||||
@@ -556,6 +703,15 @@ describe("People support query", () => {
|
|||||||
activeBans: 0,
|
activeBans: 0,
|
||||||
}),
|
}),
|
||||||
loadTickets: async () => ({ rows, total: 40 }),
|
loadTickets: async () => ({ rows, total: 40 }),
|
||||||
|
loadTicketDesk: async () => ({
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
category: "general",
|
||||||
|
assigneeId: null,
|
||||||
|
messageCount: 0,
|
||||||
|
})),
|
||||||
|
total: 40,
|
||||||
|
}),
|
||||||
loadTicket: async () => null,
|
loadTicket: async () => null,
|
||||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||||
@@ -589,6 +745,7 @@ describe("People support query", () => {
|
|||||||
const query = createPeopleSupportQuery({
|
const query = createPeopleSupportQuery({
|
||||||
loadQueue,
|
loadQueue,
|
||||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
||||||
loadTicket: async () => null,
|
loadTicket: async () => null,
|
||||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||||
@@ -627,6 +784,7 @@ describe("People support query", () => {
|
|||||||
activeBans: 0,
|
activeBans: 0,
|
||||||
}),
|
}),
|
||||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
||||||
loadTicket: async () => null,
|
loadTicket: async () => null,
|
||||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||||
@@ -665,6 +823,71 @@ describe("People support query", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("People moderation query", () => {
|
describe("People moderation query", () => {
|
||||||
|
it("accepts only the CFH participant and room zero sentinels", async () => {
|
||||||
|
const ticket = {
|
||||||
|
id: 41,
|
||||||
|
state: 0,
|
||||||
|
senderId: 0,
|
||||||
|
senderUsername: null,
|
||||||
|
reportedId: 0,
|
||||||
|
reportedUsername: null,
|
||||||
|
moderatorId: 0,
|
||||||
|
issue: "unassigned",
|
||||||
|
createdAt: null,
|
||||||
|
href: "/ase/people/moderation/cfh/41" as const,
|
||||||
|
roomId: 0,
|
||||||
|
activeBan: null,
|
||||||
|
};
|
||||||
|
const adapters = {
|
||||||
|
loadOverview: async () => ({
|
||||||
|
tickets: 0,
|
||||||
|
helpTickets: 0,
|
||||||
|
cfh: 0,
|
||||||
|
activeBans: 0,
|
||||||
|
staffOnline: 0,
|
||||||
|
recentActions: 0,
|
||||||
|
}),
|
||||||
|
loadCfh: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadCfhDetail: async () => ticket,
|
||||||
|
loadBans: async () => ({ rows: [], total: 0 }),
|
||||||
|
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
||||||
|
loadVpnSettings: async () => [],
|
||||||
|
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
||||||
|
};
|
||||||
|
const query = createPeopleModerationQuery(adapters);
|
||||||
|
expect(
|
||||||
|
await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
||||||
|
routeId: "people.moderation.cfh-detail",
|
||||||
|
id: 41,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
ticket: {
|
||||||
|
id: 41,
|
||||||
|
senderId: 0,
|
||||||
|
reportedId: 0,
|
||||||
|
moderatorId: 0,
|
||||||
|
roomId: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidQuery = createPeopleModerationQuery({
|
||||||
|
...adapters,
|
||||||
|
loadCfhDetail: async () => ({ ...ticket, reportedId: -1 }),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await invalidQuery.run(context([PERMS.MOD_CFH_VIEW]), {
|
||||||
|
routeId: "people.moderation.cfh-detail",
|
||||||
|
id: 41,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("declares exactly the same eleven-permission union as the moderation overview route", () => {
|
it("declares exactly the same eleven-permission union as the moderation overview route", () => {
|
||||||
const query = createPeopleModerationQuery({
|
const query = createPeopleModerationQuery({
|
||||||
loadOverview: async () => ({
|
loadOverview: async () => ({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type PeopleHelpTicketSummary,
|
type PeopleHelpTicketSummary,
|
||||||
type PeopleQueueSnapshot,
|
type PeopleQueueSnapshot,
|
||||||
type PeopleSupportStaff,
|
type PeopleSupportStaff,
|
||||||
|
type PeopleTicketDeskSummary,
|
||||||
type PeopleTicketDetail,
|
type PeopleTicketDetail,
|
||||||
type PeopleTicketSummary,
|
type PeopleTicketSummary,
|
||||||
type PeopleTicketTemplate,
|
type PeopleTicketTemplate,
|
||||||
@@ -43,6 +44,9 @@ export interface PeopleSupportAdapters {
|
|||||||
loadTickets(
|
loadTickets(
|
||||||
input: ReturnType<typeof normalizePeopleListInput>,
|
input: ReturnType<typeof normalizePeopleListInput>,
|
||||||
): Promise<SupportRows<PeopleTicketSummary>>;
|
): Promise<SupportRows<PeopleTicketSummary>>;
|
||||||
|
loadTicketDesk(
|
||||||
|
input: ReturnType<typeof normalizePeopleListInput>,
|
||||||
|
): Promise<SupportRows<PeopleTicketDeskSummary>>;
|
||||||
loadTicket(id: number): Promise<PeopleTicketRecord | null>;
|
loadTicket(id: number): Promise<PeopleTicketRecord | null>;
|
||||||
loadTemplates(
|
loadTemplates(
|
||||||
input: ReturnType<typeof normalizePeopleListInput>,
|
input: ReturnType<typeof normalizePeopleListInput>,
|
||||||
@@ -78,7 +82,7 @@ export type PeopleSupportQueryData =
|
|||||||
| { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> }
|
| { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> }
|
||||||
| {
|
| {
|
||||||
readonly kind: "ticket-desk";
|
readonly kind: "ticket-desk";
|
||||||
readonly page: Page<PeopleTicketSummary>;
|
readonly page: Page<PeopleTicketDeskSummary>;
|
||||||
readonly queue: PeopleQueueSnapshot;
|
readonly queue: PeopleQueueSnapshot;
|
||||||
readonly staff: readonly PeopleSupportStaff[];
|
readonly staff: readonly PeopleSupportStaff[];
|
||||||
}
|
}
|
||||||
@@ -222,7 +226,7 @@ export function createPeopleSupportQuery(
|
|||||||
|
|
||||||
if (input.routeId === "people.support.ticket-desk") {
|
if (input.routeId === "people.support.ticket-desk") {
|
||||||
const [result, queue, staff] = await Promise.all([
|
const [result, queue, staff] = await Promise.all([
|
||||||
adapters.loadTickets(list),
|
adapters.loadTicketDesk(list),
|
||||||
adapters.loadQueue(),
|
adapters.loadQueue(),
|
||||||
adapters.loadSupportStaff(),
|
adapters.loadSupportStaff(),
|
||||||
]);
|
]);
|
||||||
@@ -324,6 +328,69 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
total: result.total,
|
total: result.total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async loadTicketDesk(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 t.category LIKE ${pattern} OR creator.username LIKE ${pattern}`
|
||||||
|
: sql``;
|
||||||
|
const [rowsResult, countResult] = await Promise.all([
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT t.id, t.subject, t.category, t.priority, t.status,
|
||||||
|
t.creator_id AS creatorId, creator.username AS creatorUsername,
|
||||||
|
t.assignee_id AS assigneeId, t.updated_at AS updatedAt,
|
||||||
|
COUNT(m.id) AS messageCount
|
||||||
|
FROM website_tickets t
|
||||||
|
LEFT JOIN users creator ON creator.id = t.creator_id
|
||||||
|
LEFT JOIN website_ticket_messages m ON m.ticket_id = t.id
|
||||||
|
${where}
|
||||||
|
GROUP BY t.id, t.subject, t.category, t.priority, t.status,
|
||||||
|
t.creator_id, creator.username, t.assignee_id, t.updated_at
|
||||||
|
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.pageSize} OFFSET ${input.offset}
|
||||||
|
`),
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT COUNT(*) AS total FROM website_tickets t
|
||||||
|
LEFT JOIN users creator ON creator.id = t.creator_id
|
||||||
|
${where}
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
const rows = resultRows<{
|
||||||
|
id: number;
|
||||||
|
subject: string;
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
creatorId: number;
|
||||||
|
creatorUsername: string | null;
|
||||||
|
assigneeId: number | null;
|
||||||
|
updatedAt: Date | string | null;
|
||||||
|
messageCount: number | bigint;
|
||||||
|
}>(rowsResult).map((row) => ({
|
||||||
|
source: "cms" as const,
|
||||||
|
id: Number(row.id),
|
||||||
|
subject: row.subject,
|
||||||
|
category: row.category,
|
||||||
|
priority: row.priority,
|
||||||
|
status: row.status,
|
||||||
|
creatorId: Number(row.creatorId),
|
||||||
|
creatorUsername: row.creatorUsername,
|
||||||
|
assigneeId: row.assigneeId === null ? null : Number(row.assigneeId),
|
||||||
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||||
|
messageCount: Number(row.messageCount),
|
||||||
|
href: peopleTicketHref(Number(row.id)),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
async loadTicket(id) {
|
async loadTicket(id) {
|
||||||
const [{ sql }, { db }] = await Promise.all([
|
const [{ sql }, { db }] = await Promise.all([
|
||||||
import("drizzle-orm"),
|
import("drizzle-orm"),
|
||||||
@@ -435,10 +502,12 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
const [rowsResult, countResult] = await Promise.all([
|
const [rowsResult, countResult] = await Promise.all([
|
||||||
db.execute(sql`
|
db.execute(sql`
|
||||||
SELECT t.id, t.title, t.open, t.user_id AS userId, u.username,
|
SELECT t.id, t.title, t.open, t.user_id AS userId, u.username,
|
||||||
t.updated_at AS updatedAt
|
t.updated_at AS updatedAt, COUNT(r.id) AS replyCount
|
||||||
FROM website_help_center_tickets t
|
FROM website_help_center_tickets t
|
||||||
LEFT JOIN users u ON u.id = t.user_id
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
|
LEFT JOIN website_help_center_ticket_replies r ON r.ticket_id = t.id
|
||||||
${where}
|
${where}
|
||||||
|
GROUP BY t.id, t.title, t.open, t.user_id, u.username, t.updated_at
|
||||||
ORDER BY ${input.sort === "title" ? sql`t.title` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${
|
ORDER BY ${input.sort === "title" ? sql`t.title` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${
|
||||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||||
}, t.id ASC
|
}, t.id ASC
|
||||||
@@ -456,6 +525,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
userId: number | null;
|
userId: number | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
updatedAt: Date | string | null;
|
updatedAt: Date | string | null;
|
||||||
|
replyCount: number | bigint;
|
||||||
}>(rowsResult).map((row) => ({
|
}>(rowsResult).map((row) => ({
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
title: row.title,
|
title: row.title,
|
||||||
@@ -463,6 +533,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
userId: row.userId === null ? null : Number(row.userId),
|
userId: row.userId === null ? null : Number(row.userId),
|
||||||
username: row.username,
|
username: row.username,
|
||||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||||
|
replyCount: Number(row.replyCount),
|
||||||
href: peopleHelpTicketHref(Number(row.id)),
|
href: peopleHelpTicketHref(Number(row.id)),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
@@ -517,6 +588,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = {
|
|||||||
categoryId: row.categoryId === null ? null : Number(row.categoryId),
|
categoryId: row.categoryId === null ? null : Number(row.categoryId),
|
||||||
categoryName: row.categoryName,
|
categoryName: row.categoryName,
|
||||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||||
|
replyCount: resultRows<unknown>(repliesResult).length,
|
||||||
href: peopleHelpTicketHref(Number(row.id)),
|
href: peopleHelpTicketHref(Number(row.id)),
|
||||||
replies: resultRows<{
|
replies: resultRows<{
|
||||||
id: bigint | number;
|
id: bigint | number;
|
||||||
|
|||||||
@@ -385,16 +385,16 @@ const peopleUsersAdapters: PeopleUsersAdapters = {
|
|||||||
const sortColumn =
|
const sortColumn =
|
||||||
input.sort === "accountCount" ? sql`accountCount` : sql`ipCurrent`;
|
input.sort === "accountCount" ? sql`accountCount` : sql`ipCurrent`;
|
||||||
const direction = input.order === "desc" ? sql`DESC` : sql`ASC`;
|
const direction = input.order === "desc" ? sql`DESC` : sql`ASC`;
|
||||||
const result = await db.execute(sql`
|
const [result, countResult] = await Promise.all([
|
||||||
|
db.execute(sql`
|
||||||
WITH grouped AS (
|
WITH grouped AS (
|
||||||
SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount,
|
SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount
|
||||||
COUNT(*) OVER () AS total
|
|
||||||
FROM users
|
FROM users
|
||||||
WHERE ip_current <> '' ${search}
|
WHERE ip_current <> '' ${search}
|
||||||
GROUP BY ip_current
|
GROUP BY ip_current
|
||||||
HAVING COUNT(*) >= 2
|
HAVING COUNT(*) >= 2
|
||||||
), paged AS (
|
), paged AS (
|
||||||
SELECT ipCurrent, accountCount, total
|
SELECT ipCurrent, accountCount
|
||||||
FROM grouped
|
FROM grouped
|
||||||
ORDER BY ${sortColumn} ${direction}, ipCurrent ASC
|
ORDER BY ${sortColumn} ${direction}, ipCurrent ASC
|
||||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||||
@@ -404,17 +404,26 @@ const peopleUsersAdapters: PeopleUsersAdapters = {
|
|||||||
FROM users u
|
FROM users u
|
||||||
INNER JOIN paged p ON p.ipCurrent = u.ip_current
|
INNER JOIN paged p ON p.ipCurrent = u.ip_current
|
||||||
)
|
)
|
||||||
SELECT p.ipCurrent, p.accountCount, p.total,
|
SELECT p.ipCurrent, p.accountCount,
|
||||||
a.id, a.username, a.rank, a.online
|
a.id, a.username, a.rank, a.online
|
||||||
FROM paged p
|
FROM paged p
|
||||||
INNER JOIN ranked_accounts a ON a.ipCurrent = p.ipCurrent
|
INNER JOIN ranked_accounts a ON a.ipCurrent = p.ipCurrent
|
||||||
WHERE a.accountPosition <= 100
|
WHERE a.accountPosition <= 100
|
||||||
ORDER BY ${sortColumn} ${direction}, p.ipCurrent ASC, a.id ASC
|
ORDER BY ${sortColumn} ${direction}, p.ipCurrent ASC, a.id ASC
|
||||||
`);
|
`),
|
||||||
|
db.execute(sql`
|
||||||
|
SELECT COUNT(*) AS total FROM (
|
||||||
|
SELECT ip_current
|
||||||
|
FROM users
|
||||||
|
WHERE ip_current <> '' ${search}
|
||||||
|
GROUP BY ip_current
|
||||||
|
HAVING COUNT(*) >= 2
|
||||||
|
) AS matching_clusters
|
||||||
|
`),
|
||||||
|
]);
|
||||||
const records = resultRows<{
|
const records = resultRows<{
|
||||||
ipCurrent: string;
|
ipCurrent: string;
|
||||||
accountCount: number | bigint;
|
accountCount: number | bigint;
|
||||||
total: number | bigint;
|
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -440,9 +449,14 @@ const peopleUsersAdapters: PeopleUsersAdapters = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const totalRow = resultRows<{ total: number | bigint }>(countResult)[0];
|
||||||
|
const total = Number(totalRow?.total);
|
||||||
|
if (!Number.isSafeInteger(total) || total < 0) {
|
||||||
|
throw new Error("invalid People multi-account total");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
rows: [...clusters.values()],
|
rows: [...clusters.values()],
|
||||||
total: Number(records[0]?.total ?? 0),
|
total,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async loadSanctions(userId) {
|
async loadSanctions(userId) {
|
||||||
|
|||||||
Reference in new issue
Block a user