docs(012-admin-list-views): plan, research, data model, contract, quickstart
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
49db40d7c1
commit
2b00b6d6a1
@@ -0,0 +1,83 @@
|
||||
# Contract: Admin List Views
|
||||
|
||||
## `GET /admin/sla-runs`
|
||||
|
||||
**Auth**: `fastify.authenticate` only (agent-usable, per spec.md Assumptions).
|
||||
|
||||
**Query**: `status?: 'running' | 'paused' | 'warning' | 'breached' | 'completed'`
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ticketId": "string",
|
||||
"ticketCode": "string",
|
||||
"status": "string",
|
||||
"firstResponseDueAt": "ISO 8601 datetime | null",
|
||||
"resolutionDueAt": "ISO 8601 datetime | null",
|
||||
"breachedAt": "ISO 8601 datetime | null",
|
||||
"firstResponseBreachedAt": "ISO 8601 datetime | null"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response `400`**: an invalid `status` value.
|
||||
|
||||
## `GET /admin/escalation-events`
|
||||
|
||||
**Auth**: `fastify.authenticate` only.
|
||||
|
||||
**Query**: `limit?: number` (1-200, default 50)
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ticketId": "string",
|
||||
"ticketCode": "string",
|
||||
"reason": "string",
|
||||
"ruleId": "string | null",
|
||||
"triggeredBy": "string",
|
||||
"toNodeId": "string | null",
|
||||
"createdAt": "ISO 8601 datetime"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
Ordered most-recent-first (`createdAt desc`).
|
||||
|
||||
## `GET /admin/products`
|
||||
|
||||
**Auth**: `fastify.authenticate` + `requireRole('ADMIN')`.
|
||||
|
||||
**Response `200`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "string",
|
||||
"externalProductId": "string",
|
||||
"name": "string",
|
||||
"status": "string",
|
||||
"supportEnabled": true,
|
||||
"integrationStatus": "active | suspended | null"
|
||||
}
|
||||
],
|
||||
"meta": null
|
||||
}
|
||||
```
|
||||
|
||||
`integrationStatus` is `null` when the product has no `ProductIntegration` at all — never
|
||||
defaulted to `"active"` or any other value that could be mistaken for a real integration state.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Data Model: Admin List Views
|
||||
|
||||
No schema changes. Three response-shape projections over existing models.
|
||||
|
||||
## `SlaRunListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `ticketId` | `SLARun.ticketId` |
|
||||
| `ticketCode` | `SLARun.ticket.code` (via `include`) |
|
||||
| `status` | `SLARun.status` |
|
||||
| `firstResponseDueAt` | `SLARun.firstResponseDueAt` |
|
||||
| `resolutionDueAt` | `SLARun.resolutionDueAt` |
|
||||
| `breachedAt` | `SLARun.breachedAt` |
|
||||
| `firstResponseBreachedAt` | `SLARun.firstResponseBreachedAt` |
|
||||
|
||||
## `EscalationEventListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `ticketId` | `EscalationEvent.ticketId` |
|
||||
| `ticketCode` | `EscalationEvent.ticket.code` (via `include`) |
|
||||
| `reason` | `EscalationEvent.reason` |
|
||||
| `ruleId` | `EscalationEvent.ruleId` (null for manual/no-match) |
|
||||
| `triggeredBy` | `EscalationEvent.triggeredBy` |
|
||||
| `toNodeId` | `EscalationEvent.toNodeId` |
|
||||
| `createdAt` | `EscalationEvent.createdAt` |
|
||||
|
||||
## `ProductCatalogListItem` (response shape only)
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `id` | `Product.id` |
|
||||
| `externalProductId` | `Product.externalProductId` |
|
||||
| `name` | `Product.name` |
|
||||
| `status` | `Product.status` |
|
||||
| `supportEnabled` | `Product.supportEnabled` |
|
||||
| `integrationStatus` | Derived: `product.integration?.status ?? null` — never the full `ProductIntegration` row (research.md) |
|
||||
|
||||
## Validation / Business Rules
|
||||
|
||||
- `GET /admin/sla-runs?status=` — `status` validated against `SLA_RUN_STATUSES` (`running`,
|
||||
`paused`, `warning`, `breached`, `completed`); omitted means unfiltered.
|
||||
- `GET /admin/escalation-events?limit=` — `limit` coerced, `1..200`, default `50`; ordered by
|
||||
`createdAt desc`.
|
||||
- `GET /admin/products` — no filter; ordered by `name asc` (matches existing catalog list
|
||||
conventions elsewhere in this codebase, e.g. `TeamsRepository.findAll`).
|
||||
@@ -0,0 +1,104 @@
|
||||
# Implementation Plan: Admin List Views
|
||||
|
||||
**Branch**: `012-admin-list-views` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `specs/012-admin-list-views/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Adds three read-only endpoints, each a straightforward `findMany` on an already-existing model
|
||||
plus a small ticket-id/code projection: `GET /admin/sla-runs` (optional `?status=`),
|
||||
`GET /admin/escalation-events` (optional `?limit=`), and `GET /admin/products` (products joined
|
||||
to their integration's status). No new persisted entity, no write capability.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged).
|
||||
|
||||
**Primary Dependencies**: None new — Prisma only.
|
||||
|
||||
**Storage**: PostgreSQL via Prisma. No schema change — every field already exists; these are
|
||||
projections over `SLARun`, `EscalationEvent`, and `Product`/`ProductIntegration`.
|
||||
|
||||
**Testing**: Vitest — integration tests against real Postgres/Redis for each endpoint's filter/
|
||||
ordering/projection behavior, plus one admin-role-gating check for `GET /admin/products`.
|
||||
|
||||
**Target Platform**: Same Fastify modular monolith. Modifies `orchestration/sla` (new route +
|
||||
repository method), `orchestration/escalation` (new route + repository method), and
|
||||
`catalog/products` (new admin route + repository method) — no new module, each list lives in
|
||||
the module that already owns its underlying model.
|
||||
|
||||
**Project Type**: Backend service — single project.
|
||||
|
||||
**Performance Goals**: Each list is one indexed/simple query — `SLARun` has no per-status
|
||||
index today (status is a small string column, not indexed), acceptable at this stage per
|
||||
spec.md's own "no general search API" scoping; revisit if a future feature's data volume
|
||||
demands one.
|
||||
|
||||
**Constraints**: FR-004 — read-only, no new write path. The product-catalog list must not leak
|
||||
`ProductIntegration.credentialRef` (encrypted secret) or any other sensitive integration field
|
||||
— only `status` is projected.
|
||||
|
||||
**Scale/Scope**: Three new GET routes across three existing modules, three new repository
|
||||
methods, no new module, no schema migration. Explicitly excludes: pagination (spec.md
|
||||
Assumptions — `limit` only on the escalation-event list), and any filter beyond `status`/`limit`.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle / Section | Check | Result |
|
||||
|---|---|---|
|
||||
| I. SaaS Is the Sole Identity & Access Authority | Purely internal SupportHub domain (SLA/escalation/product-catalog monitoring) — no SaaS/customer identity involved. | PASS — N/A |
|
||||
| II. Configuration Over Hardcoding | No new configurable values. | PASS — N/A |
|
||||
| III. Layered Architecture With Enforced Module Boundaries | Each list lives in the module that already owns its model (`orchestration/sla`, `orchestration/escalation`, `catalog/products`) — no cross-module reach-through; the ticket id/code projection reads `ticketsRepository`'s own public surface via `ticketing/tickets`'s existing `index.ts`. | PASS |
|
||||
| IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A |
|
||||
| V. Evidence-Based Verification | Not applicable. | PASS — N/A |
|
||||
| VI. Durable Audit & History | Not applicable — no new mutable state. | PASS — N/A |
|
||||
| VII. Concurrency-Safe, Durable Job Handling | Read-only queries; no concurrency concern. | PASS |
|
||||
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A |
|
||||
| Technology & Platform Constraints | No new dependencies or infrastructure. | PASS |
|
||||
|
||||
No violations requiring Complexity Tracking justification.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/012-admin-list-views/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
supporthub-api/
|
||||
└── src/
|
||||
└── modules/
|
||||
├── orchestration/
|
||||
│ ├── sla/ # MODIFIED — GET /admin/sla-runs
|
||||
│ │ ├── controller/ routes/
|
||||
│ │ └── repository/ (new findAll(status?) method)
|
||||
│ └── escalation/ # MODIFIED — GET /admin/escalation-events
|
||||
│ ├── controller/ routes/
|
||||
│ └── repository/ (new findRecent(limit?) method)
|
||||
└── catalog/
|
||||
└── products/ # MODIFIED — GET /admin/products
|
||||
├── controller/ routes/
|
||||
└── repository/ (new findAllWithIntegrationStatus() method)
|
||||
└── tests/
|
||||
└── integration/ # one new test file per endpoint's own scenarios
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project, no new module — each endpoint extends the module that
|
||||
already owns its underlying data, matching 011-agent-ticket-queue's own precedent.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
*No constitution violations — table intentionally omitted.*
|
||||
@@ -0,0 +1,27 @@
|
||||
# Quickstart: Validating Admin List Views
|
||||
|
||||
## Scenario 1 — SLA runs across tickets
|
||||
|
||||
1. With SLA runs in `running`, `paused`, and `breached` states across three tickets, call
|
||||
`GET /admin/sla-runs` as any authenticated agent. **Expected**: `200`, all three, each with
|
||||
`ticketId`/`ticketCode` populated.
|
||||
2. Repeat with `?status=breached`. **Expected**: only the breached run.
|
||||
3. Repeat with `?status=not-a-real-status`. **Expected**: `400`.
|
||||
|
||||
## Scenario 2 — recent escalation events across tickets
|
||||
|
||||
1. With one automatic and one manual escalation event recorded on two different tickets, call
|
||||
`GET /admin/escalation-events`. **Expected**: `200`, both, most-recent-first, the automatic
|
||||
one showing its `ruleId` and the manual one showing `ruleId: null` and its `triggeredBy`.
|
||||
|
||||
## Scenario 3 — product catalog with integration status
|
||||
|
||||
1. With one product that has an active integration and one with no integration at all, call
|
||||
`GET /admin/products` as an admin. **Expected**: `200`, the first shows
|
||||
`integrationStatus: "active"`, the second shows `integrationStatus: null`.
|
||||
2. Repeat as a non-admin agent. **Expected**: `403`.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
All three scenarios pass against a real Postgres/Redis, and none of the three endpoints leaks
|
||||
`ProductIntegration.credentialRef` or any other integration-internal field.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Research: Admin List Views
|
||||
|
||||
## Decision: project ticket id/code via a second query, not a raw join
|
||||
|
||||
- **Decision**: Each repository method fetches its own rows (`SLARun[]`/`EscalationEvent[]`)
|
||||
with Prisma's own `include: { ticket: { select: { id: true, code: true } } }` — a single
|
||||
Prisma query using the existing `ticket` relation already on both models, not a hand-written
|
||||
SQL join or a second round-trip.
|
||||
- **Rationale**: Both `SLARun` and `EscalationEvent` already have a `ticket` relation
|
||||
(`@relation(fields: [ticketId], references: [id])`) — Prisma's `include` turns this into one
|
||||
query, not N+1, and needs no new repository dependency on `ticketsRepository`.
|
||||
- **Alternatives considered**: A second batched `ticketsRepository.findByIds(...)` call — works,
|
||||
but `include` is simpler and already idiomatic in this codebase's own repositories (e.g.
|
||||
011-agent-ticket-queue's `findAssignedToAgent`).
|
||||
|
||||
## Decision: `status` filter on `GET /admin/sla-runs` is validated against `SLA_RUN_STATUSES`
|
||||
|
||||
- **Decision**: `status` is an optional query param validated with
|
||||
`z.enum(['running', 'paused', 'warning', 'breached', 'completed']).optional()` — the same
|
||||
status vocabulary `SLARun.status` already uses (008-sla-escalation).
|
||||
- **Rationale**: A typo'd status silently returning zero rows (if left as a free string) would
|
||||
be a confusing, silent failure mode for a monitoring view; validating it up front makes an
|
||||
invalid filter a clear `400`, matching this codebase's existing "resolve/validate first, then
|
||||
act" convention (e.g. 011's proactive existence checks).
|
||||
- **Alternatives considered**: A free-text `z.string().optional()` — rejected for the silent-
|
||||
wrong-filter risk above.
|
||||
|
||||
## Decision: `GET /admin/escalation-events` defaults to `limit=50`, capped at `200`
|
||||
|
||||
- **Decision**: `limit` is `z.coerce.number().int().positive().max(200).default(50)`.
|
||||
- **Rationale**: Unlike `SLARun` (bounded by currently-open tickets) or `Product` (bounded by
|
||||
catalog size), `EscalationEvent` rows only ever accumulate — an unbounded list would grow
|
||||
without limit. A sane default plus a hard ceiling avoids both an accidentally-enormous
|
||||
response and a caller needing to know to always pass one.
|
||||
- **Alternatives considered**: True cursor-based pagination — rejected as more than this
|
||||
feature's own scope calls for (spec.md Assumptions); a capped `limit` is enough for a
|
||||
"recent escalations" monitoring view.
|
||||
|
||||
## Decision: product-catalog integration status is a derived string, not the raw `ProductIntegration` row
|
||||
|
||||
- **Decision**: `GET /admin/products` returns `integrationStatus: 'active' | 'suspended' | null`
|
||||
(`null` when `product.integration` is absent) — never the full `ProductIntegration` object.
|
||||
- **Rationale**: `ProductIntegration.credentialRef` is an encrypted secret at rest
|
||||
(002-saas-integration); even encrypted, there's no reason for a list-view response to include
|
||||
it, or any other integration-internal field (`rateLimitPerMinute`, `allowedScope`, etc.) this
|
||||
screen doesn't render (FR-003's own "constraints" — plan.md).
|
||||
- **Alternatives considered**: Nesting the full `include: { integration: true }` result under
|
||||
the product — rejected; a derived, minimal field is both simpler for the frontend and doesn't
|
||||
require re-auditing every future `ProductIntegration` field addition for accidental exposure
|
||||
through a public-adjacent list view (this route is admin-only, but the same discipline this
|
||||
codebase already applies to `AssignedTicketSummary`'s own minimal projection applies here too).
|
||||
|
||||
## Decision: `GET /admin/products` is a new admin route, not an extension of the existing public `GET /products`
|
||||
|
||||
- **Decision**: A separate route rather than adding an optional `includeIntegrationStatus` query
|
||||
param to the existing public, ungated `GET /products`.
|
||||
- **Rationale**: `GET /products` is intentionally public (spec.md Assumptions of
|
||||
002-saas-integration's own catalog read); layering an admin-only field onto a public route
|
||||
via a query flag would make that route's own auth requirement conditional on which fields
|
||||
were requested — a confusing, easy-to-get-wrong pattern. A separate `requireRole('ADMIN')`
|
||||
route keeps the gate unconditional and obvious.
|
||||
- **Alternatives considered**: The query-flag approach above — rejected for the reason stated.
|
||||
Reference in New Issue
Block a user