# 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 | null` — `null` 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.