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:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,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.');
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './queue';
|
||||
export * from './storage';
|
||||
export * from './ai';
|
||||
export * from './orchestration';
|
||||
export * from './problem-resolution';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { env } from './env';
|
||||
|
||||
export const problemResolutionConfig = {
|
||||
autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS,
|
||||
};
|
||||
@@ -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 } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
+24
@@ -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';
|
||||
+25
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user