From 7106753ed3a23dac9ac6ee040bcfc5d01753b688 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Wed, 9 Sep 2026 16:31:13 +0530 Subject: [PATCH] fix(015-reporting-dashboards): count humanEscalated by Assignment existence, not status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManagementRepository/ProductReportRepository.countEverEscalatedToHuman checked a list of terminal statuses that ticket-state-machine.ts's own transition table shows are reachable from BOTH the AI-resolved path and the human-escalation path once they converge (RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED). Every AI-resolved ticket was being double-counted as human-escalated too — confirmed live against real seeded dev data (humanEscalated: 34 out of totalCases: 34, an impossible 100%). Fixed by keying off assignments: { some: {} } instead, since orchestrationService.handleHumanEscalation is the only code path that ever creates an Assignment row. Updated management-dashboard.test.ts's own human-resolved fixture to create a real Assignment row, since it previously relied on the now-fixed buggy status-based signal without one. Found via manual verification against a real running dev server while building supporthub-web's 002-reporting-dashboards-ui, not by any existing automated test. Co-Authored-By: Claude Sonnet 5 --- .../repository/management.repository.ts | 29 ++++++++++--------- .../reports/repository/product.repository.ts | 17 +++-------- .../management-dashboard.test.ts | 11 ++++++- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/modules/platform/reports/repository/management.repository.ts b/src/modules/platform/reports/repository/management.repository.ts index 4f57a3e..62f183e 100644 --- a/src/modules/platform/reports/repository/management.repository.ts +++ b/src/modules/platform/reports/repository/management.repository.ts @@ -16,23 +16,26 @@ export class ManagementRepository { }); } - /** Ever reached HUMAN_ESCALATION — the state machine (003-ticketing) makes this a one-way - * gate, so a ticket currently past it (IN_PROGRESS, WAITING_FOR_CUSTOMER, etc.) still counts. */ + /** + * Ever escalated to a human. NOT a current-status check: 003-ticketing's own state machine + * lets both the AI path (AI_RESOLVED) and the human path (HUMAN_ESCALATION) converge on the + * same shared terminal statuses (RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED are + * all reachable from AI_RESOLVED directly, per ticket-state-machine.ts's own transition + * table) — a status-list check over those shared statuses would count every AI-resolved + * ticket as "human escalated" too (caught via manual verification against real seeded data, + * not by any test fixture, since every existing test's fixtures happened to keep the two + * paths' terminal statuses apart). + * + * The unambiguous, direct signal instead: 007-orchestration-assignment's own + * `orchestrationService.handleHumanEscalation` is the *only* code path that ever creates an + * `Assignment` row (research.md's own module map) — a ticket has one if and only if it was + * actually escalated to a human at some point, regardless of its current status. + */ async countEverEscalatedToHuman(range: DateRange): Promise { return this.prisma.ticket.count({ where: { createdAt: { gte: range.from, lte: range.to }, - status: { - in: [ - 'HUMAN_ESCALATION', - 'IN_PROGRESS', - 'WAITING_FOR_CUSTOMER', - 'RESOLUTION_PENDING_CUSTOMER', - 'RESOLVED', - 'CLOSED', - 'REOPENED', - ], - }, + assignments: { some: {} }, }, }); } diff --git a/src/modules/platform/reports/repository/product.repository.ts b/src/modules/platform/reports/repository/product.repository.ts index bfb4870..a7be5e4 100644 --- a/src/modules/platform/reports/repository/product.repository.ts +++ b/src/modules/platform/reports/repository/product.repository.ts @@ -40,24 +40,15 @@ export class ProductReportRepository { }); } - /** Same "ever reached HUMAN_ESCALATION" one-way-gate logic as - * ManagementRepository.countEverEscalatedToHuman, scoped to one product. */ + /** Same fixed "has at least one Assignment row" signal as + * ManagementRepository.countEverEscalatedToHuman (see its own comment for why a current-status + * check is wrong), scoped to one product. */ async countEverEscalatedToHuman(productId: string, range: DateRange): Promise { return this.prisma.ticket.count({ where: { productId, createdAt: { gte: range.from, lte: range.to }, - status: { - in: [ - 'HUMAN_ESCALATION', - 'IN_PROGRESS', - 'WAITING_FOR_CUSTOMER', - 'RESOLUTION_PENDING_CUSTOMER', - 'RESOLVED', - 'CLOSED', - 'REOPENED', - ], - }, + assignments: { some: {} }, }, }); } diff --git a/tests/integration/platform-reports/management-dashboard.test.ts b/tests/integration/platform-reports/management-dashboard.test.ts index 984d024..881437f 100644 --- a/tests/integration/platform-reports/management-dashboard.test.ts +++ b/tests/integration/platform-reports/management-dashboard.test.ts @@ -98,8 +98,17 @@ describe('Management dashboard (User Story 1)', () => { await resolutionRepository.create({ ticketId: aiTicketId, outcome: 'fixed', resolvedBy: 'ai' }); await driveDirectly(aiTicketId, ['RESOLVED']); - // Human-resolved ticket, with a first agent response recorded. + // Human-resolved ticket, with a first agent response recorded and a real Assignment row — + // "ever escalated to a human" is keyed off Assignment existence (see + // ManagementRepository.countEverEscalatedToHuman's own comment on why a ticket's current + // status can't distinguish the AI path from the human path once both converge on the same + // shared terminal statuses). + const team = await prismaClient.team.create({ data: { name: `Mgmt Report Team ${Date.now()}` } }); + const agent = await prismaClient.agent.create({ data: { teamId: team.id, name: 'Mgmt Report Agent' } }); const humanTicketId = await createTicket(); + await prismaClient.assignment.create({ + data: { ticketId: humanTicketId, agentId: agent.id, strategy: 'MANUAL', isCurrent: true }, + }); await messagesService.post(humanTicketId, 'agent-1', 'AGENT_MESSAGE', 'Looking into this.'); await driveDirectly(humanTicketId, [ 'HUMAN_ESCALATION',