From 7920d4f46cf19c1bf3de77faf9d1b86d5220356c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 29 Aug 2026 10:34:44 +0200 Subject: [PATCH] fix(housekeeping): complete people read fidelity --- .../task-11-report.md | 83 ++++++- .../domains/people/models.test.ts | 8 + .../housekeeping/domains/people/models.ts | 42 +++- .../domains/people/queries/community.ts | 20 +- .../people-adapters-production.test.ts | 111 +++++++++ .../people/queries/people-queries.test.ts | 229 +++++++++++++++++- .../domains/people/queries/support.ts | 78 +++++- .../domains/people/queries/users.ts | 30 ++- 8 files changed, 564 insertions(+), 37 deletions(-) diff --git a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md index 227ea01c..065f2541 100644 --- a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-11-report.md @@ -1,6 +1,6 @@ # Task 11 — People workflow read models -Status: DONE — fix round 1 +Status: DONE — fix round 2 ## 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. - 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. 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. - 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. -- 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. ## 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. +## 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 ### Cycle 1 — exact route catalog @@ -253,27 +259,86 @@ Test Files 1 passed (1) 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 ```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 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 Test Files 9 passed (9) -Tests 81 passed (81) +Tests 86 passed (86) pnpm test:housekeeping Test Files 49 passed (49) -Tests 430 passed (430) +Tests 435 passed (435) pnpm typecheck tsc --noEmit Exit 0 -pnpm exec biome check --formatter-enabled=false <11 exact changed Task 11 TypeScript files> -Checked 11 files. No fixes applied. +pnpm exec biome check --formatter-enabled=false <7 exact changed Task 11 TypeScript files> +Checked 7 files. No fixes applied. git diff --check Exit 0 diff --git a/src/features/housekeeping/domains/people/models.test.ts b/src/features/housekeeping/domains/people/models.test.ts index 93ea66e7..03b6eed4 100644 --- a/src/features/housekeeping/domains/people/models.test.ts +++ b/src/features/housekeeping/domains/people/models.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + assertPeopleSerializable, createPeoplePage, normalizePeopleListInput, normalizePeopleUser, @@ -171,5 +172,12 @@ describe("People canonical models", () => { expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow(); expect(() => peopleGuildHref(-4)).toThrow(); expect(() => toPeopleIsoDate("not-a-date")).toThrow(); + expect(() => + assertPeopleSerializable({ + id: 7, + userId: 0, + href: "/ase/people/users/7", + }), + ).toThrow(); }); }); diff --git a/src/features/housekeeping/domains/people/models.ts b/src/features/housekeeping/domains/people/models.ts index 0091405b..836800e6 100644 --- a/src/features/housekeeping/domains/people/models.ts +++ b/src/features/housekeeping/domains/people/models.ts @@ -92,15 +92,15 @@ export interface PeopleGuildSummary { readonly id: number; readonly name: string; readonly description: string; - readonly ownerId: number; + readonly userId: number; readonly ownerUsername: string | null; + readonly roomId: number; readonly memberCount: number; readonly createdAt: string | null; readonly href: `/ase/people/community/guilds/${number}`; } export interface PeopleGuildDetail extends PeopleGuildSummary { - readonly roomId: number; readonly threadCount: number; readonly members: readonly { readonly id: number; @@ -166,6 +166,13 @@ export interface PeopleTicketSummary { | `/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 { readonly category: string; readonly assigneeId: number | null; @@ -188,6 +195,7 @@ export interface PeopleHelpTicketSummary { readonly userId: number | null; readonly username: string | null; readonly updatedAt: string | null; + readonly replyCount: number; readonly href: `/ase/people/support/help-tickets/${number}`; } @@ -462,7 +470,25 @@ const DATE_KEYS = new Set([ ]); 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 ( current === null || typeof current === "string" || @@ -480,13 +506,17 @@ export function assertPeopleSerializable(value: unknown): void { if (typeof current === "number") { if (!Number.isSafeInteger(current)) 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"); } return; } if (Array.isArray(current)) { - for (const item of current) visit(item); + for (const item of current) visit(item, "", current); return; } 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)) { if (child === undefined) throw new Error("undefined People value"); - visit(child, childKey); + visit(child, childKey, current); } } visit(value); diff --git a/src/features/housekeeping/domains/people/queries/community.ts b/src/features/housekeeping/domains/people/queries/community.ts index 11c912d0..0f9ca83c 100644 --- a/src/features/housekeeping/domains/people/queries/community.ts +++ b/src/features/housekeeping/domains/people/queries/community.ts @@ -183,14 +183,16 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = { : sql``; const [rowsResult, countResult] = await Promise.all([ db.execute(sql` - SELECT g.id, g.name, g.description, g.user_id AS ownerId, - u.username AS ownerUsername, g.date_created AS createdAt, + SELECT g.id, g.name, g.description, g.user_id AS userId, + u.username AS ownerUsername, g.room_id AS roomId, + g.date_created AS createdAt, COUNT(gm.id) AS memberCount FROM guilds g LEFT JOIN users u ON u.id = g.user_id LEFT JOIN guilds_members gm ON gm.guild_id = g.id ${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`} ${ input.order === "desc" ? sql`DESC` : sql`ASC` }, g.id ASC @@ -206,16 +208,18 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = { id: number; name: string; description: string; - ownerId: number; + userId: number; ownerUsername: string | null; + roomId: number; memberCount: number; createdAt: number; }>(rowsResult).map((row) => ({ id: Number(row.id), name: row.name, description: row.description, - ownerId: Number(row.ownerId), + userId: Number(row.userId), ownerUsername: row.ownerUsername, + roomId: Number(row.roomId), memberCount: Number(row.memberCount), createdAt: toPeopleIsoDate(row.createdAt), href: peopleGuildHref(Number(row.id)), @@ -232,7 +236,7 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = { ]); const [guildResult, membersResult, threadsResult] = await Promise.all([ 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, g.date_created AS createdAt, COUNT(gm.id) AS memberCount FROM guilds g @@ -257,7 +261,7 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = { id: number; name: string; description: string; - ownerId: number; + userId: number; ownerUsername: string | null; roomId: number; createdAt: number; @@ -268,7 +272,7 @@ const peopleCommunityAdapters: PeopleCommunityAdapters = { id: Number(row.id), name: row.name, description: row.description, - ownerId: Number(row.ownerId), + userId: Number(row.userId), ownerUsername: row.ownerUsername, memberCount: Number(row.memberCount), createdAt: toPeopleIsoDate(row.createdAt), diff --git a/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts index bb34e0f2..9bffbefa 100644 --- a/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts +++ b/src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts @@ -52,6 +52,17 @@ describe("People production authorization boundary", () => { expect(inboxSource).toContain("UNION ALL"); expect(inboxSource).toMatch(/LIMIT \$\{perPage\} OFFSET \$\{offset\}/); 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 () => { @@ -162,6 +173,45 @@ describe("People production authorization boundary", () => { ).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([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 () => { vi.resetModules(); 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([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" }, + }); + }); }); diff --git a/src/features/housekeeping/domains/people/queries/people-queries.test.ts b/src/features/housekeeping/domains/people/queries/people-queries.test.ts index d35f995d..a74d34a1 100644 --- a/src/features/housekeeping/domains/people/queries/people-queries.test.ts +++ b/src/features/housekeeping/domains/people/queries/people-queries.test.ts @@ -283,6 +283,52 @@ describe("People users query", () => { }); 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 () => { const query = createPeopleCommunityQuery({ loadOnline: async () => ({ rows: [], total: 0 }), @@ -292,7 +338,7 @@ describe("People community and staff queries", () => { id: 0, name: "Corrupt", description: "", - ownerId: -1, + userId: -1, ownerUsername: null, memberCount: 0, createdAt: null, @@ -320,8 +366,9 @@ describe("People community and staff queries", () => { id: 2, name: "Builders", description: "Build", - ownerId: 4, + userId: 4, ownerUsername: "Owner", + roomId: 7, memberCount: 3, createdAt: "2026-08-01T00:00:00.000Z", href: "/ase/people/community/guilds/2" as const, @@ -335,7 +382,7 @@ describe("People community and staff queries", () => { id: 2, name: "Builders", description: "Build", - ownerId: 4, + userId: 4, ownerUsername: "Owner", memberCount: 3, createdAt: "2026-08-01T00:00:00.000Z", @@ -421,6 +468,103 @@ describe("People community and staff queries", () => { }); 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 () => { const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 }; const staff = [ @@ -446,6 +590,7 @@ describe("People support query", () => { const query = createPeopleSupportQuery({ loadQueue: async () => queue, loadTickets: async () => ({ rows: [], total: 0 }), + loadTicketDesk: async () => ({ rows: [], total: 0 }), loadTicket: async () => ({ id: 11, subject: "CMS ticket", @@ -454,6 +599,7 @@ describe("People support query", () => { creatorId: 7, creatorUsername: "Seven", updatedAt: "2026-08-20T00:00:00.000Z", + replyCount: 0, href: "/ase/people/support/tickets/11", category: "general", assigneeId: null, @@ -513,6 +659,7 @@ describe("People support query", () => { activeBans: 0, }), loadTickets: async () => ({ rows: [], total: 0 }), + loadTicketDesk: async () => ({ rows: [], total: 0 }), loadTicket: async () => ({ id: 0, href: "/ase/people/support/tickets/0" }) as never, loadTemplates: async () => ({ rows: [], total: 0 }), @@ -556,6 +703,15 @@ describe("People support query", () => { activeBans: 0, }), loadTickets: async () => ({ rows, total: 40 }), + loadTicketDesk: async () => ({ + rows: rows.map((row) => ({ + ...row, + category: "general", + assigneeId: null, + messageCount: 0, + })), + total: 40, + }), loadTicket: async () => null, loadTemplates: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }), @@ -589,6 +745,7 @@ describe("People support query", () => { const query = createPeopleSupportQuery({ loadQueue, loadTickets: async () => ({ rows: [], total: 0 }), + loadTicketDesk: async () => ({ rows: [], total: 0 }), loadTicket: async () => null, loadTemplates: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }), @@ -627,6 +784,7 @@ describe("People support query", () => { activeBans: 0, }), loadTickets: async () => ({ rows: [], total: 0 }), + loadTicketDesk: async () => ({ rows: [], total: 0 }), loadTicket: async () => null, loadTemplates: async () => ({ rows: [], total: 0 }), loadHelpTickets: async () => ({ rows: [], total: 0 }), @@ -665,6 +823,71 @@ describe("People support 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", () => { const query = createPeopleModerationQuery({ loadOverview: async () => ({ diff --git a/src/features/housekeeping/domains/people/queries/support.ts b/src/features/housekeeping/domains/people/queries/support.ts index 0d8d55d9..760693ec 100644 --- a/src/features/housekeeping/domains/people/queries/support.ts +++ b/src/features/housekeeping/domains/people/queries/support.ts @@ -19,6 +19,7 @@ import { type PeopleHelpTicketSummary, type PeopleQueueSnapshot, type PeopleSupportStaff, + type PeopleTicketDeskSummary, type PeopleTicketDetail, type PeopleTicketSummary, type PeopleTicketTemplate, @@ -43,6 +44,9 @@ export interface PeopleSupportAdapters { loadTickets( input: ReturnType, ): Promise>; + loadTicketDesk( + input: ReturnType, + ): Promise>; loadTicket(id: number): Promise; loadTemplates( input: ReturnType, @@ -78,7 +82,7 @@ export type PeopleSupportQueryData = | { readonly kind: "tickets"; readonly page: Page } | { readonly kind: "ticket-desk"; - readonly page: Page; + readonly page: Page; readonly queue: PeopleQueueSnapshot; readonly staff: readonly PeopleSupportStaff[]; } @@ -222,7 +226,7 @@ export function createPeopleSupportQuery( if (input.routeId === "people.support.ticket-desk") { const [result, queue, staff] = await Promise.all([ - adapters.loadTickets(list), + adapters.loadTicketDesk(list), adapters.loadQueue(), adapters.loadSupportStaff(), ]); @@ -324,6 +328,69 @@ const peopleSupportAdapters: PeopleSupportAdapters = { 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) { const [{ sql }, { db }] = await Promise.all([ import("drizzle-orm"), @@ -435,10 +502,12 @@ const peopleSupportAdapters: PeopleSupportAdapters = { const [rowsResult, countResult] = await Promise.all([ db.execute(sql` 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 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} + 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`} ${ input.order === "asc" ? sql`ASC` : sql`DESC` }, t.id ASC @@ -456,6 +525,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = { userId: number | null; username: string | null; updatedAt: Date | string | null; + replyCount: number | bigint; }>(rowsResult).map((row) => ({ id: Number(row.id), title: row.title, @@ -463,6 +533,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = { userId: row.userId === null ? null : Number(row.userId), username: row.username, updatedAt: toPeopleIsoDate(row.updatedAt), + replyCount: Number(row.replyCount), href: peopleHelpTicketHref(Number(row.id)), })); return { @@ -517,6 +588,7 @@ const peopleSupportAdapters: PeopleSupportAdapters = { categoryId: row.categoryId === null ? null : Number(row.categoryId), categoryName: row.categoryName, updatedAt: toPeopleIsoDate(row.updatedAt), + replyCount: resultRows(repliesResult).length, href: peopleHelpTicketHref(Number(row.id)), replies: resultRows<{ id: bigint | number; diff --git a/src/features/housekeeping/domains/people/queries/users.ts b/src/features/housekeeping/domains/people/queries/users.ts index 83d20cd3..addcbd04 100644 --- a/src/features/housekeeping/domains/people/queries/users.ts +++ b/src/features/housekeeping/domains/people/queries/users.ts @@ -385,16 +385,16 @@ const peopleUsersAdapters: PeopleUsersAdapters = { const sortColumn = input.sort === "accountCount" ? sql`accountCount` : sql`ipCurrent`; 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 ( - SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount, - COUNT(*) OVER () AS total + SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount FROM users WHERE ip_current <> '' ${search} GROUP BY ip_current HAVING COUNT(*) >= 2 ), paged AS ( - SELECT ipCurrent, accountCount, total + SELECT ipCurrent, accountCount FROM grouped ORDER BY ${sortColumn} ${direction}, ipCurrent ASC LIMIT ${input.pageSize} OFFSET ${input.offset} @@ -404,17 +404,26 @@ const peopleUsersAdapters: PeopleUsersAdapters = { FROM users u 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 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 - `); + `), + 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<{ ipCurrent: string; accountCount: number | bigint; - total: number | bigint; id: number; username: string; 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 { rows: [...clusters.values()], - total: Number(records[0]?.total ?? 0), + total, }; }, async loadSanctions(userId) {