Files
support_backend/specs/015-reporting-dashboards/research.md
T
saqib mirandClaude Sonnet 5 4a159725c2 docs(015-reporting-dashboards): plan, research, data model, contract, quickstart
Documents the exact Prisma query per dashboard figure, the one new
durable table this feature needs (ErrorCodeLookup — 014's own equivalent
metric is process-lifetime, unusable for a historical report), the
"no data -> null, never NaN" convention, and why the AI dashboard's
confidence distribution deliberately uses the system-default threshold
rather than resolving a per-diagnosis policy (AIDiagnosis has no
reliable FK back to which policy applied).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:14:29 +05:30

10 KiB

Research: Reporting and Analytics Dashboards

1. Where this lives

Decision: Wire up the existing, pre-scaffolded src/modules/platform/reports module (today just ReportsService.generateSummaryReport() returning {}, confirmed unused anywhere) rather than creating a new module. Its four real methods (getManagementDashboard, getProductDashboard, getSupportDashboard, getAiDashboard) replace the one stub method. Routes live at GET /admin/reports/management, GET /admin/reports/product/:externalProductId, GET /admin/reports/support, GET /admin/reports/ai, admin-gated the same way every other admin-only endpoint since 010-identity-auth already is (requireRole('ADMIN')).

Why not the ANALYTICS queue (src/jobs/analytics, also pre-scaffolded, also inert): a queued background job fits pre-computing a report nobody is currently waiting on; a dashboard request is someone waiting right now for an answer. Per spec.md's Assumptions, this first cut is synchronous, direct-query aggregation — the queue stub stays exactly as inert as it already was, untouched by this feature.

2. Per-dashboard queries

All four use Prisma's groupBy/count/aggregate, scoped by createdAt (or the milestone-specific timestamp named below) within [from, to], computed directly against the tables that already own each fact — no new table, no denormalized rollup.

Management (FR-001)

Figure Source
Total cases Ticket.count({ createdAt in range })
AI resolved Ticket.count({ createdAt in range, status: 'AI_RESOLVED' }) — a ticket that reached AI_RESOLVED and stayed there or moved straight to RESOLVED without a Resolution.resolvedBy other than 'ai'; see §4 below for the exact "who resolved it" rule shared with the Product dashboard
Human escalated Ticket.count({ createdAt in range, status in [HUMAN_ESCALATION, IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED] }) minus AI-only-path tickets — i.e. any ticket that ever reached HUMAN_ESCALATION; the state machine research.md already establishes this as one-way (003-ticketing)
Resolved (either path) Ticket.count({ createdAt in range, status in [RESOLVED, CLOSED] })
Open Ticket.count({ createdAt in range, status not in [RESOLVED, CLOSED] })
SLA compliance / breach count SLARun.groupBy(['status'], { ticket: { createdAt in range } }), status: 'completed' = met, 'breached' = breached (mirrors 014's own metric semantics — see 014 research.md §5's "read status before the overwrite" caveat, which applies equally here: a 'breached'-then-'completed' run is still counted breached, by reading the breachedAt/firstResponseBreachedAt timestamps rather than only the current status string)
Escalation count EscalationEvent.count({ createdAt in range })
Average response time avg(firstAgentMessage.createdAt - ticket.createdAt) over tickets with at least one AGENT_MESSAGE in range — computed in application code over a bounded query result (see §5, no raw SQL)
Average resolution time avg(resolution.createdAt - ticket.createdAt) over tickets with a Resolution row in range

Product (FR-002)

Same shape as Management, WHERE Ticket.productId = :productId (resolved from the given externalProductId, 404 if not found — FR-006), plus:

Figure Source
Problem-type breakdown Problem.groupBy(['categoryId'], { productId, createdAt in range })
Recurring problems Same grouped result, sorted descending, top N (config, default 10)
Top errors reuses 014's own instrumentation point conceptually but queries fresh — no, see §6: there is no persisted "error code lookup" table, only 014's in-memory Prometheus counter, which is NOT queryable historically. Resolved by adding a durable audit read instead: see §6.

Support (FR-003)

