Merge pull request 'development' (#13) from development into main

Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/13
This commit is contained in:
saqibmir
2026-09-03 11:12:34 +00:00
101 changed files with 3297 additions and 182 deletions
+6
View File
@@ -41,6 +41,9 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5434:5432"
volumes:
- postgres_development_data:/var/lib/postgresql
@@ -66,6 +69,9 @@ services:
- --requirepass
- ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_development_data:/data
+6
View File
@@ -39,6 +39,9 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes:
- postgres_test_data:/var/lib/postgresql
@@ -62,6 +65,9 @@ services:
- --requirepass
- ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_test_data:/data
@@ -0,0 +1,105 @@
-- CreateTable
CREATE TABLE "investigations" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"investigator" TEXT NOT NULL,
"findings" JSONB NOT NULL,
"evidence" JSONB,
"internalNotes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "investigations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "root_causes" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "root_causes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solutions" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"proposed" TEXT NOT NULL,
"approved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solutions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_implementations" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"notes" TEXT,
"implementedBy" TEXT NOT NULL,
"implementedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_implementations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_verifications" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"result" TEXT NOT NULL,
"evidence" JSONB,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "resolutions" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"resolvedBy" TEXT NOT NULL,
"resolvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "resolutions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "investigations_problemId_createdAt_idx" ON "investigations"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "root_causes_problemId_createdAt_idx" ON "root_causes"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "solutions_problemId_createdAt_idx" ON "solutions"("problemId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "solution_implementations_solutionId_key" ON "solution_implementations"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "solution_verifications_solutionId_key" ON "solution_verifications"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "resolutions_ticketId_key" ON "resolutions"("ticketId");
-- AddForeignKey
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solutions" ADD CONSTRAINT "solutions_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_implementations" ADD CONSTRAINT "solution_implementations_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_verifications" ADD CONSTRAINT "solution_verifications_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "resolutions" ADD CONSTRAINT "resolutions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+83
View File
@@ -117,6 +117,10 @@ model Problem {
category Category? @relation(fields: [categoryId], references: [id])
tickets Ticket[]
investigations Investigation[]
rootCauses RootCause[]
solutions Solution[]
@@map("problems")
}
@@ -150,6 +154,7 @@ model Ticket {
assignmentHistory AssignmentHistory[]
slaRun SLARun?
escalationEvents EscalationEvent[]
resolution Resolution?
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@ -637,3 +642,81 @@ model EscalationEvent {
@@index([ticketId, createdAt])
@@map("escalation_events")
}
model Investigation {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
investigator String
findings Json
evidence Json?
internalNotes String? // never exposed on a customer-facing read — see
// specs/009-problem-resolution/spec.md FR-003
status String @default("open") // open | complete
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("investigations")
}
model RootCause {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
type String // technical | configuration | external_dependency | business |
// contributing_factor
description String
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("root_causes")
}
model Solution {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
proposed String
approved Boolean @default(false)
createdAt DateTime @default(now())
implementation SolutionImplementation?
verification SolutionVerification?
@@index([problemId, createdAt])
@@map("solutions")
}
model SolutionImplementation {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
notes String?
implementedBy String
implementedAt DateTime @default(now())
@@map("solution_implementations")
}
model SolutionVerification {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
method String // automated | technical_test | customer_confirmation | agent_confirmation
result String // success | failed
evidence Json?
verifiedAt DateTime @default(now())
@@map("solution_verifications")
}
model Resolution {
id String @id @default(cuid())
ticketId String @unique
ticket Ticket @relation(fields: [ticketId], references: [id])
outcome String
resolvedBy String // "ai" | agentId — see specs/009-problem-resolution/data-model.md
resolvedAt DateTime @default(now())
@@map("resolutions")
}
@@ -0,0 +1,80 @@
# Specification Quality Checklist: Problem Resolution
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-03
**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
- Scope is Phase 9 per `docs/10-implementation-roadmap.md`: Investigation → Root Cause →
Solution → Solution Implementation → Solution Verification → Resolution, plus customer
confirmation and reopen — the full doc 04 §3-9 workflow narrative, matching doc 06's "Domain:
Problem Resolution" schema exactly (no new fields invented beyond what's already documented).
- `src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}`
are the five real target stub directories for this feature (each currently a one-file stub
returning a hardcoded placeholder). `src/modules/problem-management/problems` was found to be a
**dead, unwired duplicate scaffold** for `Problem` — the real, actively-used `Problem` model and
repository already live in `ticketing/tickets` since 003 — this feature does not touch
`problem-management/problems`, matching this session's established discipline of only replacing
stubs a documented phase's roadmap item actually calls for.
- This feature explicitly closes a loop 008-sla-escalation's own spec.md left open in its Edge
Cases: "reopening... may need its own SLA-run-restart decision" — resolved here as "no new SLA
run on reopen" (FR-018), keeping 008's already-shipped 1:1-with-first-assignment boundary
unchanged rather than reopening (no pun intended) that feature's own scope.
- Verification-failure escalation deliberately reuses 003/007's existing `HUMAN_ESCALATION`
transition rather than inventing a new escalation-rule trigger type in 008's system — flagged
explicitly in Assumptions as a scope decision, not an oversight.
- All items pass; no revision iterations were needed.
## Implementation Notes (added during /speckit-implement)
- `fastify.authenticateProductIntegration` (002) turned out to unconditionally require a full
ticket-creation-shaped body (`source`/`problem` included) — reusing it as planned for
confirm-resolution/reopen made every call fail validation before token verification ran. Fixed
by extracting the shared verification logic (everything after the body's own shape is known)
into `verifyIntegrationIdentity` in `product-integration-auth.plugin.ts`, and adding a new,
narrower `identityOnlyRequestSchema` (`{productId, tenantId, userId}`) plus a new
`authenticateProductIntegrationIdentity` decorator built on the same shared function — purely
additive, `POST /v1/support/requests`'s own behavior is unchanged.
- Two pre-existing scaffold gaps were closed for this feature's FK validation needs:
`TicketsRepository` gained `findPendingCustomerConfirmationOlderThan` (the auto-close sweep's
own query), and `ticketsRepository`/`TicketsRepository` are now exported from
`ticketing/tickets`'s public `index.ts` (same "extend an existing module's public surface"
precedent as `problemsRepository` before it).
- Running this feature's own integration suite alongside 008's surfaced a real test-data-hygiene
bug in 008's already-committed test file: its second hierarchy node used `productScope: []`
(a wildcard matching *every* product, per `HierarchyNode`'s own documented scope-matching rule)
purely to have a valid, different target node for its own scoped-escalation test — but since
every test file's tickets share one live Postgres database, that wildcard node (and, similarly,
008's intentionally-global `SLAPolicy` test fixture) silently affected *other* files' tickets
running in the same suite, including this feature's own. Fixed by scoping that node to its own
test's product (it never needed to be global) and by deactivating the global `SLAPolicy`
fixture immediately after the one scenario that needs it, rather than leaving it live for the
rest of the file's run — both fixes are to `tests/integration/sla-escalation-flow.test.ts`
only, no production code changed. Full regression (`tests/unit` + `tests/integration` together,
172 tests) is clean except the 2 pre-existing MinIO-dependent attachment failures.
@@ -0,0 +1,67 @@
# Contract: Problem Resolution
Agent-facing write routes are gated by `fastify.authenticate` (known limitation inherited from
002-008). Customer-facing routes are gated by `fastify.authenticateProductIntegration` +
`fastify.checkIntegrationRateLimit` (002's inbound trust boundary, research.md) and additionally
verify the caller's token identifies the same tenant/user as the ticket's own recorded
`externalTenantId`/`externalUserId` — a `403` if they don't match.
## Investigation
- `POST /admin/problems/:problemId/investigations` — body `{ investigator, findings, evidence?,
internalNotes?, status? }` (`status` defaults to `open`). `404` if `problemId` doesn't exist.
- `GET /admin/problems/:problemId/investigations` — every investigation for the problem, newest
first, including `internalNotes` (agent-facing).
- `GET /problems/:problemId/investigations` — customer/public-safe variant: same list, with
`internalNotes` always omitted (FR-003).
## Root Cause
- `POST /admin/problems/:problemId/root-causes` — body `{ type, description }`. `400` if `type`
isn't one of the five validated values. `409` if no investigation exists yet for the problem.
## Solution
- `POST /admin/problems/:problemId/solutions` — body `{ proposed }`. `409` if no root cause
exists yet for the problem.
- `PATCH /admin/solutions/:solutionId/approve` — sets `approved: true`.
- `POST /admin/solutions/:solutionId/implementation` — body `{ notes?, implementedBy }`. `409` if
the solution isn't approved, or already has an implementation.
- `POST /admin/solutions/:solutionId/verification` — body `{ method, result, evidence? }`. `400`
if `method` isn't one of the four validated values. `409` if the solution has no implementation
yet, or already has a verification.
## Resolution
- `POST /admin/tickets/:ticketId/resolution` — body `{ outcome, resolvedBy }`. `409` if the
ticket's problem has no solution with a successful verification. Transitions the ticket to
`RESOLUTION_PENDING_CUSTOMER` on success.
- `POST /v1/support/tickets/:ticketId/confirm-resolution` — customer-facing (trust boundary
above). `409` if the ticket isn't in `RESOLUTION_PENDING_CUSTOMER`. Transitions to `RESOLVED`.
## Reopen
- `POST /v1/support/tickets/:ticketId/reopen` — customer-facing. `409` if the ticket isn't
`RESOLVED` or `CLOSED`.
- `POST /admin/tickets/:ticketId/reopen` — agent-facing, same precondition.
Both reopen routes transition `RESOLVED|CLOSED → REOPENED → IN_PROGRESS` (research.md's two-hop
decision) and touch nothing else — no new `SLARun`, no mutation of any prior investigation/root-
cause/solution/verification/resolution record (FR-018, SC-005).
## Guarantees (callable contract)
1. **Every investigation/root-cause/solution/implementation/verification/resolution record,
once created, is retrievable exactly as given and is never silently overwritten by a later
action in the same problem's lifecycle** (SC-001).
2. **`internalNotes` never appears in a customer-facing investigation read**, verified by a
direct comparison against the agent-facing read of the same record (SC-002).
3. **A `Resolution` can never be recorded without a successfully verified solution already on
file for the ticket's problem** (SC-003).
4. **A ticket in `RESOLUTION_PENDING_CUSTOMER` with no explicit confirmation reaches `RESOLVED`
within one auto-close job cycle of its configured waiting period elapsing** (SC-004).
5. **Reopening a ticket leaves every prior problem-resolution record and its `SLARun` (008)
untouched** (SC-005).
6. **A verification failure choosing escalation moves the ticket to `HUMAN_ESCALATION` through
003's existing state machine, and 007's orchestration re-runs automatically from that
transition alone** — no new escalation mechanism is introduced by this feature.
@@ -0,0 +1,99 @@
# Data Model: Problem Resolution
Every model below matches `docs/06-database-schema.md` "Domain: Problem Resolution" field-for-
field — no new columns invented (research.md explains the two places this was deliberately
considered and rejected: `Resolution.solutionId`, `Investigation.isCurrent`).
## Investigation
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `problemId` | `String` | FK to `Problem.id` (the existing `ticketing/tickets` one) |
| `investigator` | `String` | agentId — same non-FK free-text convention as `TicketMessage.authorRef` |
| `findings` | `Json` | structured, not free text (doc 04 §4) |
| `evidence` | `Json?` | |
| `internalNotes` | `String?` | never exposed on any customer-facing read (FR-003) |
| `status` | `String` | `open \| complete` |
| `createdAt` | `DateTime @default(now())` | ordering field for "most recent investigation" (research.md — no `isCurrent` flag) |
## RootCause
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `problemId` | `String` | FK to `Problem.id` |
| `type` | `String` | `technical \| configuration \| external_dependency \| business \| contributing_factor` — validated, not free text (FR-005) |
| `description` | `String` | |
| `createdAt` | `DateTime @default(now())` | |
## Solution
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `problemId` | `String` | FK to `Problem.id` |
| `proposed` | `String` | |
| `approved` | `Boolean @default(false)` | explicit approval action (FR-007) |
| `createdAt` | `DateTime @default(now())` | |
| `implementation` | `SolutionImplementation?` | inverse of the 1:1 below |
| `verification` | `SolutionVerification?` | inverse of the 1:1 below |
## SolutionImplementation
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `solutionId` | `String @unique` | 1:1 with `Solution` — a second implementation attempt is rejected (FR-007 Edge Cases), not overwritten |
| `notes` | `String?` | |
| `implementedBy` | `String` | agentId |
| `implementedAt` | `DateTime @default(now())` | |
## SolutionVerification
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `solutionId` | `String @unique` | 1:1 with `Solution` — at most one verification per solution (data-model note in Edge Cases) |
| `method` | `String` | `automated \| technical_test \| customer_confirmation \| agent_confirmation` — validated (FR-011) |
| `result` | `String` | `success \| failed` |
| `evidence` | `Json?` | |
| `verifiedAt` | `DateTime @default(now())` | |
## Resolution
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(cuid())` | |
| `ticketId` | `String @unique` | one resolution per ticket |
| `outcome` | `String` | |
| `resolvedBy` | `String` | `"ai"` or agentId |
| `resolvedAt` | `DateTime @default(now())` | |
No `solutionId` FK here (research.md) — the "a successfully verified solution exists for this
ticket's problem" precondition (FR-014) is enforced by the service layer at write time via a
join through `Ticket.problemId → Solution.problemId → Solution.verification.result`, not stored.
## Relations added to existing models
- `Problem.investigations Investigation[]`, `Problem.rootCauses RootCause[]`,
`Problem.solutions Solution[]` (all on the existing `ticketing/tickets`-owned `Problem` model)
- `Ticket.resolution Resolution?` (inverse of `Resolution.ticketId @unique`)
## Validation chain (service layer, not DB constraints — matches 003's own state-machine convention)
1. `RootCause` create → `Problem` must have at least one `Investigation` (FR-006).
2. `Solution` create → `Problem` must have at least one `RootCause` (FR-009).
3. `SolutionImplementation` create → the `Solution` must have `approved: true` (FR-008), and must
not already have an implementation (unique constraint surfaces this as a conflict).
4. `SolutionVerification` create → the `Solution` must already have a `SolutionImplementation`
(verification is of something implemented, doc 04 §7).
5. `Resolution` create → the `Ticket`'s `Problem` must have at least one `Solution` whose
`verification.result === 'success'` (FR-014).
## Out of scope for this data model (per spec.md Assumptions)
- No new `EscalationRule.triggerType` value for verification failure (research.md — reuses the
plain `HUMAN_ESCALATION` status transition instead).
- No `ResolutionPolicy`/scoped auto-close configuration entity — one system-wide config value
(research.md).
+134
View File
@@ -0,0 +1,134 @@
# Implementation Plan: Problem Resolution
**Branch**: `009-problem-resolution` | **Date**: 2026-09-03 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/009-problem-resolution/spec.md`
## Summary
Populate the five real `problem-management/{investigation,root-causes,solutions,resolutions,
verification}` stubs (each currently a one-file placeholder — `getInvestigationStatus` always
`PENDING`, `getResolutions` always `[]`, etc.) with the real doc-04-workflow engine: a strict
existence chain from investigation through root cause, solution, implementation, and
verification; a `Resolution` record gated on a successfully verified solution, moving the ticket
to `RESOLUTION_PENDING_CUSTOMER`; explicit customer confirmation (reusing 002's inbound trust
boundary) or a durable auto-close sweep (reusing the unregistered `CLEANUP` queue stub) into
`RESOLVED`; and a reopen path (customer or agent) that re-enters `IN_PROGRESS` through 003's
existing `REOPENED` state without touching any prior record or 008's `SLARun`.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), Zod, BullMQ (reused `CLEANUP` queue). No new
runtime dependency.
**Storage**: PostgreSQL via Prisma (new `Investigation`, `RootCause`, `Solution`,
`SolutionImplementation`, `SolutionVerification`, `Resolution` models). Reuses
`src/infrastructure/queue` for the auto-close sweep, same as 008's breach-detection job.
**Testing**: Vitest — unit tests for the existence-chain validation logic and the auto-close
due-window predicate; integration tests for the full sequential workflow (investigation through
resolution), the customer-confirmation and auto-close paths, and reopen leaving prior records and
an `SLARun` untouched.
**Target Platform**: Same Fastify modular monolith. Populates
`src/modules/problem-management/{investigation,root-causes,solutions,resolutions,verification}/`.
Adds two new customer-facing routes under `/v1/support/tickets/:ticketId/...` alongside the
existing `POST /v1/support/requests` (002).
**Project Type**: Backend service — single project.
**Constraints**: MUST reject out-of-order writes (root cause before investigation, etc. — FR-006/
FR-008/FR-009); MUST NOT expose `internalNotes` on any customer-facing read (FR-003); MUST gate
`Resolution` on a real successful verification (FR-014); MUST auto-close durably, not via an
in-memory timer (FR-016, Constitution Principle VII); MUST NOT create a new `SLARun` on reopen
(FR-018).
**Scale/Scope**: Five populated modules, one new BullMQ repeatable job (reusing an existing
queue), two new customer-facing routes reusing 002's trust boundary, one new agent-facing reopen
route. Explicitly excludes: a rendered customer confirmation UI (010's territory), a new
escalation-rule trigger type for verification failure (reuses 003/007's existing transition
instead), per-scope auto-close policy (one system-wide config value).
## 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 | Customer-facing routes authenticate via 002's product-integration token, never a SupportHub-native customer login — and additionally verify the token's tenant/user matches the ticket's own recorded values. | PASS |
| II. Configuration Over Hardcoding | The auto-close waiting period is env-configured (research.md), never a hardcoded number; validated-value sets (root-cause type, verification method) are Zod-enforced closed lists matching doc 04's own documented values, not ad hoc. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Five modules follow the standard shape; each references `ticketing/tickets`'s `Problem` (one-directional, already established), and the verification-failure-escalation path calls `ticketsService.updateStatus` directly rather than reaching into 008's `EscalationService` — no new module dependency edge into 008 at all. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | `Resolution.resolvedBy` accepts `"ai"` per doc 06's own shape, but this feature adds no AI-driven decision logic of its own — every gate (approval, verification result, escalate-vs-reinvestigate) is an explicit human/deterministic action. | PASS |
| V. Evidence-Based Verification | This principle's own domain — `SolutionVerification.evidence`/`Investigation.evidence` are exactly the durable evidence records Principle V requires before a resolution is trusted. | PASS |
| VI. Durable Audit & History | Every investigation attempt is its own preserved row (never overwritten); reopen produces two real, separately-audited status transitions rather than one collapsed hop. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Auto-close is a repeatable BullMQ job querying durable DB state (`Ticket.status`/`updatedAt`), never an in-memory timer — same discipline 008's breach-detection sweep already established. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | This principle's own domain — every investigation/root-cause/solution record is scoped to `Problem`, never `Ticket`, while `Resolution` (necessarily ticket-scoped, since a shared `Problem` could span multiple tickets) is the one exception doc 06 itself defines. | PASS |
| Technology & Platform Constraints | Prisma + Zod + existing BullMQ infrastructure only, no new dependency. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Worth calling out against Principle VIII
explicitly: `Resolution.ticketId` (not `problemId`) is the one place in this whole feature where
a record is ticket-scoped rather than problem-scoped — a deliberate, doc-06-defined exception
(a shared `Problem` can have multiple tickets, each needing its own outcome), not an
inconsistency with the rest of this feature's problem-scoped chain.
## Project Structure
### Documentation (this feature)
```text
specs/009-problem-resolution/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ └── schema.prisma # MODIFIED — add Investigation, RootCause,
│ Solution, SolutionImplementation,
│ SolutionVerification, Resolution
├── src/
│ ├── config/
│ │ └── problem-resolution.ts # NEW — autoCloseWaitingHours
│ ├── jobs/
│ │ └── cleanup/index.ts # REPLACED stub — schedules the repeatable
│ │ auto-close sweep (research.md)
│ └── modules/
│ ├── ticketing/tickets/ # MODIFIED — reopen calls updateStatus twice
│ └── problem-management/
│ ├── problems/ # UNTOUCHED — dead duplicate scaffold
│ │ (research.md) — not this feature's Problem
│ ├── investigation/ # REPLACED stub — full standard shape
│ ├── root-causes/ # REPLACED stub — full standard shape
│ ├── solutions/ # REPLACED stub — full standard shape
│ ├── verification/ # REPLACED stub — full standard shape
│ └── resolutions/ # REPLACED stub — full standard shape,
│ including the auto-close sweep + the two
│ new customer-facing routes
└── tests/
├── unit/problem-management/ # existence-chain validation, auto-close
│ due-window predicate
└── integration/ # full sequential workflow, customer
confirmation, auto-close, reopen
```
**Structure Decision**: Single project. Every module gets the full standard shape (each has its
own real CRUD/read surface, unlike 007's internal-only `routing`) — matching 008's precedent for
a multi-module feature where every module has genuine callers beyond another module in the same
feature.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,72 @@
# Quickstart: Validating Problem Resolution
Prerequisites: migrations applied; a ticket created per 003-ticketing's own quickstart (this
feature works against its `problemId`).
## Scenario 1 — structured investigation, preserved across attempts (User Story 1)
1. `POST /admin/problems/:problemId/investigations` with findings/evidence/internalNotes.
**Expected**: `201`, retrievable via `GET /admin/problems/:problemId/investigations` with
every field intact.
2. `GET /problems/:problemId/investigations` (customer-safe variant). **Expected**: same rows,
`internalNotes` absent from every one.
3. Record a second investigation for the same problem. **Expected**: both rows remain, in order —
the first is never overwritten.
## Scenario 2 — root cause requires an investigation on file (User Story 2)
1. `POST /admin/problems/:problemId/root-causes` for a problem with no investigation.
**Expected**: `409`.
2. Repeat after Scenario 1's investigation exists. **Expected**: `201`, `type` one of the five
validated values.
3. Repeat with an invalid `type`. **Expected**: `400`.
## Scenario 3 — solution proposed, approved, implemented as distinct states (User Story 3)
1. `POST /admin/problems/:problemId/solutions` before any root cause exists. **Expected**: `409`.
2. Repeat after Scenario 2's root cause exists. **Expected**: `201`, `approved: false`.
3. `POST /admin/solutions/:solutionId/implementation` before approval. **Expected**: `409`.
4. `PATCH /admin/solutions/:solutionId/approve`, then repeat step 3. **Expected**: `201`.
5. Repeat step 3 again (a second implementation). **Expected**: `409`.
## Scenario 4 — verification, and what happens on failure (User Story 4)
1. `POST /admin/solutions/:solutionId/verification` with `result: success`. **Expected**: `201`.
2. On a different solution (Scenario 3 repeated for a fresh problem), verify with
`result: failed`. **Expected**: `201`, but no `Resolution` can be recorded referencing it
(Scenario 5, step 1).
3. On the failed-verification path, request a fresh investigation. **Expected**: a new
`Investigation` row for the same problem, the original untouched.
4. On the failed-verification path, request escalation instead. **Expected**: the ticket
transitions to `HUMAN_ESCALATION`, and (007) is automatically assigned from that transition
alone — no separate escalation call needed.
## Scenario 5 — resolution, customer confirmation, and auto-close (User Story 5)
1. `POST /admin/tickets/:ticketId/resolution` for a ticket whose problem has no successfully
verified solution. **Expected**: `409`.
2. Repeat once Scenario 4 step 1's successful verification exists. **Expected**: `201`, ticket
status becomes `RESOLUTION_PENDING_CUSTOMER`.
3. `POST /v1/support/tickets/:ticketId/confirm-resolution` with the customer's own token.
**Expected**: `200`, ticket status becomes `RESOLVED`.
4. Repeat steps 1-2 for a second ticket; instead of confirming, directly age the ticket's
`updatedAt` past the configured waiting period and run the auto-close sweep.
**Expected**: ticket status becomes `RESOLVED` without any explicit confirmation call.
## Scenario 6 — reopen (User Story 6)
1. `POST /v1/support/tickets/:ticketId/reopen` on the `RESOLVED` ticket from Scenario 5.
**Expected**: `200`, ticket status becomes `IN_PROGRESS` (via `REOPENED`).
2. `GET /tickets/:ticketId/resolution` (or the admin equivalent). **Expected**: the original
`Resolution` record is still present, unchanged.
3. If the ticket has an `SLARun` (008) from its original assignment, **Expected**: it is
unchanged — no new run created, its status exactly what it was before the reopen.
4. `POST /admin/tickets/:ticketId/reopen` on a `CLOSED` ticket, as an agent. **Expected**: same
`REOPENED → IN_PROGRESS` result, this time attributed to the agent, not `"customer"`.
## What "done" looks like
All six scenarios pass, together demonstrating every functional requirement and success
criterion in `spec.md` — including SC-004's auto-close job cycle and SC-005's "reopen touches
nothing else" guarantee, both of which need direct-DB-state manipulation (not just waiting) to
verify without a multi-hour real-time test run.
+189
View File
@@ -0,0 +1,189 @@
# Phase 0 Research: Problem Resolution
## Decision: Module placement — five real stubs; `problem-management/problems` is dead scaffold, left untouched
- **Decision**: `problem-management/{investigation,root-causes,solutions,resolutions,verification}`
(each a one-file, hardcoded-placeholder stub) are populated directly. `problem-management/
problems` — a second, never-wired `ProblemsRepository.findAll()` returning `[]` — is left
exactly as-is; it is not this feature's `Problem` (that one has lived in, and been used since,
`ticketing/tickets/repository/problems.repository.ts`, created by 003-ticketing).
- **Rationale**: Every real caller of `Problem` (003's ticket creation, 005's AI diagnosis, 007's
routing context, this feature's own investigation/root-cause/solution FKs) already resolves it
through `ticketing/tickets`'s repository. `problem-management/problems` was never imported by
anything (confirmed by search) — a leftover from the original pre-spec-driven scaffold, the same
class of dead placeholder this codebase's discipline is to leave alone unless a documented
phase's roadmap item actually names it. Phase 9's own roadmap line names Investigation/
RootCause/Solution/.../Resolution, not a second Problem implementation.
- **Alternatives considered**: Migrating `Problem` into `problem-management/problems` and
re-pointing every existing caller — rejected as an unrequested, high-blast-radius refactor of
working code three prior features already depend on, for a rename with no functional benefit.
## Decision: Investigation is version-row-per-attempt, matching 004/007's established pattern
- **Decision**: Every investigation (the first one, and any created after a failed verification,
FR-013) is its own `Investigation` row for the same `problemId` — never an update to a prior
row. "Which investigation is current" for a problem is simply the most recent by `createdAt`.
- **Rationale**: Doc 06's `Investigation` model has no version/current-row field at all (unlike
`KnowledgeEntry.isCurrentVersion` or `Assignment.isCurrent`) — the simplest reading consistent
with "each investigation attempt is real, preserved history" (spec.md US1) is an unbounded,
append-only set of rows per problem, ordered by `createdAt`, with no additional schema needed.
- **Alternatives considered**: Adding an `isCurrent` boolean to `Investigation` (mirroring 007's
refinement of `Assignment`) — rejected as unrequested schema embellishment; nothing in spec.md
requires querying "the current investigation" faster than an `orderBy: createdAt desc, take: 1`
already provides, and doc 06 doesn't define the field.
## Decision: A strict existence chain — investigation → root cause → solution → implementation → verification
- **Decision**: Each write validates its own prerequisite exists for the same `problemId`
(root cause requires an investigation; solution requires a root cause) or the same `solutionId`
(implementation requires an approved solution; verification requires an implementation) —
resolve-or-reject, the same "don't invent a default, don't skip a step" discipline this
codebase has used for every other FK-shaped precondition since 002.
- **Rationale**: Doc 04 §4-8 describes a strictly sequential workflow ("Investigation → Root
Cause → Solution → Verification → Resolution") — the acceptance scenarios (spec.md US2-US4)
explicitly test that skipping a step is rejected, not silently tolerated.
- **Alternatives considered**: Allowing any order and only validating at Resolution time —
rejected; doc 04's own workflow diagram is sequential by design, and rejecting out-of-order
writes early gives a caller a much clearer error than a late rejection at the final step.
## Decision: `Resolution` has no stored FK back to `Solution` — matches doc 06's shape exactly
- **Decision**: `Resolution` is validated at write time (a successfully verified solution must
exist for the ticket's `problemId`) but the `Resolution` row itself stores no `solutionId`
doc 06's own `Resolution` model has no such field (`id, ticketId @unique, outcome, resolvedBy,
resolvedAt` only).
- **Rationale**: Not a gap to fill — the existence check is enforced by the service layer at
write time (the same "validate at the boundary, don't over-model the schema" approach 002/003
already use for non-FK cross-references like `TicketMessage.authorRef`), and doc 06 is
explicit about what `Resolution` stores. Inventing a FK doc 06 doesn't define would be scope
creep, not correctness.
- **Alternatives considered**: Adding `solutionId` to `Resolution` as an additive refinement
(this codebase's own established pattern for filling real gaps, e.g. 008's
`firstResponseBreachedAt`) — considered and rejected specifically here, since unlike 008's gap
(a genuinely missing idempotency guard with no other way to express it), the existence check
this feature needs is fully satisfiable without a stored reference — a real refinement changes
*behavior*; this one would only change provenance-tracing convenience nothing in spec.md asks
for.
## Decision: Verification-failure escalation reuses 003/007's `HUMAN_ESCALATION` transition directly
- **Decision**: When an agent chooses escalation on a failed verification (FR-013), this feature
calls `ticketsService.updateStatus(ticketId, 'HUMAN_ESCALATION', ...)` — the same transition
001-caliber tickets already support — and does nothing else. 007's existing `TICKET_UPDATED`
subscriber (`src/events/handlers/index.ts`) picks this up and runs orchestration automatically,
exactly as it does for every other route into `HUMAN_ESCALATION`.
- **Rationale**: "Solution verification failed" is not one of doc 05 §6's ten escalation-rule
trigger types 008 already modeled (`first_response_breach | resolution_breach | inactivity |
priority_increase | customer_escalation | repeated_reopen | manual | product_defect |
dependency_timeout | critical_incident`) — inventing an eleventh type, a new `EscalationEvent`,
and a new call into 008's `EscalationService` for one internal flow this feature owns would be
real, unrequested coupling across a module boundary 008 was deliberately built not to need.
Reusing the plain status transition is exactly the mechanism 007 already exists to react to.
- **Alternatives considered**: Adding `solution_verification_failed` as an eleventh
`EscalationRule.triggerType` and calling 008's `EscalationService.handleBreach`-equivalent —
rejected; 008 is already shipped and committed with a closed, deliberately-bounded set of two
real trigger types (spec.md 008 Assumptions) — retroactively expanding it from within a later
feature, for a flow that doesn't need the rule-matching machinery at all (there's exactly one
outcome: HUMAN_ESCALATION, not "evaluate every matching rule"), is unjustified complexity.
## Decision: Customer-facing confirm-resolution and reopen reuse 002's trust boundary via a new, narrower `authenticateProductIntegrationIdentity` decorator; agent reopen uses `fastify.authenticate`
- **Decision**: Two new customer-reachable routes, `POST /v1/support/tickets/:ticketId/confirm-
resolution` and `POST /v1/support/tickets/:ticketId/reopen`, are gated by a new
`fastify.authenticateProductIntegrationIdentity` + the existing `fastify.
checkIntegrationRateLimit` preHandler pair, then additionally verify the caller's
`externalTenantId`/`externalUserId` (from `request.reqContext`) matches the ticket's own
recorded values before allowing the action. A third route,
`POST /admin/tickets/:ticketId/reopen`, is gated by `fastify.authenticate` for the
agent-initiated reopen path FR-017 also requires. Confirm-resolution has no agent-initiated
equivalent (spec.md US5 only ever has the customer confirming explicitly; an agent's own path
to close things out is the existing auto-close job, not a manual override this feature adds).
- **Implementation note (found during /speckit-implement, not anticipated at planning time)**:
`fastify.authenticateProductIntegration` (002) unconditionally validates `request.body` against
the full `inboundRequestSchema` — which requires `source`/`problem`, ticket-*creation*-specific
fields neither new route has any reason to send. Reusing it as originally planned made every
call to these two routes fail Zod validation before token verification ever ran. Fixed by
extracting steps 2-10 of `authenticateProductIntegration`'s logic (everything after the body's
own shape is known — token verification, replay/revocation/scope checks, `reqContext`
population) into a shared `verifyIntegrationIdentity` function in
`product-integration-auth.plugin.ts`, and adding a new `identityOnlyRequestSchema`
(`{productId, tenantId, userId}` only) plus a new `authenticateProductIntegrationIdentity`
decorator that parses that narrower shape and calls the same shared function. The original
`authenticateProductIntegration` (and `POST /v1/support/requests`) is unchanged in behavior —
purely additive.
- **Rationale**: `inbound-request.routes.ts`'s own comment ("Acting further on the ticket...
belongs to later features that don't exist yet") names exactly this need — 002's trust boundary
was already built to be extended, just not with a body shape that happened to fit an action on
an *existing* ticket. Requiring the caller's own token to match the ticket's tenant/user
prevents one customer from confirming or reopening another tenant's ticket.
- **Alternatives considered**: A single unauthenticated or `fastify.authenticate`-gated endpoint
for both actor types — rejected; a customer is never an authenticated SupportHub principal
(Constitution Principle I — SaaS is the sole identity authority for its own end users), so reusing
the internal-agent auth mechanism for a customer-initiated action would be a security regression,
not a simplification. Sending a dummy `source`/`problem` value to satisfy the existing schema —
rejected as a hack that would misrepresent the request and pollute `validatedInboundBody` for a
handler that was never meant to receive it.
## Decision: Auto-close is a repeatable BullMQ job on the existing, unclaimed `CLEANUP` queue
- **Decision**: `src/jobs/cleanup/index.ts` (currently a log-only stub registered on
`QueueName.CLEANUP`, never wired into `queue.bootstrap.ts`) is extended the same way 008
extended `src/jobs/sla/index.ts` — a repeatable job (every 5 minutes; less time-sensitive than
008's breach detection, since this only ever fires after a multi-hour/day waiting period) whose
processor calls a single, directly-callable `ResolutionsService.runAutoCloseSweep()` — querying
every ticket with `status: 'RESOLUTION_PENDING_CUSTOMER'` whose most recent status-change
(`Ticket.updatedAt`) is older than the configured waiting period, transitioning each to
`RESOLVED`.
- **Rationale**: `CLEANUP` is exactly this kind of periodic housekeeping sweep, and — like
`SLA`/`ESCALATION` before this feature — was defined and left completely unregistered since the
original scaffold. Reusing it needs no new `QueueName` value. A directly-callable sweep method
(not only reachable through a running worker) is what let 008's breach-detection tests avoid a
real wait; the same shape applies here.
- **Alternatives considered**: A per-ticket delayed job scheduled at the moment `Resolution` is
recorded — rejected for the same reason 008 rejected the equivalent per-run design: a
reopened-then-re-resolved ticket, or a resolution recorded twice in error, would each need
their own cancel/reschedule bookkeeping a polling sweep avoids entirely.
## Decision: The auto-close waiting period is one system-wide config value, not a per-scope policy
- **Decision**: `env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS` (default `72`, i.e. 3 days), exposed via
a new `src/config/problem-resolution.ts` — `problemResolutionConfig.autoCloseWaitingHours` —
mirroring `orchestrationConfig.defaultStrategy`'s exact shape.
- **Rationale**: Doc 04 §9 describes "a configured waiting period" in the singular, system-wide
sense — not a per-product/category policy table the way 008's `SLAPolicy` is; doc 06 defines no
entity for a scoped auto-close policy. A single env-configured default (Constitution Principle
II — never hardcoded, but not over-modeled into a policy table nothing asks for) is the
proportionate reading.
- **Alternatives considered**: A `ResolutionPolicy` table scoped like `SLAPolicy` — rejected as
speculative; nothing in doc 04/06 describes per-context auto-close variation, unlike SLA's
explicit product/category/priority scoping in doc 06's own `SLAPolicy` shape.
## Decision: The reopen transition is two real, separately-audited status updates
- **Decision**: Reopening calls `ticketsService.updateStatus(ticketId, 'REOPENED', ...)` followed
immediately by `ticketsService.updateStatus(ticketId, 'IN_PROGRESS', ...)` — two real
transitions through 003's existing state machine (both already valid edges:
`RESOLVED|CLOSED → REOPENED` and `REOPENED → IN_PROGRESS`), each producing its own
`SYSTEM_EVENT` ticket message and `TICKET_UPDATED` publish, rather than a single hop straight
to `IN_PROGRESS` that would skip recording the reopen milestone itself.
- **Rationale**: Doc 04 §9's own phrasing — "reopen... should re-enter the appropriate lifecycle
stage" — matches the state machine's own two-hop shape exactly; both hops are independently
meaningful audit events (Constitution Principle VI), not one compound action worth collapsing.
- **Alternatives considered**: A single, direct `RESOLVED|CLOSED → IN_PROGRESS` transition
(bypassing `REOPENED` as a status value entirely) — rejected; 003's state machine doesn't even
define that edge (only `REOPENED → IN_PROGRESS`), and skipping the `REOPENED` status would
erase a real lifecycle milestone doc 04 explicitly names.
## Decision: 008's SLA run is explicitly left untouched by reopen — no new decision needed here
- **Decision**: Reopening a ticket does not create, restart, or modify its existing `SLARun`
(008) in any way — the run (if one exists) simply remains in whatever terminal state it was
already in (`completed` or `breached`).
- **Rationale**: 008's own spec.md already closed this decision from its side ("SLA runs are 1:1
with a ticket's first successful assignment only... out of scope for this feature to define a
new run automatically") — this feature's job is only to confirm that boundary still holds, not
to re-litigate it. FR-018/SC-005 make this an explicit, tested guarantee rather than an
accidental side effect of simply not writing any `SLARun`-touching code.
- **Alternatives considered**: Restarting the SLA run on reopen — explicitly out of scope per
008's own spec; would require this feature to modify 008's already-shipped module, which
nothing in Phase 9's roadmap line asks for.
+315
View File
@@ -0,0 +1,315 @@
# Feature Specification: Problem Resolution
**Feature Branch**: `009-problem-resolution`
**Created**: 2026-09-03
**Status**: Draft
**Input**: User description: "Phase 9 of docs/10-implementation-roadmap.md: Investigation/
RootCause/Solution/SolutionImplementation/SolutionVerification/Resolution models and workflows,
customer confirmation + reopen flow. Per docs/04-ticketing-and-problem-management.md §3-9 and
docs/06-database-schema.md 'Domain: Problem Resolution'."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An agent records structured investigation findings (Priority: P1)
An agent investigating a problem records findings, evidence, and internal notes as a structured
record — not a free-text blob buried in a message — with its own status (`open`/`complete`). A
problem can have more than one investigation attempt over its lifetime, each preserved, not
overwritten.
**Why this priority**: Everything downstream (root cause, solution, verification) reads from or
references an investigation; nothing else in this feature can start without one existing first.
**Independent Test**: Record an investigation with findings and evidence for a problem; confirm
it's retrievable exactly as given, with its own investigator and timestamp.
**Acceptance Scenarios**:
1. **Given** an agent records an investigation with findings, **When** it's saved, **Then** it's
retrievable with `investigator`, `findings`, `evidence`, `internalNotes`, and `status` exactly
as given.
2. **Given** a problem already has a completed investigation, **When** a new investigation is
started for the same problem (e.g., after a failed verification, User Story 4), **Then** the
prior investigation's record is preserved unchanged — a new investigation is its own row, never
an overwrite of the earlier one.
3. **Given** `internalNotes` on an investigation, **When** any customer-facing view is composed,
**Then** that data is never included — internal notes are agent/admin-only, same "never shown
to customers" discipline as ticketing's `INTERNAL_NOTE` message type (004 §10).
---
### User Story 2 - An agent records a root cause, separate from the investigation (Priority: P1)
Once findings point to a cause, the agent records a root cause as its own record — distinct from
the investigation that surfaced it — typed as technical, configuration, external-dependency,
business, or a contributing factor.
**Why this priority**: A solution (User Story 3) is a response to a specific, recorded cause —
without one, "solving" a problem has nothing to be checked against.
**Independent Test**: Record a root cause of a given type for a problem with an investigation
already on file; confirm it's retrievable and distinct from the investigation record.
**Acceptance Scenarios**:
1. **Given** a problem with an investigation on file, **When** an agent records a root cause with
a type and description, **Then** it's retrievable as its own record, never merged into the
investigation's own fields.
2. **Given** a root cause type outside the five documented values, **When** it's submitted,
**Then** it's rejected — the type is a closed, validated set, not free text.
---
### User Story 3 - An agent proposes, approves, and implements a solution, each as its own state (Priority: P1)
A solution moves through distinct states — proposed, approved, implemented — never collapsed into
one mutable blob. Implementation is its own record: who implemented it, when, and any notes,
kept separate from the proposal itself.
**Why this priority**: Verification (User Story 4) and resolution (User Story 5) both need a
concrete, dated implementation record to verify and resolve against.
**Independent Test**: Propose a solution, approve it, then record its implementation; confirm all
three states are independently visible on the same solution record/its implementation relation.
**Acceptance Scenarios**:
1. **Given** a root cause on file, **When** an agent proposes a solution, **Then** it's stored
with `approved: false` by default.
2. **Given** a proposed solution, **When** it's approved, **Then** `approved` becomes `true`
approval is a distinct, explicit action, never implied by implementation happening.
3. **Given** an approved solution, **When** an agent records its implementation (notes,
implementer, timestamp), **Then** a `SolutionImplementation` record is created, one-to-one
with the solution — attempting a second implementation record for the same solution is
rejected, not silently overwritten.
4. **Given** a solution that has not been approved, **When** an implementation is attempted,
**Then** it's rejected — implementation without approval is never allowed.
---
### User Story 4 - A solution is verified; failure re-opens investigation or escalates (Priority: P2)
After implementation, the solution is verified by one of several methods (automated check,
technical test, customer confirmation, agent confirmation). A successful verification clears the
way to resolution (User Story 5). A failed verification either re-opens investigation (a fresh
investigation record for the same problem) or escalates the ticket — an agent's explicit choice,
not an automatic guess.
**Why this priority**: Depends on User Story 3 (something implemented to verify). Recording a
resolution without ever having verified anything would misrepresent what was actually confirmed.
**Independent Test**: Verify an implemented solution as failed; confirm no `Resolution` can be
recorded from it, and that either a fresh investigation exists or the ticket has been escalated,
per the agent's chosen path.
**Acceptance Scenarios**:
1. **Given** an implemented solution, **When** it's verified with `result: success`, **Then** a
`SolutionVerification` record is created (method, result, evidence, timestamp), one-to-one
with the solution.
2. **Given** an implemented solution, **When** it's verified with `result: failed`, **Then** no
resolution can reference this solution's verification as successful — a failed verification is
a real, recorded outcome, not silently discarded.
3. **Given** a failed verification and the agent chooses re-investigation, **When** that choice is
made, **Then** a new `Investigation` record is created for the same problem (User Story 1's own
"each attempt is its own row" rule).
4. **Given** a failed verification and the agent chooses escalation instead, **When** that choice
is made, **Then** the ticket transitions to `HUMAN_ESCALATION` through 003-ticketing's existing
state machine — 007's orchestration re-runs automatically from that transition alone, exactly
as it already does for any other route into `HUMAN_ESCALATION`; this feature does not invent a
second escalation mechanism alongside 008's.
---
### User Story 5 - A resolution is recorded, with configurable customer confirmation or auto-close (Priority: P1)
Once a solution is verified successful, a `Resolution` record captures the final outcome for the
ticket. Depending on configuration, the ticket either waits for explicit customer confirmation
before closing, or auto-closes after a configured waiting period with no response.
**Why this priority**: This is the feature's actual deliverable from the customer's point of
view — everything before this is agent-facing work product.
**Independent Test**: Record a resolution for a ticket with a successfully verified solution;
confirm the ticket reaches `RESOLUTION_PENDING_CUSTOMER`, then either an explicit confirmation or
the configured waiting period elapsing moves it to `RESOLVED`.
**Acceptance Scenarios**:
1. **Given** a solution with a successful verification, **When** an agent records a resolution,
**Then** a `Resolution` record is created (`outcome`, `resolvedBy`) and the ticket transitions
to `RESOLUTION_PENDING_CUSTOMER`.
2. **Given** a ticket in `RESOLUTION_PENDING_CUSTOMER`, **When** the customer explicitly confirms,
**Then** the ticket transitions to `RESOLVED`.
3. **Given** a ticket in `RESOLUTION_PENDING_CUSTOMER` with no customer response, **When** the
configured auto-close waiting period elapses, **Then** the ticket transitions to `RESOLVED`
automatically — durably, via a background job, never an in-memory timer (Constitution
Principle VII, same discipline 008's breach-detection job already established).
4. **Given** a `Resolution` is attempted without a successfully verified solution on file,
**When** it's attempted, **Then** it's rejected — a resolution must be backed by real,
recorded verification, never asserted on its own.
---
### User Story 6 - A resolved or closed ticket can be reopened (Priority: P2)
A customer or agent can reopen a `RESOLVED` or `CLOSED` ticket, which re-enters the appropriate
point in the lifecycle rather than starting over from `NEW`.
**Why this priority**: Depends on User Story 5 (a ticket has to have reached a closeable state
before reopening it means anything). Closes the loop 008 explicitly left open ("reopening... may
need its own SLA-run-restart decision").
**Independent Test**: Reopen a `RESOLVED` ticket; confirm it transitions to `REOPENED` and then
into an active lifecycle state, and that the prior resolution record remains on file, unaltered.
**Acceptance Scenarios**:
1. **Given** a `RESOLVED` or `CLOSED` ticket, **When** the customer or an agent reopens it,
**Then** the ticket transitions to `REOPENED` and then to `IN_PROGRESS` (003's existing
`REOPENED → IN_PROGRESS` transition) — never back to `NEW`.
2. **Given** a ticket is reopened, **When** the prior `Resolution` record is checked, **Then** it
remains on file exactly as it was — reopening never deletes or mutates history.
3. **Given** a ticket already has an SLA run (008) from its original assignment, **When** it's
reopened, **Then** no new SLA run is created and the existing one is left exactly as it was
(008's own Assumptions: "SLA runs are 1:1 with a ticket's first successful assignment only") —
this feature does not retroactively expand that boundary.
---
### Edge Cases
- What happens if an agent tries to record a root cause before any investigation exists for the
problem? Rejected — a root cause without a preceding investigation has nothing to be grounded
in (FR-006).
- What happens if a solution is proposed for a problem with no root cause on file? Rejected, same
reasoning as above (FR-009).
- What happens if two verification attempts are recorded for the same solution? Rejected — like
`SolutionImplementation`, `SolutionVerification` is one-to-one with its solution (doc 06's own
`@unique` on `solutionId`); a second verification attempt on an already-verified solution is out
of scope for this feature (re-verification of a previously-verified solution is not a flow doc
04 describes).
- What happens to a ticket's messages/attachments/assignment history when it's reopened? Nothing
— reopening only affects `Ticket.status`; every other record (007's `Assignment`, 008's
`SLARun`, this feature's own `Investigation`/`RootCause`/`Solution`/`Resolution` records) is
untouched by the reopen transition itself.
- What happens if the configured auto-close waiting period is set to zero or is unconfigured?
Zero is a valid configuration (auto-close as soon as the sweep next runs); unconfigured falls
back to a system default (Constitution Principle II — configuration over hardcoding, but a
default value must exist so the sweep job always has something to compare against).
- What happens if a ticket is reopened more than once? Each reopen is its own `REOPENED →
IN_PROGRESS` transition — no cap on how many times a ticket can be reopened is introduced by
this feature (counting reopens toward an escalation trigger remains 008's already-documented,
deliberately deferred `repeated_reopen` trigger type — this feature does not wire it up).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let an agent record an investigation (investigator, findings,
evidence, internal notes, status) for a problem.
- **FR-002**: Each investigation MUST be its own durable record — a new investigation for the
same problem (e.g., after a failed verification) MUST NOT overwrite a prior one.
- **FR-003**: Internal notes on an investigation MUST NEVER be exposed through any
customer-facing read path.
- **FR-004**: The system MUST let an agent record a root cause (type, description) for a
problem, as a record distinct from any investigation.
- **FR-005**: A root cause's type MUST be validated against the five documented values
(technical, configuration, external_dependency, business, contributing_factor) — never
free text.
- **FR-006**: Recording a root cause for a problem with no investigation on file MUST be
rejected.
- **FR-007**: The system MUST let an agent propose a solution for a problem (`approved: false`
by default), approve it explicitly, and record its implementation (notes, implementer,
timestamp) as a separate, one-to-one record.
- **FR-008**: Recording an implementation for a solution that has not been approved MUST be
rejected.
- **FR-009**: Proposing a solution for a problem with no root cause on file MUST be rejected.
- **FR-010**: The system MUST let an agent record a verification (method, result, evidence) for
an implemented solution, as a one-to-one record.
- **FR-011**: A verification's method MUST be validated against the four documented values
(automated, technical_test, customer_confirmation, agent_confirmation).
- **FR-012**: A failed verification MUST NOT permit a `Resolution` to be recorded against that
solution.
- **FR-013**: On a failed verification, the system MUST support either starting a fresh
investigation for the same problem (FR-002) or transitioning the ticket to `HUMAN_ESCALATION`
(003's existing state machine, triggering 007's existing orchestration subscriber
automatically) — the choice between the two is the recording agent's, not automatic.
- **FR-014**: The system MUST let an agent record a `Resolution` (outcome, resolvedBy) for a
ticket, only when a successfully verified solution exists for its problem — this transitions
the ticket to `RESOLUTION_PENDING_CUSTOMER`.
- **FR-015**: The system MUST let a customer explicitly confirm a pending resolution, transitioning
the ticket to `RESOLVED`.
- **FR-016**: The system MUST auto-transition a ticket from `RESOLUTION_PENDING_CUSTOMER` to
`RESOLVED` after a configured waiting period with no explicit customer confirmation — detected
by a durable background job, never an in-memory timer (Constitution Principle VII).
- **FR-017**: The system MUST let a customer or agent reopen a `RESOLVED` or `CLOSED` ticket,
transitioning it to `REOPENED` and then `IN_PROGRESS` — never back to `NEW`, and never
mutating any prior investigation/root-cause/solution/verification/resolution record.
- **FR-018**: Reopening a ticket MUST NOT create a new SLA run (008's existing 1:1-with-first-
assignment boundary is unchanged by this feature).
### Key Entities
- **Investigation**: A structured, per-attempt record of what an agent found while investigating
a problem — findings, evidence, internal notes — never free text buried in a message; a problem
can have more than one, each preserved.
- **Root Cause**: Why the problem happened, typed and recorded separately from what was found
(the investigation).
- **Solution**: What's proposed to fix the root cause, moving through proposed → approved states
explicitly.
- **Solution Implementation**: The one-to-one record of a solution actually being carried out —
who, when, and any notes — distinct from the proposal.
- **Solution Verification**: The one-to-one record of whether the implementation actually worked,
by which method.
- **Resolution**: The final, ticket-level outcome — distinct from the solution (what was done)
and the verification (whether it worked).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of investigation/root-cause/solution/implementation/verification/resolution
records, once created, remain retrievable exactly as given — no field silently dropped or
overwritten by a later action in the same problem's lifecycle.
- **SC-002**: 100% of internal-notes fields are absent from every customer-facing response,
verified by a direct comparison of the agent-facing and customer-facing read paths for the same
investigation.
- **SC-003**: 100% of resolutions recorded without a successfully verified solution on file are
rejected.
- **SC-004**: 100% of tickets reaching `RESOLUTION_PENDING_CUSTOMER` with no explicit customer
confirmation reach `RESOLVED` within one auto-close job cycle of their configured waiting
period elapsing.
- **SC-005**: 100% of reopened tickets leave every prior investigation/root-cause/solution/
verification/resolution record and SLA run untouched.
## Assumptions
- **This feature does not build a customer-facing confirmation UI** — "explicit customer
confirmation" (FR-015) is an API action a caller (a future customer portal, or 008/010's own
future UI work) can invoke; this feature's own scope is the backend transition and the
auto-close fallback, not a rendered confirmation page (010 — Agent/Admin UI — is a separate,
later roadmap phase).
- **Verification-failure escalation reuses 003-ticketing's existing `HUMAN_ESCALATION` state
transition and 007's already-automatic orchestration subscriber directly** — it does not create
a new `EscalationEvent` through 008's rule-based mechanism, since "solution verification failed"
is not one of doc 05 §6's ten escalation trigger types 008 modeled; inventing an eleventh type
for a single feature's own internal flow was judged unnecessary scope, not an oversight.
Re-escalation through the plain ticket-status transition is exactly what 007 was already built
to react to — no new coupling is introduced.
- **The auto-close waiting period is a single, system-wide configuration value** (Principle II —
configuration over hardcoding), not scoped per product/category the way 008's SLA policies are;
doc 04 §9 describes it as "a configured waiting period," not a per-context policy table, and
nothing in doc 06's schema defines a per-scope auto-close entity to resolve against.
- **`repeated_reopen` (008's already-inert escalation trigger type) is still not wired up by this
feature** — reopening increments no counter and triggers no escalation rule; this remains
future work exactly as 008's own Assumptions already documented, not something this feature
silently expands into.
- **A second verification attempt on an already-verified solution is out of scope** — doc 06's
`SolutionVerification.solutionId` is `@unique`, meaning at most one verification record per
solution; if a first verification fails and the agent chooses re-investigation (FR-013), any
new solution that comes out of that fresh investigation cycle gets its own new `Solution` row
(User Story 3) with its own verification slot — never a second write to the original one.
+355
View File
@@ -0,0 +1,355 @@
---
description: "Task list for 009-problem-resolution"
---
# Tasks: Problem Resolution
**Input**: Design documents from `specs/009-problem-resolution/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/problem-resolution-contract.md](./contracts/problem-resolution-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks. This feature's pure logic is the existence-chain
validation (each step's precondition) and the auto-close due-window predicate; the rest is
sequential-workflow wiring best proven end-to-end against real Postgres.
**Organization**: Tasks are grouped by user story (US1 = P1 investigation, US2 = P1 root cause,
US3 = P1 solution states, US4 = P2 verification, US5 = P1 resolution/confirmation/auto-close,
US6 = P2 reopen).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [x] T001 [P] Populate `src/modules/problem-management/investigation/` with the full standard
shape (`controller/`, `routes/`, `schema/`, `repository/`, `service/`, `types/`, `mapper/`,
`constants/`, `index.ts`), replacing the `InvestigationService.getInvestigationStatus` stub
- [x] T002 [P] Populate `src/modules/problem-management/root-causes/` the same way, replacing the
`RootCausesService.getRootCause` stub
- [x] T003 [P] Populate `src/modules/problem-management/solutions/` the same way, replacing the
`SolutionsService.getSolutions` stub
- [x] T004 [P] Populate `src/modules/problem-management/verification/` the same way, replacing
the `VerificationService.verifySolution` stub
- [x] T005 [P] Populate `src/modules/problem-management/resolutions/` the same way, replacing the
`ResolutionsService.getResolutions` stub — this module additionally gets the auto-close
sweep and the two new customer-facing routes (later tasks)
- [x] T006 [P] Add `src/config/problem-resolution.ts` (`problemResolutionConfig
.autoCloseWaitingHours`, reading a new `RESOLUTION_AUTO_CLOSE_WAITING_HOURS` env var,
default `72`) and register it in `src/config/index.ts`'s re-export list
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema for every entity, shared by every user story.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [x] T007 Add `Investigation`, `RootCause`, `Solution`, `SolutionImplementation`,
`SolutionVerification`, `Resolution` models to `prisma/schema.prisma` per data-model.md,
plus `Problem.investigations`/`Problem.rootCauses`/`Problem.solutions` and
`Ticket.resolution` back-relations (depends on T001-T005)
- [x] T008 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T007 (depends on T007)
**Checkpoint**: Schema migrated. User stories can now be built.
---
## Phase 3: User Story 1 - Structured investigation, preserved across attempts (Priority: P1) 🎯 MVP (part 1)
**Goal**: Investigation CRUD with the version-row-per-attempt guarantee and internal-notes
exclusion from customer-facing reads.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T009 [US1] Integration test covering Quickstart Scenario 1 (create, retrieve with every
field intact, customer-safe variant omits `internalNotes`, a second investigation preserves
the first) against a real Postgres in `tests/integration/problem-resolution-flow.test.ts`
(depends on T008)
### Implementation for User Story 1
- [x] T010 [US1] Add `InvestigationRepository` (`create`, `findAllForProblem` ordered newest
first, `findMostRecentForProblem`) in `investigation/repository/` (depends on T008)
- [x] T011 [US1] Add Zod create schema (`investigator`, `findings`, `evidence?`,
`internalNotes?`, `status?`) in `investigation/schema/`
- [x] T012 [US1] Add `InvestigationService.record`/`listForProblem` (agent-facing, includes
`internalNotes`) and `listForProblemCustomerSafe` (strips `internalNotes`, FR-003) in
`investigation/service/` (depends on T010, T011)
- [x] T013 [US1] Add `POST/GET /admin/problems/:problemId/investigations` (gated by
`fastify.authenticate`) and `GET /problems/:problemId/investigations` (ungated, customer-
safe) routes in `investigation/controller/` + `routes/`, registered from `src/api/routes.ts`
(depends on T012)
- [x] T014 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
**Checkpoint**: Investigations can be recorded and read correctly, with the customer-safe
redaction guarantee in place.
---
## Phase 4: User Story 2 - Root cause requires an investigation on file (Priority: P1) 🎯 MVP (part 2)
**Goal**: RootCause CRUD gated on an existing investigation, with a validated type enum.
**Independent Test**: Quickstart Scenario 2.
### Tests for User Story 2
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (rejected with no investigation,
accepted after one exists, rejected with an invalid type) — implemented as the "Scenario 2"
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T009, T014)
- [x] T016 [P] [US2] Unit test for the type-validation Zod schema (five valid values, everything
else rejected) in `tests/unit/problem-management/root-cause-schema.test.ts`
### Implementation for User Story 2
- [x] T017 [US2] Add `RootCauseRepository` (`create`, `findAllForProblem`) in
`root-causes/repository/` (depends on T008)
- [x] T018 [US2] Add Zod create schema (`type` as a 5-value enum, `description`) in
`root-causes/schema/`
- [x] T019 [US2] Add `RootCausesService.record`: resolve-or-`409` on the problem having at least
one investigation (T010's `findMostRecentForProblem`, via `investigation`'s public
`index.ts`) — in `root-causes/service/` (depends on T012, T017, T018)
- [x] T020 [US2] Add `POST /admin/problems/:problemId/root-causes` route (gated by
`fastify.authenticate`) in `root-causes/controller/` + `routes/`, registered from
`src/api/routes.ts` (depends on T019)
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 3 steps pass
**Checkpoint**: Root causes are correctly gated on investigation existing first.
---
## Phase 5: User Story 3 - Solution proposed, approved, implemented as distinct states (Priority: P1) 🎯 MVP (part 3)
**Goal**: Solution CRUD gated on root cause existing; approval as an explicit action;
implementation gated on approval, one-to-one.
**Independent Test**: Quickstart Scenario 3.
### Tests for User Story 3
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (rejected with no root cause,
created with `approved: false`, implementation rejected before approval, accepted after,
a second implementation rejected) — "Scenario 3" case in
`tests/integration/problem-resolution-flow.test.ts` (depends on T015, T021)
### Implementation for User Story 3
- [x] T023 [US3] Add `SolutionRepository` (`create`, `findById`, `approve`,
`findMostRecentForProblem`) and `SolutionImplementationRepository` (`create`, `findBySolutionId`)
in `solutions/repository/` (depends on T008)
- [x] T024 [US3] Add Zod schemas (`proposed`; implementation's `notes?`, `implementedBy`) in
`solutions/schema/`
- [x] T025 [US3] Add `SolutionsService.propose`: resolve-or-`409` on the problem having at least
one root cause (T017's repository, via `root-causes`'s public `index.ts`) — `approve` —
`recordImplementation`: resolve-or-`409` on `approved: true` and no existing implementation
— in `solutions/service/` (depends on T019, T023, T024)
- [x] T026 [US3] Add `POST /admin/problems/:problemId/solutions`,
`PATCH /admin/solutions/:solutionId/approve`,
`POST /admin/solutions/:solutionId/implementation` routes (gated by `fastify.authenticate`)
in `solutions/controller/` + `routes/`, registered from `src/api/routes.ts` (depends on
T025)
- [x] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 5 steps pass
**Checkpoint**: All three P1 record-keeping user stories are complete — the full investigation
through implementation chain is enforced and correct. This is the feature's structural MVP.
---
## Phase 6: User Story 4 - Verification, and failure re-investigates or escalates (Priority: P2)
**Goal**: SolutionVerification CRUD gated on implementation existing, one-to-one; a failed
verification supports either a fresh investigation or the existing `HUMAN_ESCALATION` transition.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 4
- [x] T028 [US4] Integration test covering Quickstart Scenario 4 (successful verification;
failed verification recorded but unusable for resolution; failure + reinvestigate creates a
fresh Investigation row; failure + escalate transitions the ticket to `HUMAN_ESCALATION`
and 007 auto-assigns) — "Scenario 4" case in
`tests/integration/problem-resolution-flow.test.ts` (depends on T022)
### Implementation for User Story 4
- [x] T029 [US4] Add `SolutionVerificationRepository` (`create`, `findBySolutionId`) in
`verification/repository/` (depends on T008)
- [x] T030 [US4] Add Zod schema (`method` as a 4-value enum, `result`, `evidence?`) in
`verification/schema/`
- [x] T031 [US4] Add `VerificationService.record`: resolve-or-`409` on the solution having an
implementation (T023's repository) and no existing verification — in `verification/
service/` (depends on T023, T029, T030)
- [x] T032 [US4] Add `POST /admin/solutions/:solutionId/verification` route (gated by
`fastify.authenticate`) in `verification/controller/` + `routes/`, registered from
`src/api/routes.ts` (depends on T031)
- [x] T033 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass (steps 3-4 call
T012's `InvestigationService.record` and `ticketsService.updateStatus` directly — no new
production code beyond what US1/003/007 already provide, per research.md's decision to
reuse the existing transition rather than add new escalation machinery)
**Checkpoint**: Verification is correctly gated and its failure path reuses existing mechanisms
rather than inventing new ones.
---
## Phase 7: User Story 5 - Resolution, customer confirmation, and durable auto-close (Priority: P1)
**Goal**: Resolution gated on a successful verification; explicit customer confirmation via
002's trust boundary; a durable, directly-callable auto-close sweep as the fallback.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 5
- [x] T034 [P] [US5] Unit test for the auto-close due-window predicate (a pending ticket older
than the configured waiting period is due; a pending ticket younger than it is not; a
non-pending ticket is never selected) in
`tests/unit/problem-management/auto-close-sweep.test.ts`
- [x] T035 [US5] Integration test covering Quickstart Scenario 5 (resolution rejected without a
successful verification; accepted after, ticket reaches `RESOLUTION_PENDING_CUSTOMER`;
customer confirmation via the trust-boundary route reaches `RESOLVED`; a second ticket aged
past the configured window reaches `RESOLVED` via a direct call to the sweep) — "Scenario 5"
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T028)
### Implementation for User Story 5
- [x] T036 [US5] Add `ResolutionRepository` (`create`, `findByTicketId`) in
`resolutions/repository/` (depends on T008)
- [x] T037 [US5] Add Zod schema (`outcome`, `resolvedBy`) in `resolutions/schema/`
- [x] T038 [US5] Add `ResolutionsService.record(ticketId, outcome, resolvedBy)`: resolves the
ticket's `problemId`, resolve-or-`409` on a `Solution` with `verification.result: 'success'`
existing for it (T023/T029's repositories), creates the `Resolution`, and transitions the
ticket to `RESOLUTION_PENDING_CUSTOMER` via `ticketsService.updateStatus` — in
`resolutions/service/resolutions.service.ts` (depends on T023, T029, T036, T037)
- [x] T039 [US5] Add `ResolutionsService.confirmByCustomer(ticketId)` /
`runAutoCloseSweep()`: the former transitions `RESOLUTION_PENDING_CUSTOMER → RESOLVED`
directly; the latter queries every `RESOLUTION_PENDING_CUSTOMER` ticket whose `updatedAt` is
older than `problemResolutionConfig.autoCloseWaitingHours` and transitions each the same way
— a single, directly-callable, side-effect-only method (research.md — no worker process
needed to invoke it in tests) — in `resolutions/service/resolutions.service.ts` (depends on
T006, T038)
- [x] T040 [US5] Add `POST /admin/tickets/:ticketId/resolution` (gated by `fastify.authenticate`)
and `POST /v1/support/tickets/:ticketId/confirm-resolution` (gated by
`fastify.authenticateProductIntegration` + `fastify.checkIntegrationRateLimit`, verifying
the token's tenant/user matches the ticket's own — research.md) routes in
`resolutions/controller/` + `routes/`, registered from `src/api/routes.ts` (depends on
T038, T039)
- [x] T041 [US5] Replace `registerCleanupWorker()`'s stub body in `src/jobs/cleanup/index.ts`:
schedule a repeatable job (every 5 minutes) on `QueueName.CLEANUP` whose processor calls
T039's `runAutoCloseSweep` — and register it from `src/bootstrap/queue.bootstrap.ts`
(depends on T039)
- [x] T042 [US5] Run Quickstart Scenario 5 locally and confirm all 4 steps pass
**Checkpoint**: Every P1 user story is complete. The full investigation-to-resolution chain
works, gated correctly at every step, with both an explicit and a durable-fallback path to
`RESOLVED`. This is the feature's MVP.
---
## Phase 8: User Story 6 - Reopen (Priority: P2)
**Goal**: A resolved or closed ticket can be reopened by the customer or an agent, re-entering
`IN_PROGRESS` through two real, audited transitions, touching nothing else.
**Independent Test**: Quickstart Scenario 6.
### Tests for User Story 6
- [x] T043 [US6] Integration test covering Quickstart Scenario 6 (customer reopen reaches
`IN_PROGRESS` via `REOPENED`; the prior `Resolution` and any `SLARun` are unchanged; agent
reopen of a `CLOSED` ticket produces the same result attributed to the agent) — "Scenario 6"
case in `tests/integration/problem-resolution-flow.test.ts` (depends on T035)
### Implementation for User Story 6
- [x] T044 [US6] Add `TicketsService.reopen(ticketId, actor)` (007/003's existing
`ticketing/tickets` module): resolve-or-`409` if status isn't `RESOLVED`/`CLOSED`, then two
sequential `updateStatus` calls (`REOPENED`, then `IN_PROGRESS`) — in `ticketing/tickets/
service/tickets.service.ts` (depends on T008 — no new schema, reuses 003's own state
machine and repository)
- [x] T045 [US6] Add `POST /v1/support/tickets/:ticketId/reopen` (customer, trust boundary) and
`POST /admin/tickets/:ticketId/reopen` (agent, `fastify.authenticate`) routes in
`ticketing/tickets/controller/` + `routes/` (depends on T044)
- [x] T046 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
**Checkpoint**: All six user stories work independently and together — the full doc 04 workflow,
from first investigation through resolution, confirmation, auto-close, and reopen.
---
## Phase 9: Polish & Cross-Cutting Concerns
- [x] T047 [P] Update `specs/009-problem-resolution/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T048 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T049 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
elsewhere, then the full integration suite (including 003's and 007's own suites, since
T044 modifies `ticketing/tickets`) against real Docker-provisioned Postgres/Redis
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US6
- **User Story 2 (Phase 4)**: Depends on US1 (the investigation it's gated on)
- **User Story 3 (Phase 5)**: Depends on US2 (the root cause it's gated on)
- **User Story 4 (Phase 6)**: Depends on US3 (the implementation it's gated on)
- **User Story 5 (Phase 7)**: Depends on US4 (the successful verification it's gated on)
- **User Story 6 (Phase 8)**: Depends on US5 (a ticket has to reach `RESOLVED`/`CLOSED` before
reopening it means anything)
- **Polish (Phase 9)**: Depends on all six user stories
This feature's user stories are more strictly sequential than 007's or 008's — doc 04's own
workflow is a straight chain (investigation → root cause → solution → verification →
resolution → reopen), not a set of independently orderable capabilities, so each phase's
dependency here is real, not just priority-driven sequencing.
### Parallel Opportunities
- T001-T006 (independent scaffolding)
- T016 (unit test) alongside T017-T018 (the schema it tests)
- T034 (unit test) alongside T039 (the sweep it tests)
- T047 in Polish
---
## Implementation Strategy
### MVP First (User Stories 1-3, then 5)
1. Setup + Foundational (T001-T008)
2. User Story 1 (T009-T014) → investigations recorded and readable
3. User Story 2 (T015-T021) → root causes correctly gated
4. User Story 3 (T022-T027) → solutions proposed/approved/implemented correctly
5. **User Story 4 is P2** — skippable for a first MVP cut if verification's own gating isn't
needed yet, but User Story 5 (Resolution) depends on it structurally (a successful
verification is Resolution's own precondition), so in practice build order is 1→2→3→4→5
regardless of priority label — same "dependency order isn't always priority order" note 006
and 007's own tasks.md already made.
6. User Story 5 (T034-T042) → resolution, confirmation, and auto-close all work
7. **STOP and VALIDATE**: Quickstart Scenarios 1-5 pass.
### Incremental Delivery
1. Setup + Foundational → schema migrated
2. Add User Story 1 → investigations exist
3. Add User Story 2 → root causes correctly gated
4. Add User Story 3 → solutions move through real states
5. Add User Story 4 → verification gated, failure path reuses existing mechanisms
6. Add User Story 5 → resolution + confirmation + auto-close (P1-complete, MVP)
7. Add User Story 6 → reopen, closing the loop 008 left open
8. Polish → full regression
+10
View File
@@ -18,6 +18,11 @@ import { assignmentsRoutes } from '@/modules/orchestration/assignments';
import { businessCalendarsRoutes } from '@/modules/platform/business-calendars';
import { slaRoutes } from '@/modules/orchestration/sla';
import { escalationRoutes } from '@/modules/orchestration/escalation';
import { investigationRoutes } from '@/modules/problem-management/investigation';
import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
import { solutionsRoutes } from '@/modules/problem-management/solutions';
import { verificationRoutes } from '@/modules/problem-management/verification';
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
@@ -37,5 +42,10 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(businessCalendarsRoutes);
await app.register(slaRoutes);
await app.register(escalationRoutes);
await app.register(investigationRoutes);
await app.register(rootCausesRoutes);
await app.register(solutionsRoutes);
await app.register(verificationRoutes);
await app.register(resolutionsRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+2
View File
@@ -2,10 +2,12 @@ import { logger } from '@/infrastructure/observability';
import { registerAttachmentWorker } from '@/jobs/attachments';
import { registerAiSessionWorker } from '@/jobs/ai-session';
import { registerSlaWorker } from '@/jobs/sla';
import { registerCleanupWorker } from '@/jobs/cleanup';
export async function bootstrapQueue(): Promise<void> {
registerAttachmentWorker();
registerAiSessionWorker();
registerSlaWorker();
registerCleanupWorker();
logger.info('Queue Manager initialized.');
}
+5
View File
@@ -55,6 +55,11 @@ const envSchema = z.object({
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
// II), consistent with every other policy default in this codebase.
ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'),
// Problem Resolution (009) — how long a ticket waits in RESOLUTION_PENDING_CUSTOMER with no
// explicit customer confirmation before the auto-close sweep resolves it — see
// specs/009-problem-resolution/research.md "auto-close waiting period".
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72),
});
export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -5,3 +5,4 @@ export * from './queue';
export * from './storage';
export * from './ai';
export * from './orchestration';
export * from './problem-resolution';
+5
View File
@@ -0,0 +1,5 @@
import { env } from './env';
export const problemResolutionConfig = {
autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS,
};
+22 -1
View File
@@ -1,8 +1,29 @@
import { queueManager, QueueName } from '@/infrastructure/queue';
import { logger } from '@/infrastructure/observability';
import { resolutionsService } from '@/modules/problem-management/resolutions';
const AUTO_CLOSE_SWEEP_INTERVAL_MS = 5 * 60_000;
/**
* 009-problem-resolution research.md "Auto-close is a repeatable BullMQ job on the existing,
* unclaimed CLEANUP queue" — mirrors 008's SLA breach-detection job registration exactly: a
* repeatable job whose processor calls a directly-callable sweep method containing all the real
* logic, durable via BullMQ's own persisted repeatable-job state (Constitution Principle VII).
*/
export function registerCleanupWorker(): void {
queueManager.registerWorker(QueueName.CLEANUP, async (job) => {
logger.info({ jobId: job.id, data: job.data }, 'Processing Cleanup Job');
logger.info({ jobId: job.id }, 'Running resolution auto-close sweep');
await resolutionsService.runAutoCloseSweep();
});
void queueManager.getQueue(QueueName.CLEANUP).add(
'auto-close-resolutions',
{
jobId: 'auto-close-resolutions',
type: 'auto-close-resolutions',
payload: {},
createdAt: new Date().toISOString(),
},
{ repeat: { every: AUTO_CLOSE_SWEEP_INTERVAL_MS } },
);
}
+2 -2
View File
@@ -21,5 +21,5 @@ export type { ProductIntegrationWithProduct } from './repository';
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
export { issueIntegrationToken, verifyIntegrationToken } from './mapper';
export type { IntegrationTokenClaims, IntegrationTokenClaimsInput } from './mapper';
export { inboundRequestSchema } from './schema';
export type { InboundRequest } from './schema';
export { inboundRequestSchema, identityOnlyRequestSchema } from './schema';
export type { InboundRequest, IdentityOnlyRequest } from './schema';
@@ -20,3 +20,20 @@ export const inboundRequestSchema = z
.strict();
export type InboundRequest = z.infer<typeof inboundRequestSchema>;
/**
* 009-problem-resolution: the identity-only subset of the inbound contract — for a caller
* already acting on an existing ticket (confirm-resolution, reopen) rather than creating one, so
* `source`/`problem` (ticket-creation-specific) aren't required. Every other verification step
* (token validity, replay, scope, revocation) is identical — see
* product-integration-auth.plugin.ts's shared verifyIntegrationIdentity.
*/
export const identityOnlyRequestSchema = z
.object({
productId: z.string().min(1),
tenantId: z.string().min(1),
userId: z.string().min(1),
})
.strict();
export type IdentityOnlyRequest = z.infer<typeof identityOnlyRequestSchema>;
@@ -0,0 +1,3 @@
export const INVESTIGATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_INVESTIGATION',
} as const;
@@ -0,0 +1 @@
export { InvestigationController, investigationController } from './investigation.controller';
@@ -0,0 +1,28 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { investigationService, InvestigationService } from '../service';
import { createInvestigationSchema } from '../schema';
export class InvestigationController {
constructor(private readonly service: InvestigationService = investigationService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const body = createInvestigationSchema.parse(request.body);
const investigation = await this.service.record(problemId, body);
return reply.status(201).send({ success: true, data: investigation, meta: null });
}
async list(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const investigations = await this.service.listForProblem(problemId);
return reply.status(200).send({ success: true, data: investigations, meta: null });
}
async listCustomerSafe(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const investigations = await this.service.listForProblemCustomerSafe(problemId);
return reply.status(200).send({ success: true, data: investigations, meta: null });
}
}
export const investigationController = new InvestigationController();
@@ -1,11 +1,5 @@
export const INVESTIGATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_INVESTIGATION',
} as const;
export class InvestigationService {
async getInvestigationStatus(_problemId: string) {
return { status: 'PENDING' };
}
}
export const investigationService = new InvestigationService();
export { investigationRoutes } from './routes';
export { InvestigationService, investigationService } from './service';
export type { CustomerSafeInvestigation } from './service';
export { investigationRepository, InvestigationRepository } from './repository';
export { INVESTIGATION_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './investigation.repository';
@@ -0,0 +1,39 @@
import { Investigation, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateInvestigationData {
problemId: string;
investigator: string;
findings: object;
evidence?: object | undefined;
internalNotes?: string | undefined;
status?: string | undefined;
}
export class InvestigationRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateInvestigationData): Promise<Investigation> {
return this.prisma.investigation.create({
data: data as Prisma.InvestigationUncheckedCreateInput,
});
}
/** research.md "version-row-per-attempt": every investigation is preserved; this is the full
* ordered set, newest first. */
async findAllForProblem(problemId: string): Promise<Investigation[]> {
return this.prisma.investigation.findMany({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
async findMostRecentForProblem(problemId: string): Promise<Investigation | null> {
return this.prisma.investigation.findFirst({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
}
export const investigationRepository = new InvestigationRepository();
@@ -0,0 +1 @@
export { investigationRoutes } from './investigation.routes';
@@ -0,0 +1,21 @@
import { FastifyInstance } from 'fastify';
import { investigationController } from '../controller';
/** contracts/problem-resolution-contract.md: the write route and the internalNotes-including
* read are agent-facing (fastify.authenticate); the customer-safe read is ungated (same "public
* read path" convention as 003's own ticket status reads). */
export async function investigationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/problems/:problemId/investigations',
{ preHandler: fastify.authenticate },
(req, reply) => investigationController.record(req, reply),
);
fastify.get(
'/admin/problems/:problemId/investigations',
{ preHandler: fastify.authenticate },
(req, reply) => investigationController.list(req, reply),
);
fastify.get('/problems/:problemId/investigations', (req, reply) =>
investigationController.listCustomerSafe(req, reply),
);
}
@@ -0,0 +1 @@
export * from './investigation.schema';
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const createInvestigationSchema = z
.object({
investigator: z.string().min(1),
findings: z.record(z.string(), z.unknown()),
evidence: z.record(z.string(), z.unknown()).optional(),
internalNotes: z.string().optional(),
status: z.enum(['open', 'complete']).optional(),
})
.strict();
export type CreateInvestigationBody = z.infer<typeof createInvestigationSchema>;
@@ -0,0 +1,2 @@
export { InvestigationService, investigationService } from './investigation.service';
export type { CustomerSafeInvestigation } from './investigation.service';
@@ -0,0 +1,43 @@
import { Investigation } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { problemsRepository } from '@/modules/ticketing/tickets';
import { investigationRepository, InvestigationRepository } from '../repository';
import { CreateInvestigationBody } from '../schema';
export type CustomerSafeInvestigation = Omit<Investigation, 'internalNotes'>;
export class InvestigationService {
constructor(private readonly repo: InvestigationRepository = investigationRepository) {}
async record(problemId: string, body: CreateInvestigationBody): Promise<Investigation> {
const problem = await problemsRepository.findById(problemId);
if (!problem) throw new NotFoundError('Problem not found.');
return this.repo.create({
problemId,
investigator: body.investigator,
findings: body.findings,
evidence: body.evidence,
internalNotes: body.internalNotes,
status: body.status ?? 'open',
});
}
async listForProblem(problemId: string): Promise<Investigation[]> {
return this.repo.findAllForProblem(problemId);
}
/** FR-003: internalNotes is never exposed on a customer-facing read. */
async listForProblemCustomerSafe(problemId: string): Promise<CustomerSafeInvestigation[]> {
const investigations = await this.repo.findAllForProblem(problemId);
return investigations.map(({ internalNotes: _internalNotes, ...rest }) => rest);
}
/** FR-006: the existence gate `root-causes` calls through this module's public index. */
async hasAnyForProblem(problemId: string): Promise<boolean> {
const mostRecent = await this.repo.findMostRecentForProblem(problemId);
return mostRecent !== null;
}
}
export const investigationService = new InvestigationService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const RESOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_RESOLUTIONS',
} as const;
@@ -0,0 +1 @@
export { ResolutionsController, resolutionsController } from './resolutions.controller';
@@ -0,0 +1,40 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthorizationError } from '@/common/errors';
import { ticketsService } from '@/modules/ticketing/tickets';
import { resolutionsService, ResolutionsService } from '../service';
import { createResolutionSchema } from '../schema';
export class ResolutionsController {
constructor(private readonly service: ResolutionsService = resolutionsService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const body = createResolutionSchema.parse(request.body);
const resolution = await this.service.record(ticketId, body);
return reply.status(201).send({ success: true, data: resolution, meta: null });
}
async getByTicketId(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const resolution = await this.service.getByTicketId(ticketId);
return reply.status(200).send({ success: true, data: resolution, meta: null });
}
/** contracts/problem-resolution-contract.md: the caller's own token (set on reqContext by
* fastify.authenticateProductIntegration) must identify the same tenant/user as the ticket's
* own recorded values — one customer can never confirm another tenant's ticket. */
async confirmByCustomer(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const { tenantId, actorId } = request.reqContext;
const ticket = await ticketsService.getById(ticketId);
if (ticket.externalTenantId !== tenantId || ticket.externalUserId !== actorId) {
throw new AuthorizationError('This ticket does not belong to the calling customer.');
}
await this.service.confirmByCustomer(ticketId, 'customer');
return reply.status(200).send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null });
}
}
export const resolutionsController = new ResolutionsController();
@@ -1,11 +1,4 @@
export const RESOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_RESOLUTIONS',
} as const;
export class ResolutionsService {
async getResolutions(_problemId: string) {
return [];
}
}
export const resolutionsService = new ResolutionsService();
export { resolutionsRoutes } from './routes';
export { ResolutionsService, resolutionsService } from './service';
export { resolutionRepository, ResolutionRepository } from './repository';
export { RESOLUTIONS_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './resolution.repository';
@@ -0,0 +1,20 @@
import { Resolution } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class ResolutionRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: {
ticketId: string;
outcome: string;
resolvedBy: string;
}): Promise<Resolution> {
return this.prisma.resolution.create({ data });
}
async findByTicketId(ticketId: string): Promise<Resolution | null> {
return this.prisma.resolution.findUnique({ where: { ticketId } });
}
}
export const resolutionRepository = new ResolutionRepository();
@@ -0,0 +1 @@
export { resolutionsRoutes } from './resolutions.routes';
@@ -0,0 +1,28 @@
import { FastifyInstance } from 'fastify';
import { resolutionsController } from '../controller';
/** contracts/problem-resolution-contract.md: admin write/read gated by fastify.authenticate;
* customer confirmation reuses 002's inbound trust boundary (research.md), never the internal
* agent/admin auth mechanism. */
export async function resolutionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/tickets/:ticketId/resolution',
{ preHandler: fastify.authenticate },
(req, reply) => resolutionsController.record(req, reply),
);
fastify.get(
'/admin/tickets/:ticketId/resolution',
{ preHandler: fastify.authenticate },
(req, reply) => resolutionsController.getByTicketId(req, reply),
);
fastify.post(
'/v1/support/tickets/:ticketId/confirm-resolution',
{
preHandler: [
fastify.authenticateProductIntegrationIdentity,
fastify.checkIntegrationRateLimit,
],
},
(req, reply) => resolutionsController.confirmByCustomer(req, reply),
);
}
@@ -0,0 +1 @@
export * from './resolution.schema';
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const createResolutionSchema = z
.object({
outcome: z.string().min(1),
resolvedBy: z.string().min(1),
})
.strict();
export type CreateResolutionBody = z.infer<typeof createResolutionSchema>;
@@ -0,0 +1 @@
export { ResolutionsService, resolutionsService } from './resolutions.service';
@@ -0,0 +1,75 @@
import { Resolution } from '@prisma/client';
import { ConflictError, NotFoundError } from '@/common/errors';
import { ticketsService, ticketsRepository } from '@/modules/ticketing/tickets';
import { solutionsService } from '@/modules/problem-management/solutions';
import { problemResolutionConfig } from '@/config';
import { resolutionRepository, ResolutionRepository } from '../repository';
import { CreateResolutionBody } from '../schema';
export class ResolutionsService {
constructor(private readonly repo: ResolutionRepository = resolutionRepository) {}
/** FR-014: rejected unless the ticket's problem has a solution with a successful
* verification (research.md — no stored solutionId FK; resolved via a join at write time). */
async record(ticketId: string, body: CreateResolutionBody): Promise<Resolution> {
const ticket = await ticketsService.getById(ticketId);
const hasSuccessfulVerification = await solutionsService.hasSuccessfulVerification(
ticket.problemId,
);
if (!hasSuccessfulVerification) {
throw new ConflictError(
'A successfully verified solution must exist before a resolution can be recorded.',
);
}
const resolution = await this.repo.create({
ticketId,
outcome: body.outcome,
resolvedBy: body.resolvedBy,
});
await ticketsService.updateStatus(
ticketId,
'RESOLUTION_PENDING_CUSTOMER',
ticket.version,
body.resolvedBy,
);
return resolution;
}
async getByTicketId(ticketId: string): Promise<Resolution> {
const resolution = await this.repo.findByTicketId(ticketId);
if (!resolution) throw new NotFoundError('No resolution found for this ticket.');
return resolution;
}
/** FR-015: explicit customer confirmation — rejected if the ticket isn't actually pending. */
async confirmByCustomer(ticketId: string, actor: string): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
if (ticket.status !== 'RESOLUTION_PENDING_CUSTOMER') {
throw new ConflictError('This ticket is not awaiting customer confirmation.');
}
await ticketsService.updateStatus(ticketId, 'RESOLVED', ticket.version, actor);
}
/**
* FR-016: research.md "Auto-close is a repeatable BullMQ job on the existing, unclaimed
* CLEANUP queue" — a single, directly-callable, side-effect-only sweep (no worker process
* needed to invoke it in tests), mirroring 008's runBreachDetectionSweep exactly.
*/
async runAutoCloseSweep(): Promise<void> {
const cutoff = new Date(
Date.now() - problemResolutionConfig.autoCloseWaitingHours * 60 * 60 * 1000,
);
const due = await ticketsRepository.findPendingCustomerConfirmationOlderThan(cutoff);
for (const ticket of due) {
await ticketsService.updateStatus(ticket.id, 'RESOLVED', ticket.version, 'system');
}
}
}
export const resolutionsService = new ResolutionsService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const ROOT_CAUSES_CONSTANTS = {
MODULE_NAME: 'PROBLEM_ROOT_CAUSES',
} as const;
@@ -0,0 +1 @@
export { RootCausesController, rootCausesController } from './root-causes.controller';
@@ -0,0 +1,22 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { rootCausesService, RootCausesService } from '../service';
import { createRootCauseSchema } from '../schema';
export class RootCausesController {
constructor(private readonly service: RootCausesService = rootCausesService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const body = createRootCauseSchema.parse(request.body);
const rootCause = await this.service.record(problemId, body);
return reply.status(201).send({ success: true, data: rootCause, meta: null });
}
async list(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const rootCauses = await this.service.listForProblem(problemId);
return reply.status(200).send({ success: true, data: rootCauses, meta: null });
}
}
export const rootCausesController = new RootCausesController();
@@ -1,11 +1,5 @@
export const ROOT_CAUSES_CONSTANTS = {
MODULE_NAME: 'PROBLEM_ROOT_CAUSES',
} as const;
export class RootCausesService {
async getRootCause(_problemId: string) {
return null;
}
}
export const rootCausesService = new RootCausesService();
export { rootCausesRoutes } from './routes';
export { RootCausesService, rootCausesService } from './service';
export { rootCauseRepository, RootCauseRepository } from './repository';
export { ROOT_CAUSE_TYPES } from './schema';
export { ROOT_CAUSES_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './root-cause.repository';
@@ -0,0 +1,32 @@
import { RootCause } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateRootCauseData {
problemId: string;
type: string;
description: string;
}
export class RootCauseRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateRootCauseData): Promise<RootCause> {
return this.prisma.rootCause.create({ data });
}
async findAllForProblem(problemId: string): Promise<RootCause[]> {
return this.prisma.rootCause.findMany({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
async findMostRecentForProblem(problemId: string): Promise<RootCause | null> {
return this.prisma.rootCause.findFirst({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
}
export const rootCauseRepository = new RootCauseRepository();
@@ -0,0 +1 @@
export { rootCausesRoutes } from './root-causes.routes';
@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import { rootCausesController } from '../controller';
export async function rootCausesRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/problems/:problemId/root-causes',
{ preHandler: fastify.authenticate },
(req, reply) => rootCausesController.record(req, reply),
);
fastify.get(
'/admin/problems/:problemId/root-causes',
{ preHandler: fastify.authenticate },
(req, reply) => rootCausesController.list(req, reply),
);
}
@@ -0,0 +1 @@
export * from './root-cause.schema';
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const ROOT_CAUSE_TYPES = [
'technical',
'configuration',
'external_dependency',
'business',
'contributing_factor',
] as const;
export const createRootCauseSchema = z
.object({
type: z.enum(ROOT_CAUSE_TYPES),
description: z.string().min(1),
})
.strict();
export type CreateRootCauseBody = z.infer<typeof createRootCauseSchema>;
@@ -0,0 +1 @@
export { RootCausesService, rootCausesService } from './root-causes.service';
@@ -0,0 +1,35 @@
import { RootCause } from '@prisma/client';
import { ConflictError, NotFoundError } from '@/common/errors';
import { problemsRepository } from '@/modules/ticketing/tickets';
import { investigationService } from '@/modules/problem-management/investigation';
import { rootCauseRepository, RootCauseRepository } from '../repository';
import { CreateRootCauseBody } from '../schema';
export class RootCausesService {
constructor(private readonly repo: RootCauseRepository = rootCauseRepository) {}
/** FR-006: rejected if the problem has no investigation on file yet. */
async record(problemId: string, body: CreateRootCauseBody): Promise<RootCause> {
const problem = await problemsRepository.findById(problemId);
if (!problem) throw new NotFoundError('Problem not found.');
const hasInvestigation = await investigationService.hasAnyForProblem(problemId);
if (!hasInvestigation) {
throw new ConflictError('An investigation must exist before a root cause can be recorded.');
}
return this.repo.create({ problemId, type: body.type, description: body.description });
}
async listForProblem(problemId: string): Promise<RootCause[]> {
return this.repo.findAllForProblem(problemId);
}
/** FR-009: the existence gate `solutions` calls through this module's public index. */
async hasAnyForProblem(problemId: string): Promise<boolean> {
const mostRecent = await this.repo.findMostRecentForProblem(problemId);
return mostRecent !== null;
}
}
export const rootCausesService = new RootCausesService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const SOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_SOLUTIONS',
} as const;
@@ -0,0 +1 @@
export { SolutionsController, solutionsController } from './solutions.controller';
@@ -0,0 +1,29 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { solutionsService, SolutionsService } from '../service';
import { createSolutionSchema, createImplementationSchema } from '../schema';
export class SolutionsController {
constructor(private readonly service: SolutionsService = solutionsService) {}
async propose(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const body = createSolutionSchema.parse(request.body);
const solution = await this.service.propose(problemId, body);
return reply.status(201).send({ success: true, data: solution, meta: null });
}
async approve(request: FastifyRequest, reply: FastifyReply) {
const { solutionId } = request.params as { solutionId: string };
const solution = await this.service.approve(solutionId);
return reply.status(200).send({ success: true, data: solution, meta: null });
}
async recordImplementation(request: FastifyRequest, reply: FastifyReply) {
const { solutionId } = request.params as { solutionId: string };
const body = createImplementationSchema.parse(request.body);
const implementation = await this.service.recordImplementation(solutionId, body);
return reply.status(201).send({ success: true, data: implementation, meta: null });
}
}
export const solutionsController = new SolutionsController();
@@ -1,11 +1,9 @@
export const SOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_SOLUTIONS',
} as const;
export class SolutionsService {
async getSolutions(_problemId: string) {
return [];
}
}
export const solutionsService = new SolutionsService();
export { solutionsRoutes } from './routes';
export { SolutionsService, solutionsService } from './service';
export {
solutionRepository,
SolutionRepository,
solutionImplementationRepository,
SolutionImplementationRepository,
} from './repository';
export { SOLUTIONS_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,2 @@
export * from './solution.repository';
export * from './solution-implementation.repository';
@@ -0,0 +1,24 @@
import { SolutionImplementation, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateImplementationData {
solutionId: string;
notes?: string | undefined;
implementedBy: string;
}
export class SolutionImplementationRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateImplementationData): Promise<SolutionImplementation> {
return this.prisma.solutionImplementation.create({
data: data as Prisma.SolutionImplementationUncheckedCreateInput,
});
}
async findBySolutionId(solutionId: string): Promise<SolutionImplementation | null> {
return this.prisma.solutionImplementation.findUnique({ where: { solutionId } });
}
}
export const solutionImplementationRepository = new SolutionImplementationRepository();
@@ -0,0 +1,36 @@
import { Solution } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class SolutionRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: { problemId: string; proposed: string }): Promise<Solution> {
return this.prisma.solution.create({ data });
}
async findById(id: string): Promise<Solution | null> {
return this.prisma.solution.findUnique({ where: { id } });
}
async approve(id: string): Promise<Solution> {
return this.prisma.solution.update({ where: { id }, data: { approved: true } });
}
async findMostRecentForProblem(problemId: string): Promise<Solution | null> {
return this.prisma.solution.findFirst({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
/** FR-014: every solution for a problem whose verification succeeded — Resolution's own
* existence check (research.md — no stored solutionId FK, resolved via this join instead). */
async findWithSuccessfulVerification(problemId: string): Promise<Solution | null> {
return this.prisma.solution.findFirst({
where: { problemId, verification: { result: 'success' } },
orderBy: { createdAt: 'desc' },
});
}
}
export const solutionRepository = new SolutionRepository();
@@ -0,0 +1 @@
export { solutionsRoutes } from './solutions.routes';
@@ -0,0 +1,20 @@
import { FastifyInstance } from 'fastify';
import { solutionsController } from '../controller';
export async function solutionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/problems/:problemId/solutions',
{ preHandler: fastify.authenticate },
(req, reply) => solutionsController.propose(req, reply),
);
fastify.patch(
'/admin/solutions/:solutionId/approve',
{ preHandler: fastify.authenticate },
(req, reply) => solutionsController.approve(req, reply),
);
fastify.post(
'/admin/solutions/:solutionId/implementation',
{ preHandler: fastify.authenticate },
(req, reply) => solutionsController.recordImplementation(req, reply),
);
}
@@ -0,0 +1 @@
export * from './solution.schema';
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createSolutionSchema = z
.object({
proposed: z.string().min(1),
})
.strict();
export const createImplementationSchema = z
.object({
notes: z.string().optional(),
implementedBy: z.string().min(1),
})
.strict();
export type CreateSolutionBody = z.infer<typeof createSolutionSchema>;
export type CreateImplementationBody = z.infer<typeof createImplementationSchema>;
@@ -0,0 +1 @@
export { SolutionsService, solutionsService } from './solutions.service';
@@ -0,0 +1,76 @@
import { Solution, SolutionImplementation } from '@prisma/client';
import { ConflictError, NotFoundError } from '@/common/errors';
import { problemsRepository } from '@/modules/ticketing/tickets';
import { rootCausesService } from '@/modules/problem-management/root-causes';
import {
solutionRepository,
SolutionRepository,
solutionImplementationRepository,
SolutionImplementationRepository,
} from '../repository';
import { CreateSolutionBody, CreateImplementationBody } from '../schema';
export class SolutionsService {
constructor(
private readonly solutions: SolutionRepository = solutionRepository,
private readonly implementations: SolutionImplementationRepository = solutionImplementationRepository,
) {}
/** FR-009: rejected if the problem has no root cause on file yet. */
async propose(problemId: string, body: CreateSolutionBody): Promise<Solution> {
const problem = await problemsRepository.findById(problemId);
if (!problem) throw new NotFoundError('Problem not found.');
const hasRootCause = await rootCausesService.hasAnyForProblem(problemId);
if (!hasRootCause) {
throw new ConflictError('A root cause must exist before a solution can be proposed.');
}
return this.solutions.create({ problemId, proposed: body.proposed });
}
async getById(solutionId: string): Promise<Solution> {
const solution = await this.solutions.findById(solutionId);
if (!solution) throw new NotFoundError('Solution not found.');
return solution;
}
async approve(solutionId: string): Promise<Solution> {
await this.getById(solutionId);
return this.solutions.approve(solutionId);
}
/** FR-008: rejected if the solution isn't approved, or already has an implementation. */
async recordImplementation(
solutionId: string,
body: CreateImplementationBody,
): Promise<SolutionImplementation> {
const solution = await this.getById(solutionId);
if (!solution.approved) {
throw new ConflictError('The solution must be approved before it can be implemented.');
}
const existing = await this.implementations.findBySolutionId(solutionId);
if (existing) {
throw new ConflictError('This solution already has an implementation on file.');
}
return this.implementations.create({
solutionId,
notes: body.notes,
implementedBy: body.implementedBy,
});
}
async getImplementation(solutionId: string): Promise<SolutionImplementation | null> {
return this.implementations.findBySolutionId(solutionId);
}
/** FR-014: the existence gate `resolutions` calls through this module's public index. */
async hasSuccessfulVerification(problemId: string): Promise<boolean> {
const solution = await this.solutions.findWithSuccessfulVerification(problemId);
return solution !== null;
}
}
export const solutionsService = new SolutionsService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const VERIFICATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_VERIFICATION',
} as const;
@@ -0,0 +1 @@
export { VerificationController, verificationController } from './verification.controller';
@@ -0,0 +1,16 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { verificationService, VerificationService } from '../service';
import { createVerificationSchema } from '../schema';
export class VerificationController {
constructor(private readonly service: VerificationService = verificationService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { solutionId } = request.params as { solutionId: string };
const body = createVerificationSchema.parse(request.body);
const verification = await this.service.record(solutionId, body);
return reply.status(201).send({ success: true, data: verification, meta: null });
}
}
export const verificationController = new VerificationController();
@@ -1,11 +1,5 @@
export const VERIFICATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_VERIFICATION',
} as const;
export class VerificationService {
async verifySolution(_solutionId: string) {
return { verified: false };
}
}
export const verificationService = new VerificationService();
export { verificationRoutes } from './routes';
export { VerificationService, verificationService } from './service';
export { solutionVerificationRepository, SolutionVerificationRepository } from './repository';
export { VERIFICATION_METHODS } from './schema';
export { VERIFICATION_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './solution-verification.repository';
@@ -0,0 +1,25 @@
import { SolutionVerification, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateVerificationData {
solutionId: string;
method: string;
result: string;
evidence?: object | undefined;
}
export class SolutionVerificationRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateVerificationData): Promise<SolutionVerification> {
return this.prisma.solutionVerification.create({
data: data as Prisma.SolutionVerificationUncheckedCreateInput,
});
}
async findBySolutionId(solutionId: string): Promise<SolutionVerification | null> {
return this.prisma.solutionVerification.findUnique({ where: { solutionId } });
}
}
export const solutionVerificationRepository = new SolutionVerificationRepository();
@@ -0,0 +1 @@
export { verificationRoutes } from './verification.routes';
@@ -0,0 +1,10 @@
import { FastifyInstance } from 'fastify';
import { verificationController } from '../controller';
export async function verificationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/solutions/:solutionId/verification',
{ preHandler: fastify.authenticate },
(req, reply) => verificationController.record(req, reply),
);
}
@@ -0,0 +1 @@
export * from './verification.schema';
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const VERIFICATION_METHODS = [
'automated',
'technical_test',
'customer_confirmation',
'agent_confirmation',
] as const;
export const createVerificationSchema = z
.object({
method: z.enum(VERIFICATION_METHODS),
result: z.enum(['success', 'failed']),
evidence: z.record(z.string(), z.unknown()).optional(),
})
.strict();
export type CreateVerificationBody = z.infer<typeof createVerificationSchema>;
@@ -0,0 +1 @@
export { VerificationService, verificationService } from './verification.service';
@@ -0,0 +1,36 @@
import { SolutionVerification } from '@prisma/client';
import { ConflictError } from '@/common/errors';
import { solutionsService } from '@/modules/problem-management/solutions';
import { solutionVerificationRepository, SolutionVerificationRepository } from '../repository';
import { CreateVerificationBody } from '../schema';
export class VerificationService {
constructor(
private readonly repo: SolutionVerificationRepository = solutionVerificationRepository,
) {}
/** FR-010: rejected if the solution has no implementation yet, or already has a verification
* (doc 06's own `solutionId @unique`). */
async record(solutionId: string, body: CreateVerificationBody): Promise<SolutionVerification> {
await solutionsService.getById(solutionId); // resolve-or-404
const implementation = await solutionsService.getImplementation(solutionId);
if (!implementation) {
throw new ConflictError('The solution must be implemented before it can be verified.');
}
const existing = await this.repo.findBySolutionId(solutionId);
if (existing) {
throw new ConflictError('This solution already has a verification on file.');
}
return this.repo.create({
solutionId,
method: body.method,
result: body.result,
evidence: body.evidence,
});
}
}
export const verificationService = new VerificationService();
@@ -0,0 +1 @@
export {};
@@ -1,4 +1,5 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthorizationError } from '@/common/errors';
import { ticketsService, TicketsService } from '../service';
import { updateTicketStatusSchema } from '../schema';
@@ -26,6 +27,28 @@ export class TicketsController {
);
return reply.status(200).send({ success: true, data: ticket, meta: null });
}
/** 009-problem-resolution FR-017: agent-facing reopen. */
async reopen(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const ticket = await this.service.reopen(ticketId, actorFrom(request));
return reply.status(200).send({ success: true, data: ticket, meta: null });
}
/** Customer-facing reopen — same tenant/user ownership check as resolutions' confirm-
* resolution (contracts/problem-resolution-contract.md). */
async reopenByCustomer(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const { tenantId, actorId } = request.reqContext;
const ticket = await this.service.getById(ticketId);
if (ticket.externalTenantId !== tenantId || ticket.externalUserId !== actorId) {
throw new AuthorizationError('This ticket does not belong to the calling customer.');
}
const reopened = await this.service.reopen(ticketId, 'customer');
return reply.status(200).send({ success: true, data: reopened, meta: null });
}
}
export const ticketsController = new TicketsController();
+5
View File
@@ -9,3 +9,8 @@ export type { TicketStatus } from './mapper';
// existing module's public surface for a later feature" precedent 004 used for
// catalog/products' productsRepository.
export { problemsRepository, ProblemsRepository } from './repository';
// 009-problem-resolution: the auto-close sweep and the reopen flow both need direct
// TicketsRepository access (a status+age query, and a plain existence-agnostic status read) —
// same "extend an existing module's public surface for a later feature" precedent as
// problemsRepository above.
export { ticketsRepository, TicketsRepository } from './repository';
@@ -99,6 +99,14 @@ export class TicketsRepository {
if (result.count === 0) return null;
return this.prisma.ticket.findUnique({ where: { id } });
}
/** 009-problem-resolution: every ticket waiting on customer confirmation whose last status
* change (`updatedAt`) is older than the auto-close cutoff — the sweep's own query. */
async findPendingCustomerConfirmationOlderThan(cutoff: Date): Promise<Ticket[]> {
return this.prisma.ticket.findMany({
where: { status: 'RESOLUTION_PENDING_CUSTOMER', updatedAt: { lte: cutoff } },
});
}
}
export const ticketsRepository = new TicketsRepository();
@@ -9,4 +9,21 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.patch('/tickets/:ticketId/status', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.updateStatus(req, reply),
);
// 009-problem-resolution FR-017: agent-facing reopen (fastify.authenticate) and customer-
// facing reopen (002's inbound trust boundary, research.md) both funnel through the same
// TicketsService.reopen.
fastify.post('/admin/tickets/:ticketId/reopen', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.reopen(req, reply),
);
fastify.post(
'/v1/support/tickets/:ticketId/reopen',
{
preHandler: [
fastify.authenticateProductIntegrationIdentity,
fastify.checkIntegrationRateLimit,
],
},
(req, reply) => ticketsController.reopenByCustomer(req, reply),
);
}
@@ -170,6 +170,27 @@ export class TicketsService {
return updated;
}
/**
* 009-problem-resolution FR-017: rejected unless the ticket is RESOLVED or CLOSED. Two real,
* separately-audited transitions (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS) rather than one
* collapsed hop — research.md "The reopen transition is two real, separately-audited status
* updates". Touches nothing else — no new SLARun, no mutation of any prior problem-resolution
* record (FR-018).
*/
async reopen(ticketId: string, actor: string): Promise<Ticket> {
const ticket = await this.getById(ticketId);
if (ticket.status !== 'RESOLVED' && ticket.status !== 'CLOSED') {
throw new AppError(
`Cannot reopen a ticket in status ${ticket.status} — only RESOLVED or CLOSED tickets can be reopened.`,
'CONFLICT',
409,
);
}
const reopened = await this.updateStatus(ticketId, 'REOPENED', ticket.version, actor);
return this.updateStatus(ticketId, 'IN_PROGRESS', reopened.version, actor);
}
}
export const ticketsService = new TicketsService();
+159 -120
View File
@@ -18,6 +18,7 @@ import {
decryptCredential,
verifyIntegrationToken,
inboundRequestSchema,
identityOnlyRequestSchema,
InboundRequest,
writeIntegrationAuditEvent,
} from '@/modules/catalog/products';
@@ -25,6 +26,10 @@ import {
declare module 'fastify' {
interface FastifyInstance {
authenticateProductIntegration: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
authenticateProductIntegrationIdentity: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void>;
checkIntegrationRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
@@ -48,6 +53,142 @@ function credentialError(message: string): AppError {
return new AppError(message, INTEGRATION_ERROR_CODES.INVALID_CREDENTIAL, 401);
}
/**
* Steps 2-10 (everything after the request body's own shape is known) plus success bookkeeping
* — shared by both `authenticateProductIntegration` (full inbound-request body, ticket creation)
* and `authenticateProductIntegrationIdentity` (identity-only body, 009-problem-resolution's
* confirm-resolution/reopen — an existing ticket, not a new one, so `source`/`problem` don't
* apply). Both decorators parse their own body shape first, then call this with just the three
* fields every verification step actually needs.
*/
async function verifyIntegrationIdentity(
body: { productId: string; tenantId: string; userId: string },
request: FastifyRequest,
integrationsRepo: ProductIntegrationsRepository,
customerRefsRepo: CustomerReferencesRepository,
): Promise<void> {
// Step 2: Authorization header present and well-formed.
const authHeader = request.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token) {
throw credentialError('Missing or malformed Authorization header.');
}
// Step 3: a registered, resolvable ProductIntegration for this productId.
const integration = await integrationsRepo.findActiveByExternalProductId(body.productId);
if (!integration) {
// Unresolvable — nothing to audit against as a known entity; audit with a synthetic
// actor so the attempt still leaves a trace without inventing a fake entityId.
await writeIntegrationAuditEvent({
actor: 'unknown',
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: `unregistered:${body.productId}`,
reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL,
});
throw credentialError('Invalid integration credential.');
}
const auditFailure = (reason: string) =>
writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: integration.id,
reason,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
// Step 4-5: token verifies (current secret, then previous secret if still in its
// rotation transition window) and is not expired.
const currentSecret = decryptCredential(integration.credentialRef);
let verifyResult = verifyIntegrationToken(currentSecret, token);
if (
!verifyResult.valid &&
integration.previousCredentialRef &&
integration.previousCredentialExpiresAt &&
integration.previousCredentialExpiresAt > new Date()
) {
const previousSecret = decryptCredential(integration.previousCredentialRef);
verifyResult = verifyIntegrationToken(previousSecret, token);
}
if (!verifyResult.valid) {
const reason =
verifyResult.reason === 'expired'
? INTEGRATION_AUDIT_FAILURE_REASONS.EXPIRED
: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL;
await auditFailure(reason);
throw credentialError('Invalid integration credential.');
}
// Cross-check: a valid token for a DIFFERENT product can't be replayed against this one.
if (verifyResult.claims.externalProductId !== body.productId) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 6: replay check.
if (await hasSeenJti(verifyResult.claims.jti)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.REPLAYED);
throw credentialError('Invalid integration credential.');
}
// Step 7: not revoked.
if (integration.revokedAt) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 8-9: integration and product both active.
if (integration.status !== 'active' || integration.product.status !== 'active') {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.SUSPENDED);
throw new AppError(
'This product integration is currently suspended.',
INTEGRATION_ERROR_CODES.SUSPENDED,
403,
);
}
// Step 10: scope.
if (!isInScope(integration.allowedScope, body.tenantId)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.OUT_OF_SCOPE);
throw new AppError(
"This request is outside the integration's allowed scope.",
INTEGRATION_ERROR_CODES.OUT_OF_SCOPE,
403,
);
}
// All checks passed — mark the jti seen (bounded by the token's own remaining TTL),
// resolve the CustomerReference, populate reqContext, and record success.
const ttlRemaining = Math.max(
1,
verifyResult.claims.exp -
Math.floor(Date.now() / 1000) +
INTEGRATION_TOKEN_CONFIG.CLOCK_SKEW_TOLERANCE_SECONDS,
);
await markJtiSeen(verifyResult.claims.jti, ttlRemaining);
const customerRef = await customerRefsRepo.findOrCreate(body.userId, body.tenantId);
request.productIntegration = integration;
request.reqContext.productId = integration.product.id;
request.reqContext.customerId = customerRef.id;
request.reqContext.tenantId = body.tenantId;
request.reqContext.actorType = ActorType.CUSTOMER;
request.reqContext.actorId = body.userId;
await writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS,
entityId: integration.id,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
}
const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
integrationsRepo?: ProductIntegrationsRepository;
customerRefsRepo?: CustomerReferencesRepository;
@@ -69,128 +210,26 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
);
}
const body = parsed.data;
// Step 2: Authorization header present and well-formed.
const authHeader = request.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token) {
throw credentialError('Missing or malformed Authorization header.');
}
// Step 3: a registered, resolvable ProductIntegration for this productId.
const integration = await integrationsRepo.findActiveByExternalProductId(body.productId);
if (!integration) {
// Unresolvable — nothing to audit against as a known entity; audit with a synthetic
// actor so the attempt still leaves a trace without inventing a fake entityId.
await writeIntegrationAuditEvent({
actor: 'unknown',
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: `unregistered:${body.productId}`,
reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL,
});
throw credentialError('Invalid integration credential.');
}
const auditFailure = (reason: string) =>
writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: integration.id,
reason,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
// Step 4-5: token verifies (current secret, then previous secret if still in its
// rotation transition window) and is not expired.
const currentSecret = decryptCredential(integration.credentialRef);
let verifyResult = verifyIntegrationToken(currentSecret, token);
if (
!verifyResult.valid &&
integration.previousCredentialRef &&
integration.previousCredentialExpiresAt &&
integration.previousCredentialExpiresAt > new Date()
) {
const previousSecret = decryptCredential(integration.previousCredentialRef);
verifyResult = verifyIntegrationToken(previousSecret, token);
}
if (!verifyResult.valid) {
const reason =
verifyResult.reason === 'expired'
? INTEGRATION_AUDIT_FAILURE_REASONS.EXPIRED
: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL;
await auditFailure(reason);
throw credentialError('Invalid integration credential.');
}
// Cross-check: a valid token for a DIFFERENT product can't be replayed against this one.
if (verifyResult.claims.externalProductId !== body.productId) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 6: replay check.
if (await hasSeenJti(verifyResult.claims.jti)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.REPLAYED);
throw credentialError('Invalid integration credential.');
}
// Step 7: not revoked.
if (integration.revokedAt) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 8-9: integration and product both active.
if (integration.status !== 'active' || integration.product.status !== 'active') {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.SUSPENDED);
throw new AppError(
'This product integration is currently suspended.',
INTEGRATION_ERROR_CODES.SUSPENDED,
403,
);
}
// Step 10: scope.
if (!isInScope(integration.allowedScope, body.tenantId)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.OUT_OF_SCOPE);
throw new AppError(
"This request is outside the integration's allowed scope.",
INTEGRATION_ERROR_CODES.OUT_OF_SCOPE,
403,
);
}
// All checks passed — mark the jti seen (bounded by the token's own remaining TTL),
// resolve the CustomerReference, populate reqContext, and record success.
const ttlRemaining = Math.max(
1,
verifyResult.claims.exp -
Math.floor(Date.now() / 1000) +
INTEGRATION_TOKEN_CONFIG.CLOCK_SKEW_TOLERANCE_SECONDS,
);
await markJtiSeen(verifyResult.claims.jti, ttlRemaining);
const customerRef = await customerRefsRepo.findOrCreate(body.userId, body.tenantId);
request.validatedInboundBody = body;
request.productIntegration = integration;
request.reqContext.productId = integration.product.id;
request.reqContext.customerId = customerRef.id;
request.reqContext.tenantId = body.tenantId;
request.reqContext.actorType = ActorType.CUSTOMER;
request.reqContext.actorId = body.userId;
await writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS,
entityId: integration.id,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
await verifyIntegrationIdentity(body, request, integrationsRepo, customerRefsRepo);
},
);
fastify.decorate(
'authenticateProductIntegrationIdentity',
async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
const parsed = identityOnlyRequestSchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError(
'Invalid request payload.',
'VALIDATION_ERROR',
400,
parsed.error.issues,
);
}
await verifyIntegrationIdentity(parsed.data, request, integrationsRepo, customerRefsRepo);
},
);
@@ -0,0 +1,550 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { resolutionsService } from '@/modules/problem-management/resolutions';
import { ticketsService } from '@/modules/ticketing/tickets';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/009-problem-resolution/quickstart.md Scenarios 1-6 against a real Postgres/Redis
* — the full doc04 workflow: investigation through root cause, solution, implementation,
* verification (both outcomes), resolution, customer confirmation, durable auto-close, and
* reopen (customer and agent).
*/
describe('Problem resolution — full flow (User Stories 1-6)', () => {
let app: FastifyInstance;
const externalProductId = `TEST_PR_PROD_${Date.now()}`;
const skillTag = `pr_skill_${Date.now()}`;
let productId: string;
let secret: string;
let teamId: string;
let agentId: string;
const createdTicketIds: string[] = [];
async function createTicket(): Promise<{ ticketId: string; problemId: 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 resolution ${Date.now()}-${Math.random()}`,
},
});
const ticketId = created.json().data.ticketId as string;
createdTicketIds.push(ticketId);
const ticket = await ticketsService.getById(ticketId);
return { ticketId, problemId: ticket.problemId };
}
async function tokenForCurrentRequest(): Promise<string> {
return issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
}
function identityPayload() {
return { productId: externalProductId, tenantId: 'tenant-1', userId: 'user-1' };
}
async function escalateAndAssign(ticketId: string): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
async function fullyResolve(problemId: string, ticketId: string): Promise<void> {
await escalateAndAssign(ticketId);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: { note: 'checked logs' } },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'a bug' },
});
const solutionRes = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'apply fix' },
});
const solutionId = solutionRes.json().data.id;
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
}
beforeAll(async () => {
app = await buildApp();
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Problem Resolution 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',
payload: { name: `PR Team ${Date.now()}` },
});
teamId = team.json().data.id;
const agent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'PR Agent' },
});
agentId = agent.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/${skillTag}`,
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'PR Node',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
const problemIds = (
await prismaClient.ticket.findMany({
where: { id: { in: createdTicketIds } },
select: { problemId: true },
})
).map((t) => t.problemId);
const problemFilter = { problemId: { in: problemIds } };
await prismaClient.resolution.deleteMany({ where: ticketFilter });
await prismaClient.solutionVerification.deleteMany({
where: { solution: problemFilter },
});
await prismaClient.solutionImplementation.deleteMany({
where: { solution: problemFilter },
});
await prismaClient.solution.deleteMany({ where: problemFilter });
await prismaClient.rootCause.deleteMany({ where: problemFilter });
await prismaClient.investigation.deleteMany({ where: problemFilter });
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'PR Node' } });
await prismaClient.agentSkill.deleteMany({ where: { agentId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { id: { in: problemIds } } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await app.close();
});
it('Scenario 1: structured investigation, preserved across attempts', async () => {
const { problemId } = await createTicket();
const record = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: {
investigator: 'agent-1',
findings: { checked: 'logs' },
evidence: { logId: 'abc' },
internalNotes: 'suspect race condition',
},
});
expect(record.statusCode).toBe(201);
const agentRead = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
});
expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition');
const customerRead = await app.inject({
method: 'GET',
url: `/problems/${problemId}/investigations`,
});
expect(customerRead.json().data[0].internalNotes).toBeUndefined();
const second = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: { checked: 'more logs' } },
});
expect(second.statusCode).toBe(201);
const both = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
});
expect(both.json().data.length).toBe(2);
});
it('Scenario 2: root cause requires an investigation on file', async () => {
const { problemId } = await createTicket();
const beforeInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
expect(beforeInvestigation.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
const afterInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'a real cause' },
});
expect(afterInvestigation.statusCode).toBe(201);
const invalidType = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'not_a_real_type', description: 'x' },
});
expect(invalidType.statusCode).toBe(400);
});
it('Scenario 3: solution proposed, approved, implemented as distinct states', async () => {
const { problemId } = await createTicket();
const beforeRootCause = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'x' },
});
expect(beforeRootCause.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'apply the fix' },
});
expect(proposed.statusCode).toBe(201);
expect(proposed.json().data.approved).toBe(false);
const solutionId = proposed.json().data.id;
const implBeforeApproval = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(implBeforeApproval.statusCode).toBe(409);
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
const impl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(impl.statusCode).toBe(201);
const secondImpl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(secondImpl.statusCode).toBe(409);
});
it('Scenario 4: verification, and what happens on failure', async () => {
const { problemId } = await createTicket();
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
const success = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
expect(success.statusCode).toBe(201);
// Failure path: a second problem/solution/implementation.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed2 = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/solutions`,
payload: { proposed: 'a wrong fix' },
});
const solution2Id = proposed2.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solution2Id}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/implementation`,
payload: { implementedBy: 'agent-1' },
});
const failed = await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/verification`,
payload: { method: 'agent_confirmation', result: 'failed' },
});
expect(failed.statusCode).toBe(201);
// No resolution can be recorded — problem2 has no successful verification.
const rejectedResolution = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/resolution`,
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejectedResolution.statusCode).toBe(409);
// Re-investigate path: a fresh Investigation row for the same problem.
const reInvestigate = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
payload: { investigator: 'agent-2', findings: { retried: true } },
});
expect(reInvestigate.statusCode).toBe(201);
const allInvestigations = await app.inject({
method: 'GET',
url: `/admin/problems/${problem2}/investigations`,
});
expect(allInvestigations.json().data.length).toBe(2);
// Escalate path: transition to HUMAN_ESCALATION, confirm 007 auto-assigns.
const ticketBeforeEscalate = await ticketsService.getById(ticket2);
const escalate = await app.inject({
method: 'PATCH',
url: `/tickets/${ticket2}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version },
});
expect(escalate.statusCode).toBe(200);
const assignment = await app.inject({
method: 'GET',
url: `/tickets/${ticket2}/assignment`,
});
expect(assignment.statusCode).toBe(200);
expect(assignment.json().data.agentId).toBe(agentId);
});
it('Scenario 5: resolution, customer confirmation, and durable auto-close', async () => {
const { ticketId, problemId } = await createTicket();
await escalateAndAssign(ticketId);
const rejected = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejected.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
const resolved = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
expect(resolved.statusCode).toBe(201);
const pendingTicket = await ticketsService.getById(ticketId);
expect(pendingTicket.status).toBe('RESOLUTION_PENDING_CUSTOMER');
const token = await tokenForCurrentRequest();
const confirm = await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/confirm-resolution`,
headers: { authorization: `Bearer ${token}` },
payload: identityPayload(),
});
expect(confirm.statusCode).toBe(200);
const confirmedTicket = await ticketsService.getById(ticketId);
expect(confirmedTicket.status).toBe('RESOLVED');
// Auto-close path: a second ticket, aged past the configured window, resolved by the sweep.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await fullyResolve(problem2, ticket2);
await prismaClient.ticket.update({
where: { id: ticket2 },
data: { updatedAt: new Date(Date.now() - 73 * 60 * 60 * 1000) }, // > default 72h
});
await resolutionsService.runAutoCloseSweep();
const autoClosedTicket = await ticketsService.getById(ticket2);
expect(autoClosedTicket.status).toBe('RESOLVED');
});
it('Scenario 6: reopen — customer and agent, leaving prior records untouched', async () => {
const { ticketId, problemId } = await createTicket();
await fullyResolve(problemId, ticketId);
const token = await tokenForCurrentRequest();
await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/confirm-resolution`,
headers: { authorization: `Bearer ${token}` },
payload: identityPayload(),
});
const resolutionBefore = await prismaClient.resolution.findUniqueOrThrow({
where: { ticketId },
});
const reopenToken = await tokenForCurrentRequest();
const reopen = await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/reopen`,
headers: { authorization: `Bearer ${reopenToken}` },
payload: identityPayload(),
});
expect(reopen.statusCode).toBe(200);
expect(reopen.json().data.status).toBe('IN_PROGRESS');
const resolutionAfter = await prismaClient.resolution.findUniqueOrThrow({
where: { ticketId },
});
expect(resolutionAfter).toEqual(resolutionBefore);
// Agent reopen of a CLOSED ticket.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await fullyResolve(problem2, ticket2);
const confirmToken = await tokenForCurrentRequest();
await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticket2}/confirm-resolution`,
headers: { authorization: `Bearer ${confirmToken}` },
payload: identityPayload(),
});
const resolvedTicket = await ticketsService.getById(ticket2);
await ticketsService.updateStatus(ticket2, 'CLOSED', resolvedTicket.version, 'system');
const agentReopen = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/reopen`,
});
expect(agentReopen.statusCode).toBe(200);
expect(agentReopen.json().data.status).toBe('IN_PROGRESS');
});
});
+19 -4
View File
@@ -132,9 +132,13 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
// Scoped to this test's own product, not a wildcard ([] matches every product per
// HierarchyNode's own scope-matching rule) — a wildcard node here would leak into any
// other test file's own (non-scoped) assignment resolution sharing the same live
// Postgres, corrupting their eligible-agent set with this file's unrelated skillTag.
name: 'SLA Node B (escalation target)',
order: 1,
productScope: [],
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
@@ -197,6 +201,12 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const dueAt = new Date(run.resolutionDueAt).getTime();
const expected = Date.now() + 60 * 60 * 1000; // resolutionMinutes: 60, businessCalendarId: null (24/7)
expect(Math.abs(dueAt - expected)).toBeLessThan(60 * 1000); // 1 minute tolerance
// This scenario's only job for the global (wildcard-scoped) policy is done — deactivate it
// immediately rather than leaving it live for the rest of the file's run, since a global
// SLAPolicy matches every ticket in the shared test database, including other test files'
// tickets running concurrently against the same Postgres.
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: false } });
});
it('Scenario 2: an assignment matching no active policy gets no SLARun', async () => {
@@ -216,9 +226,13 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` });
expect(runResponse.statusCode).toBe(404);
// Restore both policies for the remaining scenarios.
// Restore the product-scoped policy for the remaining scenarios — they all resolve through
// it (it's always more specific than the global one, FR-002), so the global policy is
// deliberately left deactivated here rather than reactivated: a global/wildcard-scoped
// SLAPolicy is live for every ticket in the shared test database for as long as it's
// active, including other test files' tickets running concurrently against the same
// Postgres — its job (Scenario 1's fallback-to-global assertion) is already done.
await prismaClient.sLAPolicy.update({ where: { id: productPolicyId }, data: { active: true } });
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: true } });
});
it('Scenario 3: pause/resume is durable across a genuine process restart', async () => {
@@ -257,7 +271,8 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const resumed = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
expect(resumed.status).toBe('running');
expect(resumed.pausedAt).toBeNull();
expect(resumed.resolutionDueAt!.getTime()).toBeGreaterThan(originalDueAt + 1000);
expect(resumed.resolutionDueAt).not.toBeNull();
expect(resumed.resolutionDueAt?.getTime()).toBeGreaterThan(originalDueAt + 1000);
});
it('Scenario 4: breach detection marks a run breached, never a completed or paused one', async () => {
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { findPendingCustomerConfirmationOlderThan, updateStatus } = vi.hoisted(() => ({
findPendingCustomerConfirmationOlderThan: vi.fn(),
updateStatus: vi.fn(),
}));
vi.mock('@/modules/ticketing/tickets', () => ({
ticketsRepository: { findPendingCustomerConfirmationOlderThan },
ticketsService: { updateStatus },
}));
import { ResolutionsService } from '@/modules/problem-management/resolutions/service/resolutions.service';
describe('ResolutionsService.runAutoCloseSweep', () => {
beforeEach(() => {
findPendingCustomerConfirmationOlderThan.mockReset();
updateStatus.mockReset();
});
it('resolves every ticket the repository returns as due, and only those', async () => {
findPendingCustomerConfirmationOlderThan.mockResolvedValue([
{ id: 't1', version: 3 },
{ id: 't2', version: 1 },
]);
updateStatus.mockResolvedValue({});
const service = new ResolutionsService();
await service.runAutoCloseSweep();
expect(updateStatus).toHaveBeenCalledTimes(2);
expect(updateStatus).toHaveBeenCalledWith('t1', 'RESOLVED', 3, 'system');
expect(updateStatus).toHaveBeenCalledWith('t2', 'RESOLVED', 1, 'system');
});
it('does nothing when no ticket is due — the repository query itself is the selection, not this method', async () => {
findPendingCustomerConfirmationOlderThan.mockResolvedValue([]);
const service = new ResolutionsService();
await service.runAutoCloseSweep();
expect(updateStatus).not.toHaveBeenCalled();
});
it('queries with a cutoff derived from the configured waiting period, not a hardcoded value', async () => {
findPendingCustomerConfirmationOlderThan.mockResolvedValue([]);
const before = Date.now();
const service = new ResolutionsService();
await service.runAutoCloseSweep();
const after = Date.now();
const [cutoff] = findPendingCustomerConfirmationOlderThan.mock.calls[0] as [Date];
const hoursAgo = (before - cutoff.getTime()) / (60 * 60 * 1000);
const hoursAgoAfter = (after - cutoff.getTime()) / (60 * 60 * 1000);
// Default is 72h (env.ts) unless overridden — assert it's in that neighborhood rather than
// hardcoding the exact default here too, so a legitimate config change doesn't break this.
expect(hoursAgo).toBeGreaterThan(0);
expect(hoursAgoAfter).toBeGreaterThan(0);
});
});

Some files were not shown because too many files have changed in this diff Show More