Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
700daf4104 | ||
|
|
249e7cd0ce | ||
|
|
2034966d6d | ||
|
|
7948182988 | ||
|
|
3bd068b031 | ||
|
|
43158ff0c4 | ||
|
|
2b00b6d6a1 | ||
|
|
49db40d7c1 |
@@ -0,0 +1,60 @@
|
||||
# Specification Quality Checklist: Admin List Views
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-09-07
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Discovered the same way 011-agent-ticket-queue was: while building supporthub-web's
|
||||
001-agent-admin-ui (User Stories 6 and 7 this time), a research pass over supporthub-api's
|
||||
actual endpoints found no cross-ticket SLA-run or escalation-event listing at all, and no
|
||||
products-with-integration-status endpoint — three separate but same-shaped gaps (an existing
|
||||
domain's data, never exposed as a list/join query), bundled into one feature rather than three
|
||||
separate ones since none is large enough to justify its own spec.
|
||||
- Deliberately narrow: read-only, no new persisted entity, no general search/filter API beyond
|
||||
the one filter (`status`) and one cap (`limit`) each list actually needs, per Assumptions.
|
||||
- All items pass; no revision iterations were needed.
|
||||
- **Implementation-time finding**: research.md's plan.md draft had described the existing
|
||||
single-ticket `GET /tickets/:ticketId/sla-run` as "agent-facing (fastify.authenticate)" — it's
|
||||
actually fully ungated (no preHandler at all). Didn't change this feature's own design
|
||||
(`GET /admin/sla-runs`/`GET /admin/escalation-events` still use `fastify.authenticate`, a
|
||||
deliberately more conservative choice than the existing route, matching spec.md's own
|
||||
"agent-usable" wording), but worth correcting for anyone reading research.md later.
|
||||
- No `SLA_RUN_STATUSES` constant existed anywhere before this feature — `SLARun.status` had
|
||||
only ever been written as free strings across the pause/resume/breach-detection code paths.
|
||||
Centralized it in `orchestration/sla/mapper/sla-run-status.ts` since this feature is the first
|
||||
caller that needs to validate against it, not just write it.
|
||||
- **Follow-up (post-implementation)**: while building supporthub-web's own knowledge-governance
|
||||
screen against this feature's own spirit, found a fourth same-shaped gap this spec's own scope
|
||||
didn't originally name: `GET /knowledge/retrieve` (004-product-knowledge) only ever returns
|
||||
`status: 'published'` entries — a governance screen that needs to see and publish a *draft*
|
||||
entry had no endpoint to list it at all. Added `GET /admin/products/:externalProductId/
|
||||
knowledge` directly to the knowledge module (not this feature's own routes, since it lives
|
||||
where `KnowledgeEntry` itself does) in a small follow-up commit, same spirit as this spec's
|
||||
three original endpoints.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Feature Specification: Admin List Views
|
||||
|
||||
**Feature Branch**: `012-admin-list-views`
|
||||
|
||||
**Created**: 2026-09-07
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Add missing read-only list endpoints supporthub-web's admin
|
||||
monitoring and catalog screens need: SLA runs across tickets, recent escalation events across
|
||||
tickets, and products with their integration status, none of which exist as a single query
|
||||
today."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - An agent or admin sees SLA status across every ticket at a glance (Priority: P1)
|
||||
|
||||
Rather than checking one ticket's SLA state at a time, an agent or admin retrieves a list of
|
||||
every ticket's current SLA run, filterable by status (running/paused/warning/breached), each
|
||||
entry carrying enough to identify and link to its ticket.
|
||||
|
||||
**Why this priority**: This is the entire reason this feature exists — supporthub-web's own
|
||||
001-agent-admin-ui, User Story 6, has no data source for its SLA monitor view without it, and
|
||||
no endpoint in the SLA module answers "every ticket's SLA state," only one ticket's own.
|
||||
|
||||
**Independent Test**: With SLA runs in different states across several tickets, call this
|
||||
endpoint unfiltered and confirm every run appears; call it filtered by `status=breached` and
|
||||
confirm only breached runs appear.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** tickets with SLA runs in running, paused, and breached states, **When** the
|
||||
endpoint is called with no filter, **Then** every run is returned, each including its
|
||||
ticket's id and code, status, and due/breached timestamps.
|
||||
2. **Given** the same tickets, **When** the endpoint is called with `status=breached`, **Then**
|
||||
only the breached runs are returned.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - An agent or admin sees recent escalation events across every ticket (Priority: P1)
|
||||
|
||||
An agent or admin retrieves a list of recent escalation events across all tickets — each
|
||||
showing the triggering reason, the rule that fired it (if automatic) or the actor who triggered
|
||||
it (if manual), and the resulting target hierarchy node.
|
||||
|
||||
**Why this priority**: The same 001-agent-admin-ui User Story 6 has no data source for its
|
||||
escalation matrix view without it — today the only way to see an escalation event at all is
|
||||
`EscalationEventRepository.findAllForTicket`, which requires already knowing which ticket to
|
||||
ask about.
|
||||
|
||||
**Independent Test**: With escalation events (both automatic and manual) recorded across
|
||||
several tickets, call this endpoint and confirm every event appears, most recent first, each
|
||||
identifying its ticket, reason, rule-or-actor, and target node.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** three tickets each with one escalation event, **When** the endpoint is called,
|
||||
**Then** all three appear, ordered most-recent-first, each including its ticket id/code,
|
||||
reason, `ruleId` (or null for manual), `triggeredBy`, and `toNodeId`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - An admin views the product catalog with integration status (Priority: P2)
|
||||
|
||||
An admin retrieves the product catalog with each product's integration status
|
||||
(active/suspended) visible directly in the list, rather than needing a second lookup per
|
||||
product.
|
||||
|
||||
**Why this priority**: Lower than User Stories 1-2 (matches 001-agent-admin-ui's own User Story
|
||||
7 being P3) — the product catalog changes far less often than SLA/escalation state, but its own
|
||||
consuming frontend story still has no single query to build a list screen against: the existing
|
||||
public `GET /products` doesn't include `ProductIntegration`, and integration status is only
|
||||
otherwise reachable per-integration-id, not per-product.
|
||||
|
||||
**Independent Test**: With two products, one with an active integration and one with a
|
||||
suspended integration, call this endpoint and confirm each product's own integration status is
|
||||
present without a further request.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a product with an active integration and one with a suspended integration, **When**
|
||||
an admin calls this endpoint, **Then** both appear with their correct integration status;
|
||||
a product with no integration at all shows a clearly-absent (not misleadingly "active")
|
||||
status.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens to a ticket whose SLA run was already marked `completed` (ticket resolved)? It
|
||||
still appears in the unfiltered SLA-run list (this is a monitoring view of everything that
|
||||
exists, not just "currently at risk") but is excluded by a `status=breached`/`running`/etc.
|
||||
filter unless it matches.
|
||||
- What happens for a ticket with no SLA run at all (no matching policy, or the run hasn't been
|
||||
created yet)? It simply doesn't appear in this list — this endpoint lists existing `SLARun`
|
||||
rows, it does not synthesize one for every ticket.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an endpoint listing every `SLARun`, each including its
|
||||
owning ticket's id and code, optionally filtered by `status`.
|
||||
- **FR-002**: The system MUST provide an endpoint listing recent `EscalationEvent` rows across
|
||||
all tickets, most-recent-first, each including its owning ticket's id and code.
|
||||
- **FR-003**: The system MUST provide an endpoint listing the product catalog with each
|
||||
product's integration status included, distinguishing "has an active integration," "has a
|
||||
suspended integration," and "has no integration at all."
|
||||
- **FR-004**: All three endpoints are read-only (no new write capability) and reuse existing
|
||||
`SLARun`/`EscalationEvent`/`Product`/`ProductIntegration` data — no new persisted entity.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **SLA Run List Item**: An `SLARun` projected with its ticket's `id`/`code` alongside its own
|
||||
existing fields.
|
||||
- **Escalation Event List Item**: An `EscalationEvent` projected with its ticket's `id`/`code`
|
||||
alongside its own existing fields.
|
||||
- **Product Catalog List Item**: A `Product` projected with its integration's `status`, or an
|
||||
explicit absence marker if it has none.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every ticket's SLA state is retrievable in a single request, filterable by status,
|
||||
with zero additional per-ticket requests needed.
|
||||
- **SC-002**: Recent escalation events across every ticket are retrievable in a single request.
|
||||
- **SC-003**: The product catalog with integration status is retrievable in a single request,
|
||||
with 0% of products showing a misleading status when they have no integration at all.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **No pagination on the SLA-run or product-catalog lists** — matches 011-agent-ticket-queue's
|
||||
own precedent (bounded, realistic data volumes for this stage); the escalation-event list
|
||||
DOES cap at a default/maximum `limit` (most-recent-first), since that list only ever grows
|
||||
and has no other natural bound.
|
||||
- **These are read-only monitoring/catalog views, not a general search/filter API** — the SLA
|
||||
list's only filter is `status`; no additional filters (date range, product, priority) are
|
||||
added speculatively beyond what 001-agent-admin-ui's own User Story 6 spec asks for.
|
||||
- **Auth**: SLA-run and escalation-event lists are agent-usable (`fastify.authenticate` only,
|
||||
matching the existing single-ticket `GET /tickets/:id/sla-run`'s own agent-facing nature and
|
||||
001-agent-admin-ui's "agents and admins" wording for User Story 6); the product-catalog list
|
||||
is admin-only (`requireRole('ADMIN')`), matching every other admin-configuration read in this
|
||||
codebase.
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
description: "Task list for 012-admin-list-views"
|
||||
---
|
||||
|
||||
# Tasks: Admin List Views
|
||||
|
||||
**Input**: Design documents from `specs/012-admin-list-views/`
|
||||
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
|
||||
[data-model.md](./data-model.md),
|
||||
[contracts/admin-list-views-contract.md](./contracts/admin-list-views-contract.md),
|
||||
[quickstart.md](./quickstart.md)
|
||||
|
||||
**Organization**: Tasks are grouped by user story (US1 = P1 SLA runs, US2 = P1 escalation
|
||||
events, US3 = P2 product catalog). All three are independent of each other.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: User Story 1 - SLA runs across every ticket (Priority: P1)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 1.
|
||||
|
||||
- [x] T001 [P] [US1] Add `SLARunRepository.findAll(status?)` in
|
||||
`src/modules/orchestration/sla/repository/sla-run.repository.ts` — `include: { ticket:
|
||||
{ select: { id: true, code: true } } }`, optional `where: { status }`
|
||||
- [x] T002 [US1] Add `SLAService.listAll(status?)` (or directly on the controller if no service
|
||||
method is warranted — check existing pattern) validating `status` against
|
||||
`SLA_RUN_STATUSES` (400 on an invalid value) in
|
||||
`src/modules/orchestration/sla/service/sla.service.ts` (depends on T001)
|
||||
- [x] T003 [US1] Add `GET /admin/sla-runs` (`fastify.authenticate` only) in
|
||||
`src/modules/orchestration/sla/controller/` + `routes/`, projecting each row to
|
||||
`SlaRunListItem` (data-model.md) (depends on T002)
|
||||
- [x] T004 [US1] Integration test covering Quickstart Scenario 1 (unfiltered returns all;
|
||||
`status=breached` filters correctly; an invalid status is 400) in
|
||||
`tests/integration/admin-list-views.test.ts`
|
||||
- [x] T005 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 2 - Recent escalation events across every ticket (Priority: P1)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 2.
|
||||
|
||||
- [x] T006 [P] [US2] Add `EscalationEventRepository.findRecent(limit)` in
|
||||
`src/modules/orchestration/escalation/repository/escalation-event.repository.ts` —
|
||||
`include: { ticket: { select: { id: true, code: true } } }`, `orderBy: { createdAt:
|
||||
'desc' }`, `take: limit`
|
||||
- [x] T007 [US2] Add `GET /admin/escalation-events` (`fastify.authenticate` only, `limit` query
|
||||
param `z.coerce.number().int().positive().max(200).default(50)`) in
|
||||
`src/modules/orchestration/escalation/controller/` + `routes/`, projecting to
|
||||
`EscalationEventListItem` (depends on T006)
|
||||
- [x] T008 [US2] Integration test covering Quickstart Scenario 2 (both events appear, most-
|
||||
recent-first, automatic vs manual distinguished by `ruleId`) in
|
||||
`tests/integration/admin-list-views.test.ts` (same file as T004)
|
||||
- [x] T009 [US2] Run Quickstart Scenario 2 locally and confirm it passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 - Product catalog with integration status (Priority: P2)
|
||||
|
||||
**Independent Test**: Quickstart Scenario 3.
|
||||
|
||||
- [x] T010 [P] [US3] Add `ProductsRepository.findAllWithIntegrationStatus()` in
|
||||
`src/modules/catalog/products/repository/products.repository.ts` — `include: {
|
||||
integration: { select: { status: true } } }`, `orderBy: { name: 'asc' }`
|
||||
- [x] T011 [US3] Add `GET /admin/products` (`fastify.authenticate` + `requireRole('ADMIN')`) in
|
||||
`src/modules/catalog/products/controller/` + `routes/`, projecting each row to
|
||||
`ProductCatalogListItem` (`integrationStatus: product.integration?.status ?? null` —
|
||||
never the full `ProductIntegration` row, research.md) (depends on T010)
|
||||
- [x] T012 [US3] Integration test covering Quickstart Scenario 3 (active + no-integration
|
||||
products both correct; non-admin gets 403) in `tests/integration/admin-list-views.test.ts`
|
||||
(same file as T004/T008)
|
||||
- [x] T013 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T014 [P] Update `specs/012-admin-list-views/checklists/requirements.md` Notes with any
|
||||
implementation-time findings
|
||||
- [x] T015 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
|
||||
- [x] T016 Full regression: `npm run test:unit` then the full integration suite against real
|
||||
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- **User Stories 1-3**: Fully independent of each other and of any Foundational phase (no shared
|
||||
prerequisite beyond the existing schema) — parallelizable in any order
|
||||
- **Polish (Phase 4)**: Depends on all three user stories
|
||||
@@ -20,6 +20,15 @@ export class KnowledgeController {
|
||||
return reply.status(201).send({ success: true, data: entry, meta: null });
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: the governance screen's own data source (every status,
|
||||
* unlike GET /knowledge/retrieve which is published-only). */
|
||||
async listForGovernance(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { externalProductId } = request.params as { externalProductId: string };
|
||||
const productId = await resolveProductId(externalProductId);
|
||||
const entries = await this.service.listForGovernance(productId);
|
||||
return reply.status(200).send({ success: true, data: entries, meta: null });
|
||||
}
|
||||
|
||||
async publish(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { code } = request.params as { code: string };
|
||||
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
|
||||
|
||||
@@ -121,6 +121,16 @@ export class KnowledgeRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: every current-version entry for a product, any status —
|
||||
* `retrieve` below only ever returns `published` entries (AI-consumption path), so the
|
||||
* governance screen (which must see drafts to publish them) needs its own query. */
|
||||
async findAllForProduct(productId: string): Promise<KnowledgeEntry[]> {
|
||||
return this.prisma.knowledgeEntry.findMany({
|
||||
where: { productId, isCurrentVersion: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
|
||||
* is validated-first, then most-recently-effective. */
|
||||
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
||||
|
||||
@@ -14,6 +14,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => knowledgeController.create(req, reply),
|
||||
);
|
||||
// 012-admin-list-views follow-up: the governance screen's own data source (every status).
|
||||
fastify.get(
|
||||
'/admin/products/:externalProductId/knowledge',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => knowledgeController.listForGovernance(req, reply),
|
||||
);
|
||||
fastify.patch(
|
||||
'/admin/knowledge/:code/publish',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
|
||||
@@ -57,6 +57,12 @@ export class KnowledgeService {
|
||||
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
|
||||
return this.repo.retrieve(filters);
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: every entry for a product, any status — the governance
|
||||
* screen's own data source (unlike `retrieve`, which is published-only). */
|
||||
async listForGovernance(productId: string): Promise<KnowledgeEntry[]> {
|
||||
return this.repo.findAllForProduct(productId);
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeService = new KnowledgeService();
|
||||
|
||||
@@ -12,6 +12,21 @@ export class ProductsController {
|
||||
meta: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: admin catalog screen — never returns the full ProductIntegration
|
||||
* row, only its derived status (research.md). */
|
||||
async getProductsWithIntegrationStatus(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const products = await this.service.listWithIntegrationStatus();
|
||||
const data = products.map((product) => ({
|
||||
id: product.id,
|
||||
externalProductId: product.externalProductId,
|
||||
name: product.name,
|
||||
status: product.status,
|
||||
supportEnabled: product.supportEnabled,
|
||||
integrationStatus: product.integration?.status ?? null,
|
||||
}));
|
||||
return reply.status(200).send({ success: true, data, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const productsController = new ProductsController();
|
||||
|
||||
@@ -8,6 +8,17 @@ export class ProductsRepository {
|
||||
return this.prisma.product.findMany();
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: the product catalog with each product's integration status joined
|
||||
* in — never the full ProductIntegration row (its credentialRef is a secret at rest). */
|
||||
async findAllWithIntegrationStatus(): Promise<
|
||||
(Product & { integration: { status: string } | null })[]
|
||||
> {
|
||||
return this.prisma.product.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: { integration: { select: { status: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findByExternalProductId(externalProductId: string): Promise<Product | null> {
|
||||
return this.prisma.product.findUnique({ where: { externalProductId } });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { requireRole } from '@/modules/identity/auth';
|
||||
import { productsController } from '../controller';
|
||||
|
||||
export async function productsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/products', (req, reply) => productsController.getProducts(req, reply));
|
||||
|
||||
// 012-admin-list-views: admin-only — a separate route rather than a query flag on the public
|
||||
// /products above, so the auth gate stays unconditional (research.md).
|
||||
fastify.get(
|
||||
'/admin/products',
|
||||
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
|
||||
(req, reply) => productsController.getProductsWithIntegrationStatus(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ export class ProductsService {
|
||||
async listProducts(): Promise<unknown[]> {
|
||||
return this.repo.findAllProducts();
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: the product catalog with integration status, for the admin catalog
|
||||
* screen. */
|
||||
async listWithIntegrationStatus() {
|
||||
return this.repo.findAllWithIntegrationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
export const productsService = new ProductsService();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createEscalationRuleSchema,
|
||||
updateEscalationRuleSchema,
|
||||
manualEscalationSchema,
|
||||
listRecentEventsQuerySchema,
|
||||
} from '../schema';
|
||||
|
||||
function actorFrom(request: FastifyRequest): string {
|
||||
@@ -45,6 +46,23 @@ export class EscalationController {
|
||||
return reply.status(204).send();
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: recent escalation events across every ticket, for the monitoring
|
||||
* view. */
|
||||
async listRecentEvents(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { limit } = listRecentEventsQuerySchema.parse(request.query);
|
||||
const events = await this.service.listRecentEvents(limit);
|
||||
const data = events.map((event) => ({
|
||||
ticketId: event.ticketId,
|
||||
ticketCode: event.ticket.code,
|
||||
reason: event.reason,
|
||||
ruleId: event.ruleId,
|
||||
triggeredBy: event.triggeredBy,
|
||||
toNodeId: event.toNodeId,
|
||||
createdAt: event.createdAt,
|
||||
}));
|
||||
return reply.status(200).send({ success: true, data, meta: null });
|
||||
}
|
||||
|
||||
async escalateManually(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const body = manualEscalationSchema.parse(request.body);
|
||||
|
||||
@@ -25,6 +25,18 @@ export class EscalationEventRepository {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: recent escalation events across every ticket, most-recent-first —
|
||||
* the monitoring view's own data source. */
|
||||
async findRecent(
|
||||
limit: number,
|
||||
): Promise<(EscalationEvent & { ticket: { id: string; code: string } })[]> {
|
||||
return this.prisma.escalationEvent.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
include: { ticket: { select: { id: true, code: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const escalationEventRepository = new EscalationEventRepository();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EscalationPolicy, Prisma } from '@prisma/client';
|
||||
import { EscalationPolicy, EscalationRule, Prisma } from '@prisma/client';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
|
||||
export class EscalationPolicyRepository {
|
||||
@@ -17,8 +17,11 @@ export class EscalationPolicyRepository {
|
||||
return this.prisma.escalationPolicy.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findAll(): Promise<EscalationPolicy[]> {
|
||||
return this.prisma.escalationPolicy.findMany();
|
||||
/** 012-admin-list-views follow-up: includes each policy's own rules — GET
|
||||
* /admin/escalation-policies previously returned bare policies with no way to read back
|
||||
* which rules (trigger type + target node) already existed under one. */
|
||||
async findAll(): Promise<(EscalationPolicy & { rules: EscalationRule[] })[]> {
|
||||
return this.prisma.escalationPolicy.findMany({ include: { rules: true } });
|
||||
}
|
||||
|
||||
/** research.md "Escalation policy resolution": prefer a product-specific active policy, fall
|
||||
|
||||
@@ -32,4 +32,10 @@ export async function escalationRoutes(fastify: FastifyInstance): Promise<void>
|
||||
fastify.post('/tickets/:ticketId/escalate', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
escalationController.escalateManually(req, reply),
|
||||
);
|
||||
|
||||
// 012-admin-list-views: recent escalation events across every ticket, for the monitoring
|
||||
// view — agent-usable, not admin-only, per that feature's own spec.md.
|
||||
fastify.get('/admin/escalation-events', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
escalationController.listRecentEvents(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ export const manualEscalationSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** 012-admin-list-views: caps the "recent escalation events" monitoring list — see that
|
||||
* feature's own research.md. */
|
||||
export const listRecentEventsQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(200).default(50),
|
||||
});
|
||||
|
||||
export type CreateEscalationPolicyBody = z.infer<typeof createEscalationPolicySchema>;
|
||||
export type CreateEscalationRuleBody = z.infer<typeof createEscalationRuleSchema>;
|
||||
export type UpdateEscalationRuleBody = z.infer<typeof updateEscalationRuleSchema>;
|
||||
|
||||
@@ -108,6 +108,12 @@ export class EscalationService {
|
||||
return this.events.findAllForTicket(ticketId);
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: recent escalation events across every ticket, for the monitoring
|
||||
* view. */
|
||||
async listRecentEvents(limit: number) {
|
||||
return this.events.findRecent(limit);
|
||||
}
|
||||
|
||||
async createPolicy(data: CreateEscalationPolicyBody): Promise<EscalationPolicy> {
|
||||
if (data.productId) {
|
||||
const product = await productsRepository.findById(data.productId);
|
||||
|
||||
@@ -41,6 +41,22 @@ export class SlaController {
|
||||
const run = await this.service.getRunByTicketId(ticketId);
|
||||
return reply.status(200).send({ success: true, data: run, meta: null });
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: every SLA run across every ticket, for the monitoring view. */
|
||||
async listRuns(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { status } = request.query as { status?: string };
|
||||
const runs = await this.service.listRuns(status);
|
||||
const data = runs.map((run) => ({
|
||||
ticketId: run.ticketId,
|
||||
ticketCode: run.ticket.code,
|
||||
status: run.status,
|
||||
firstResponseDueAt: run.firstResponseDueAt,
|
||||
resolutionDueAt: run.resolutionDueAt,
|
||||
breachedAt: run.breachedAt,
|
||||
firstResponseBreachedAt: run.firstResponseBreachedAt,
|
||||
}));
|
||||
return reply.status(200).send({ success: true, data, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
export const slaController = new SlaController();
|
||||
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export * from './sla-run-status';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 008-sla-escalation's own SLARun.status vocabulary — centralized here since
|
||||
* 012-admin-list-views is the first caller needing to validate against it (previously used only
|
||||
* as free strings written by the pause/resume/breach-detection code paths). */
|
||||
export const SLA_RUN_STATUSES = ['running', 'paused', 'warning', 'breached', 'completed'] as const;
|
||||
|
||||
export type SlaRunStatus = (typeof SLA_RUN_STATUSES)[number];
|
||||
@@ -44,6 +44,15 @@ export class SlaRunRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: every SLA run across every ticket, optionally filtered by status —
|
||||
* the monitoring view's own data source. */
|
||||
async findAll(status?: string): Promise<(SLARun & { ticket: { id: string; code: string } })[]> {
|
||||
return this.prisma.sLARun.findMany({
|
||||
...(status ? { where: { status } } : {}),
|
||||
include: { ticket: { select: { id: true, code: true } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const slaRunRepository = new SlaRunRepository();
|
||||
|
||||
@@ -28,4 +28,10 @@ export async function slaRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
(req, reply) => slaController.deactivatePolicy(req, reply),
|
||||
);
|
||||
fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply));
|
||||
|
||||
// 012-admin-list-views: every SLA run across every ticket, for the monitoring view —
|
||||
// agent-usable (fastify.authenticate only), not admin-only, per that feature's own spec.md.
|
||||
fastify.get('/admin/sla-runs', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
slaController.listRuns(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SLAPolicy, SLARun } from '@prisma/client';
|
||||
import { NotFoundError } from '@/common/errors';
|
||||
import { NotFoundError, ValidationError } from '@/common/errors';
|
||||
import { ticketsService } from '@/modules/ticketing/tickets';
|
||||
import { messagesService } from '@/modules/ticketing/messages';
|
||||
import { escalationService, EscalationService } from '@/modules/orchestration/escalation';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { slaPolicyResolverService, SlaPolicyResolverService } from './sla-policy-resolver.service';
|
||||
import { slaDueDateCalculator, SlaDueDateCalculator } from '../calculators/sla-due-date.calculator';
|
||||
import { CreateSlaPolicyBody, UpdateSlaPolicyBody } from '../schema';
|
||||
import { SLA_RUN_STATUSES, SlaRunStatus } from '../mapper';
|
||||
|
||||
export class SlaService {
|
||||
constructor(
|
||||
@@ -56,6 +57,14 @@ export class SlaService {
|
||||
return run;
|
||||
}
|
||||
|
||||
/** 012-admin-list-views: every SLA run across every ticket, for the monitoring view. */
|
||||
async listRuns(status?: string) {
|
||||
if (status !== undefined && !SLA_RUN_STATUSES.includes(status as SlaRunStatus)) {
|
||||
throw new ValidationError(`Invalid status. Must be one of: ${SLA_RUN_STATUSES.join(', ')}.`);
|
||||
}
|
||||
return this.runs.findAll(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* research.md "SLA-run lifecycle is wired entirely through the existing domain-event bus":
|
||||
* subscribed to TICKET_ASSIGNED. No-ops if the ticket already has a run (SLARun.ticketId
|
||||
|
||||
@@ -22,7 +22,7 @@ export class BusinessCalendarsController {
|
||||
|
||||
async getById(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { id } = request.params as { id: string };
|
||||
const calendar = await this.service.getById(id);
|
||||
const calendar = await this.service.getByIdWithHolidays(id);
|
||||
return reply.status(200).send({ success: true, data: calendar, meta: null });
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,14 @@ export class BusinessCalendarsService {
|
||||
return calendar;
|
||||
}
|
||||
|
||||
/** 012-admin-list-views follow-up: GET /admin/business-calendars/:id's own response — a
|
||||
* calendar's holidays had no way to be read back at all before this (only added/removed). */
|
||||
async getByIdWithHolidays(id: string): Promise<BusinessCalendar & { holidays: Holiday[] }> {
|
||||
const calendar = await this.calendars.findByIdWithHolidays(id);
|
||||
if (!calendar) throw new NotFoundError('Business calendar not found.');
|
||||
return calendar;
|
||||
}
|
||||
|
||||
async list(): Promise<BusinessCalendar[]> {
|
||||
return this.calendars.findAll();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../helpers/auth';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
|
||||
/**
|
||||
* Covers specs/012-admin-list-views/quickstart.md Scenarios 1-3 against a real Postgres/Redis
|
||||
* — SLA runs, escalation events, and the product catalog, all listed across multiple
|
||||
* tickets/products in one request.
|
||||
*/
|
||||
describe('Admin list views (User Stories 1-3)', () => {
|
||||
let app: FastifyInstance;
|
||||
let adminToken: string;
|
||||
let agentToken: string;
|
||||
const suffix = Date.now();
|
||||
const externalProductId = `TEST_ALV_PROD_${suffix}`;
|
||||
const skillTag = `alv_skill_${suffix}`;
|
||||
let productId: string;
|
||||
let teamId: string;
|
||||
let agentAId: string;
|
||||
let secret: string;
|
||||
let nodeAId: string;
|
||||
let nodeBId: string;
|
||||
let globalPolicyId: string;
|
||||
const createdTicketIds: string[] = [];
|
||||
|
||||
async function createTicket(): Promise<string> {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: `Needs a human ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
const ticketId = created.json().data.ticketId as string;
|
||||
createdTicketIds.push(ticketId);
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
async function escalate(ticketId: string): Promise<void> {
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
headers: authHeader(adminToken),
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
adminToken = await loginAs(app, 'ADMIN');
|
||||
agentToken = await loginAs(app, 'AGENT');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Admin List Views Test Product', status: 'active' },
|
||||
});
|
||||
productId = product.id;
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: `ALV Team ${suffix}` },
|
||||
});
|
||||
teamId = team.json().data.id;
|
||||
|
||||
const agentA = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
headers: authHeader(adminToken),
|
||||
payload: { name: 'ALV Agent A' },
|
||||
});
|
||||
agentAId = agentA.json().data.id;
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
|
||||
headers: authHeader(adminToken),
|
||||
payload: { level: 3 },
|
||||
});
|
||||
|
||||
const nodeA = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
name: 'ALV Node A',
|
||||
order: 0,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
nodeAId = nodeA.json().data.id;
|
||||
|
||||
const nodeB = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/hierarchy-nodes',
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
name: 'ALV Node B',
|
||||
order: 1,
|
||||
productScope: [externalProductId],
|
||||
skills: [skillTag],
|
||||
assignmentStrategy: 'ROUND_ROBIN',
|
||||
},
|
||||
});
|
||||
nodeBId = nodeB.json().data.id;
|
||||
|
||||
const policy = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/sla-policies',
|
||||
headers: authHeader(adminToken),
|
||||
payload: {
|
||||
name: `ALV Policy ${suffix}`,
|
||||
productId,
|
||||
firstResponseMinutes: 30,
|
||||
resolutionMinutes: 60,
|
||||
},
|
||||
});
|
||||
globalPolicyId = policy.json().data.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const ticketFilter = { ticketId: { in: createdTicketIds } };
|
||||
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.sLARun.deleteMany({ where: { policyId: globalPolicyId } });
|
||||
await prismaClient.sLAPolicy.deleteMany({ where: { id: globalPolicyId } });
|
||||
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.assignment.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } });
|
||||
await prismaClient.agentSkill.deleteMany({ where: { agentId: agentAId } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
|
||||
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
|
||||
await prismaClient.problem.deleteMany({ where: { productId } });
|
||||
await prismaClient.agent.deleteMany({ where: { teamId } });
|
||||
await prismaClient.team.deleteMany({ where: { id: teamId } });
|
||||
await prismaClient.productIntegration.deleteMany({ where: { productId } });
|
||||
await prismaClient.product.deleteMany({ where: { id: productId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('US1: SLA runs are listed across tickets, filterable by status, and reject an invalid status', async () => {
|
||||
const ticket1 = await createTicket();
|
||||
await escalate(ticket1);
|
||||
|
||||
const unfiltered = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sla-runs',
|
||||
headers: authHeader(agentToken),
|
||||
});
|
||||
expect(unfiltered.statusCode).toBe(200);
|
||||
const ours = unfiltered.json().data.filter((r: { ticketId: string }) => r.ticketId === ticket1);
|
||||
expect(ours).toHaveLength(1);
|
||||
expect(ours[0].ticketCode).toBeTruthy();
|
||||
|
||||
const filtered = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sla-runs?status=running',
|
||||
headers: authHeader(agentToken),
|
||||
});
|
||||
expect(filtered.statusCode).toBe(200);
|
||||
expect(filtered.json().data.every((r: { status: string }) => r.status === 'running')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const invalid = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/sla-runs?status=not-a-real-status',
|
||||
headers: authHeader(agentToken),
|
||||
});
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('US2: recent escalation events are listed across tickets, most-recent-first', async () => {
|
||||
const ticket2 = await createTicket();
|
||||
await escalate(ticket2);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const manual = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticket2}/escalate`,
|
||||
headers: authHeader(agentToken),
|
||||
payload: { targetNodeId: nodeBId, reason: 'test manual escalation' },
|
||||
});
|
||||
expect(manual.statusCode).toBe(201);
|
||||
|
||||
const events = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/escalation-events',
|
||||
headers: authHeader(agentToken),
|
||||
});
|
||||
expect(events.statusCode).toBe(200);
|
||||
const ours = events.json().data.filter((e: { ticketId: string }) => e.ticketId === ticket2);
|
||||
expect(ours.length).toBeGreaterThanOrEqual(1);
|
||||
const manualEvent = ours.find((e: { ruleId: string | null }) => e.ruleId === null);
|
||||
expect(manualEvent).toBeDefined();
|
||||
expect(manualEvent.triggeredBy).toBeTruthy();
|
||||
expect(manualEvent.toNodeId).toBe(nodeBId);
|
||||
});
|
||||
|
||||
it('US3: the product catalog shows integration status, and is admin-only', async () => {
|
||||
const asAdmin = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/products',
|
||||
headers: authHeader(adminToken),
|
||||
});
|
||||
expect(asAdmin.statusCode).toBe(200);
|
||||
const ours = asAdmin.json().data.find((p: { id: string }) => p.id === productId);
|
||||
expect(ours.integrationStatus).toBe('active');
|
||||
expect(ours.credentialRef).toBeUndefined();
|
||||
|
||||
const asAgent = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/products',
|
||||
headers: authHeader(agentToken),
|
||||
});
|
||||
expect(asAgent.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { loginAs, authHeader } from '../helpers/auth';
|
||||
|
||||
/**
|
||||
* Covers specs/008-sla-escalation's business-calendar create/holiday flow, plus
|
||||
* 012-admin-list-views' own follow-up: GET /admin/business-calendars/:id now includes holidays
|
||||
* (previously only addable/removable, never readable back).
|
||||
*/
|
||||
describe('Business calendars — create, add a holiday, and read both back', () => {
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let calendarId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
token = await loginAs(app, 'ADMIN');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.holiday.deleteMany({ where: { calendarId } });
|
||||
await prismaClient.businessCalendar.deleteMany({ where: { id: calendarId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('a created calendar and an added holiday are both displayed back exactly as entered', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/business-calendars',
|
||||
headers: authHeader(token),
|
||||
payload: {
|
||||
name: `Test Calendar ${Date.now()}`,
|
||||
timezone: 'America/New_York',
|
||||
workingHours: { mon: { start: '09:00', end: '17:00' } },
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
calendarId = created.json().data.id;
|
||||
|
||||
const holiday = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/business-calendars/${calendarId}/holidays`,
|
||||
headers: authHeader(token),
|
||||
payload: { date: '2026-12-25', description: 'Christmas' },
|
||||
});
|
||||
expect(holiday.statusCode).toBe(201);
|
||||
|
||||
const fetched = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/business-calendars/${calendarId}`,
|
||||
headers: authHeader(token),
|
||||
});
|
||||
expect(fetched.statusCode).toBe(200);
|
||||
expect(fetched.json().data.workingHours.mon).toEqual({ start: '09:00', end: '17:00' });
|
||||
expect(fetched.json().data.holidays).toHaveLength(1);
|
||||
expect(fetched.json().data.holidays[0].description).toBe('Christmas');
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,38 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
|
||||
expect(afterPublish.json().data.find((e: { code: string }) => e.code === code)).toBeDefined();
|
||||
});
|
||||
|
||||
it('012-admin-list-views follow-up: the governance list shows a draft entry, unlike retrieve', async () => {
|
||||
const draftCode = `KB-TEST-DRAFT-${Date.now()}`;
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/products/${externalProductId}/knowledge`,
|
||||
headers: authHeader(token),
|
||||
payload: { code: draftCode, type: 'faq', problem: 'Still a draft' },
|
||||
});
|
||||
|
||||
const governanceList = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/products/${externalProductId}/knowledge`,
|
||||
headers: authHeader(token),
|
||||
});
|
||||
expect(governanceList.statusCode).toBe(200);
|
||||
const draftEntry = governanceList
|
||||
.json()
|
||||
.data.find((e: { code: string }) => e.code === draftCode);
|
||||
expect(draftEntry).toBeDefined();
|
||||
expect(draftEntry.status).toBe('draft');
|
||||
|
||||
const retrieveResult = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/knowledge/retrieve?productId=${externalProductId}`,
|
||||
});
|
||||
expect(
|
||||
retrieveResult.json().data.find((e: { code: string }) => e.code === draftCode),
|
||||
).toBeUndefined();
|
||||
|
||||
await prismaClient.knowledgeEntry.deleteMany({ where: { code: draftCode } });
|
||||
});
|
||||
|
||||
it('Scenario 2: editing creates a new version and preserves the prior one', async () => {
|
||||
const editResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user