Figure Source
Per-agent workload Assignment.groupBy(['agentId'], { isCurrent: true }) — a snapshot of right now, not date-ranged (workload is inherently current, not historical — spec.md's own framing: "how much work is currently assigned")
SLA risk / breached SLARun.findMany({ status: 'running', resolutionDueAt: {gte: now} }) filtered in application code by "due within SLA_RISK_THRESHOLD_MINUTES of now" for risk, vs. status: 'breached' for already-breached
Escalation count Same as Management, unfiltered by product
Response/resolution performance Same computation as Management's averages

AI (FR-004)

Figure Source
AI resolution rate / human-handoff rate AISupportSession.groupBy(['status'], { startedAt in range })resolved vs. escalated/ended_by_agent as a share of total terminal sessions
Failed-troubleshooting-then-escalated rate Sessions with status: 'escalated' that have at least one AIInteraction/AIAction recording a failed troubleshooting attempt — see 005-ai-support's own runbook-step-outcome classification (classifyStepOutcome), reused rather than reinvented
Knowledge-match rate AIKnowledgeReference presence per session (recordMany is only ever called with actual retrieval results — 005-ai-support's own diagnose.ts) vs. sessions with zero references recorded
Confidence distribution AIDiagnosis.findMany({ createdAt in range }), bucketed in application code against aiConfig.defaultHighConfidence/defaultLowConfidence (see §7 — NOT a per-diagnosis resolved policy)
Tool success/failure AIAction joined to AIActionResult, grouped by result.status

3. "No data" convention (FR-007)

Decision: every rate/average field is number | nullnull means "no qualifying records in range," distinguished in the response shape from a genuine 0 (e.g., a real 0% AI resolution rate because everything escalated is a valid, meaningful 0; "nobody's data exists yet" is null). Application code computes every average by fetching the qualifying count first and returning null before ever dividing, never relying on 0/0 producing NaN and hoping a caller notices.

4. "Who resolved it" — reused from 014, not reinvented

014-full-observability's own event subscriber already established the authoritative rule: a ticket's Resolution.resolvedBy field ("ai" | an agentId) is the single source of truth for whether a resolution was AI- or human-driven (014 research.md §5). This feature's Management/ Product dashboards reuse the exact same join (Resolution.findMany scoped to the range, resolvedBy === 'ai' vs. not) rather than re-deriving it from Ticket.status transitions a second, potentially-inconsistent way.

5. No raw SQL

Decision: every duration average (response time, resolution time) is computed by fetching the bounded set of qualifying rows (ticket createdAt + the milestone timestamp) via Prisma and averaging in application code, not a raw $queryRaw computing AVG(EXTRACT(EPOCH FROM ...)) in SQL. At the data volumes spec.md's Assumptions accept for this first cut (no pre-aggregation, synchronous queries), a bounded per-range fetch is simple, type-safe, and testable without hand-writing SQL — consistent with this codebase's near-total avoidance of $queryRaw elsewhere (confirmed by grep: no existing module uses it for reporting-shaped queries).

6. Top errors needs a durable, queryable record — a real gap 014 left open

014-full-observability's supporthub_known_error_lookups_total Prometheus counter is process-lifetime, in-memory, and reset on every restart — useless for "top errors in the last 30 days." Since no durable "error code lookup" record exists anywhere in this codebase today (the existing error-codes.service.ts just reads KnownIssue/ErrorCode rows, never records that a lookup happened), this feature adds one small, focused piece of new state: a durable ErrorCodeLookup audit row (errorCodeId, productId, createdAt), written by error-codes.service.ts's already-existing findKnownIssuesByErrorCode (the same call site 014 instrumented for its own live counter — this feature adds one more line there, a durable write alongside the existing live-metric increment, not a replacement for it). This is the one schema change this feature needs; every other dashboard figure is computed from tables that already exist.

7. Confidence distribution uses the system default threshold, not a per-diagnosis policy

Decision: bucket every AIDiagnosis.confidence value in range against the env-configured system-wide defaults (aiConfig.defaultHighConfidence/defaultLowConfidence), the same decideConfidenceBand pure function 005-ai-support already exports — reused directly, not reimplemented.

Why not resolve each diagnosis's actual applicable per-product/category policy (what the live reasoning path itself does): AIDiagnosis.product/feature are the AI's own free-text classification output, not foreign keys to Product/Category — there is no reliable, existing join from a diagnosis row back to which ConfidencePolicy row actually applied to it at the time without speculatively string-matching free text against product names, which this codebase does nowhere else and which research.md declines to invent here. A dashboard-level aggregate distribution using the system-wide default is an honest, documented simplification (spec.md Assumptions) — precise enough to show a meaningful shape without fabricating a false precision the data doesn't actually support.

8. New configuration (Constitution Principle II — nothing hardcoded)

Env var Default Used by
REPORTING_DEFAULT_WINDOW_DAYS 30 Every dashboard's from/to default when omitted (FR-005)
REPORTING_SLA_RISK_THRESHOLD_MINUTES 60 Support dashboard's "at risk" classification (FR-003)
REPORTING_TOP_N_LIMIT 10 Product dashboard's recurring-problems/top-errors ranking length

9. Test strategy

Integration tests create real tickets/problems/SLA runs/escalation events/AI sessions/diagnoses/ actions/error-code lookups directly against real Postgres (mixing real HTTP-driven setup where a realistic flow matters and direct repository/Prisma writes where only the aggregation math is under test — the same mix 014's own business-metrics.test.ts used), then request each dashboard endpoint and assert every figure against hand-computed expected values. Unit tests cover the "no data → null, never NaN" guard and the confidence-bucketing pure-function reuse.