feat: implement problem resolution (009)

Populates the five real problem-management stubs (investigation,
root-causes, solutions, verification, resolutions -- problems is
confirmed dead/unwired scaffold and stays untouched) with doc04's
sequential workflow engine:

- investigation: version-row-per-attempt (never overwritten), with a
  customer-safe read path that always strips internalNotes.
- root-causes/solutions/verification: a strict existence chain
  (investigation -> root cause -> solution -> approval -> implementation
  -> verification), each step resolve-or-409 on its own precondition,
  matching doc06's schema field-for-field with no invented columns.
- resolutions: gated on a successfully verified solution (no stored
  solutionId FK, per doc06 -- resolved via a join at write time), moving
  the ticket to RESOLUTION_PENDING_CUSTOMER; explicit customer
  confirmation and a durable auto-close sweep (the previously-unregistered
  CLEANUP queue stub, mirroring 008's breach-detection job) both resolve
  it from there.
- reopen (ticketing/tickets): two real, separately-audited transitions
  (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS), touching no prior
  problem-resolution record and no SLARun -- closes the loop 008's own
  spec.md left open.

Verification-failure escalation reuses 003/007's existing
HUMAN_ESCALATION transition directly rather than adding an eleventh
trigger type to 008's already-shipped escalation rules.

Customer-facing confirm-resolution/reopen needed a body-shape variant of
002's inbound trust boundary that didn't previously exist:
fastify.authenticateProductIntegration hard-required a full
ticket-creation-shaped body. Extracted the shared token/scope/replay
verification into verifyIntegrationIdentity and added a narrower
authenticateProductIntegrationIdentity decorator + identityOnlyRequestSchema
on top of it -- purely additive, ticket creation's own behavior is
unchanged.

Also fixes a real test-data-hygiene bug surfaced by running this
feature's suite alongside 008's: a wildcard-scoped HierarchyNode and an
intentionally-global SLAPolicy in 008's own test fixtures were silently
affecting other test files' tickets sharing the same live Postgres.

Verified against throwaway Docker Postgres/Redis: typecheck, lint,
architecture-check all clean; full regression (tests/unit +
tests/integration together, 172 tests) passes except the 2 pre-existing
MinIO-dependent attachment failures, unrelated to this feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-03 15:09:29 +05:30
co-authored by Claude Sonnet 5
parent aaa51ef475
commit 16daf8d32d
94 changed files with 2080 additions and 245 deletions
@@ -0,0 +1,105 @@
-- CreateTable
CREATE TABLE "investigations" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"investigator" TEXT NOT NULL,
"findings" JSONB NOT NULL,
"evidence" JSONB,
"internalNotes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "investigations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "root_causes" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "root_causes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solutions" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"proposed" TEXT NOT NULL,
"approved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solutions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_implementations" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"notes" TEXT,
"implementedBy" TEXT NOT NULL,
"implementedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_implementations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_verifications" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"result" TEXT NOT NULL,
"evidence" JSONB,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "resolutions" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"resolvedBy" TEXT NOT NULL,
"resolvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "resolutions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "investigations_problemId_createdAt_idx" ON "investigations"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "root_causes_problemId_createdAt_idx" ON "root_causes"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "solutions_problemId_createdAt_idx" ON "solutions"("problemId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "solution_implementations_solutionId_key" ON "solution_implementations"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "solution_verifications_solutionId_key" ON "solution_verifications"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "resolutions_ticketId_key" ON "resolutions"("ticketId");
-- AddForeignKey
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solutions" ADD CONSTRAINT "solutions_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_implementations" ADD CONSTRAINT "solution_implementations_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_verifications" ADD CONSTRAINT "solution_verifications_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "resolutions" ADD CONSTRAINT "resolutions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+83
View File
@@ -117,6 +117,10 @@ model Problem {
category Category? @relation(fields: [categoryId], references: [id])
tickets Ticket[]
investigations Investigation[]
rootCauses RootCause[]
solutions Solution[]
@@map("problems")
}
@@ -150,6 +154,7 @@ model Ticket {
assignmentHistory AssignmentHistory[]
slaRun SLARun?
escalationEvents EscalationEvent[]
resolution Resolution?
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@ -637,3 +642,81 @@ model EscalationEvent {
@@index([ticketId, createdAt])
@@map("escalation_events")
}
model Investigation {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
investigator String
findings Json
evidence Json?
internalNotes String? // never exposed on a customer-facing read — see
// specs/009-problem-resolution/spec.md FR-003
status String @default("open") // open | complete
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("investigations")
}
model RootCause {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
type String // technical | configuration | external_dependency | business |
// contributing_factor
description String
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("root_causes")
}
model Solution {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
proposed String
approved Boolean @default(false)
createdAt DateTime @default(now())
implementation SolutionImplementation?
verification SolutionVerification?
@@index([problemId, createdAt])
@@map("solutions")
}
model SolutionImplementation {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
notes String?
implementedBy String
implementedAt DateTime @default(now())
@@map("solution_implementations")
}
model SolutionVerification {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
method String // automated | technical_test | customer_confirmation | agent_confirmation
result String // success | failed
evidence Json?
verifiedAt DateTime @default(now())
@@map("solution_verifications")
}
model Resolution {
id String @id @default(cuid())
ticketId String @unique
ticket Ticket @relation(fields: [ticketId], references: [id])
outcome String
resolvedBy String // "ai" | agentId — see specs/009-problem-resolution/data-model.md
resolvedAt DateTime @default(now())
@@map("resolutions")
}
@@ -50,3 +50,31 @@
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.
+29 -14
View File
@@ -86,28 +86,43 @@
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 inbound trust boundary; agent reopen uses `fastify.authenticate`
## 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 the same
`fastify.authenticateProductIntegration` + `fastify.checkIntegrationRateLimit` preHandler pair
`POST /v1/support/requests` already uses (002-saas-integration) — verifying the caller's
`externalTenantId`/`externalUserId` (from the signed token) 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).
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 generically enough to reuse, not something this feature has to reinvent.
Requiring the caller's own token to match the ticket's tenant/user prevents one customer from
confirming or reopening another tenant's ticket.
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.
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
+49 -49
View File
@@ -27,19 +27,19 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Setup
- [ ] T001 [P] Populate `src/modules/problem-management/investigation/` with the full standard
- [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
- [ ] T002 [P] Populate `src/modules/problem-management/root-causes/` the same way, replacing the
- [x] T002 [P] Populate `src/modules/problem-management/root-causes/` the same way, replacing the
`RootCausesService.getRootCause` stub
- [ ] T003 [P] Populate `src/modules/problem-management/solutions/` the same way, replacing the
- [x] T003 [P] Populate `src/modules/problem-management/solutions/` the same way, replacing the
`SolutionsService.getSolutions` stub
- [ ] T004 [P] Populate `src/modules/problem-management/verification/` the same way, replacing
- [x] T004 [P] Populate `src/modules/problem-management/verification/` the same way, replacing
the `VerificationService.verifySolution` stub
- [ ] T005 [P] Populate `src/modules/problem-management/resolutions/` the same way, replacing the
- [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)
- [ ] T006 [P] Add `src/config/problem-resolution.ts` (`problemResolutionConfig
- [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
@@ -51,11 +51,11 @@ All file paths are relative to `supporthub-api/` (repo root).
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [ ] T007 Add `Investigation`, `RootCause`, `Solution`, `SolutionImplementation`,
- [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)
- [ ] T008 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
- [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.
@@ -71,25 +71,25 @@ exclusion from customer-facing reads.
### Tests for User Story 1
- [ ] T009 [US1] Integration test covering Quickstart Scenario 1 (create, retrieve with every
- [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
- [ ] T010 [US1] Add `InvestigationRepository` (`create`, `findAllForProblem` ordered newest
- [x] T010 [US1] Add `InvestigationRepository` (`create`, `findAllForProblem` ordered newest
first, `findMostRecentForProblem`) in `investigation/repository/` (depends on T008)
- [ ] T011 [US1] Add Zod create schema (`investigator`, `findings`, `evidence?`,
- [x] T011 [US1] Add Zod create schema (`investigator`, `findings`, `evidence?`,
`internalNotes?`, `status?`) in `investigation/schema/`
- [ ] T012 [US1] Add `InvestigationService.record`/`listForProblem` (agent-facing, includes
- [x] T012 [US1] Add `InvestigationService.record`/`listForProblem` (agent-facing, includes
`internalNotes`) and `listForProblemCustomerSafe` (strips `internalNotes`, FR-003) in
`investigation/service/` (depends on T010, T011)
- [ ] T013 [US1] Add `POST/GET /admin/problems/:problemId/investigations` (gated by
- [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)
- [ ] T014 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass
- [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.
@@ -104,25 +104,25 @@ redaction guarantee in place.
### Tests for User Story 2
- [ ] T015 [US2] Integration test covering Quickstart Scenario 2 (rejected with no investigation,
- [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)
- [ ] T016 [P] [US2] Unit test for the type-validation Zod schema (five valid values, everything
- [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
- [ ] T017 [US2] Add `RootCauseRepository` (`create`, `findAllForProblem`) in
- [x] T017 [US2] Add `RootCauseRepository` (`create`, `findAllForProblem`) in
`root-causes/repository/` (depends on T008)
- [ ] T018 [US2] Add Zod create schema (`type` as a 5-value enum, `description`) in
- [x] T018 [US2] Add Zod create schema (`type` as a 5-value enum, `description`) in
`root-causes/schema/`
- [ ] T019 [US2] Add `RootCausesService.record`: resolve-or-`409` on the problem having at least
- [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)
- [ ] T020 [US2] Add `POST /admin/problems/:problemId/root-causes` route (gated by
- [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)
- [ ] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 3 steps pass
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 3 steps pass
**Checkpoint**: Root causes are correctly gated on investigation existing first.
@@ -137,28 +137,28 @@ implementation gated on approval, one-to-one.
### Tests for User Story 3
- [ ] T022 [US3] Integration test covering Quickstart Scenario 3 (rejected with no root cause,
- [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
- [ ] T023 [US3] Add `SolutionRepository` (`create`, `findById`, `approve`,
- [x] T023 [US3] Add `SolutionRepository` (`create`, `findById`, `approve`,
`findMostRecentForProblem`) and `SolutionImplementationRepository` (`create`, `findBySolutionId`)
in `solutions/repository/` (depends on T008)
- [ ] T024 [US3] Add Zod schemas (`proposed`; implementation's `notes?`, `implementedBy`) in
- [x] T024 [US3] Add Zod schemas (`proposed`; implementation's `notes?`, `implementedBy`) in
`solutions/schema/`
- [ ] T025 [US3] Add `SolutionsService.propose`: resolve-or-`409` on the problem having at least
- [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)
- [ ] T026 [US3] Add `POST /admin/problems/:problemId/solutions`,
- [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)
- [ ] T027 [US3] Run Quickstart Scenario 3 locally and confirm all 5 steps pass
- [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.
@@ -174,7 +174,7 @@ verification supports either a fresh investigation or the existing `HUMAN_ESCALA
### Tests for User Story 4
- [ ] T028 [US4] Integration test covering Quickstart Scenario 4 (successful verification;
- [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
@@ -182,17 +182,17 @@ verification supports either a fresh investigation or the existing `HUMAN_ESCALA
### Implementation for User Story 4
- [ ] T029 [US4] Add `SolutionVerificationRepository` (`create`, `findBySolutionId`) in
- [x] T029 [US4] Add `SolutionVerificationRepository` (`create`, `findBySolutionId`) in
`verification/repository/` (depends on T008)
- [ ] T030 [US4] Add Zod schema (`method` as a 4-value enum, `result`, `evidence?`) in
- [x] T030 [US4] Add Zod schema (`method` as a 4-value enum, `result`, `evidence?`) in
`verification/schema/`
- [ ] T031 [US4] Add `VerificationService.record`: resolve-or-`409` on the solution having an
- [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)
- [ ] T032 [US4] Add `POST /admin/solutions/:solutionId/verification` route (gated by
- [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)
- [ ] T033 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass (steps 3-4 call
- [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)
@@ -211,11 +211,11 @@ rather than inventing new ones.
### Tests for User Story 5
- [ ] T034 [P] [US5] Unit test for the auto-close due-window predicate (a pending ticket older
- [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`
- [ ] T035 [US5] Integration test covering Quickstart Scenario 5 (resolution rejected without a
- [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"
@@ -223,32 +223,32 @@ rather than inventing new ones.
### Implementation for User Story 5
- [ ] T036 [US5] Add `ResolutionRepository` (`create`, `findByTicketId`) in
- [x] T036 [US5] Add `ResolutionRepository` (`create`, `findByTicketId`) in
`resolutions/repository/` (depends on T008)
- [ ] T037 [US5] Add Zod schema (`outcome`, `resolvedBy`) in `resolutions/schema/`
- [ ] T038 [US5] Add `ResolutionsService.record(ticketId, outcome, resolvedBy)`: resolves the
- [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)
- [ ] T039 [US5] Add `ResolutionsService.confirmByCustomer(ticketId)` /
- [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)
- [ ] T040 [US5] Add `POST /admin/tickets/:ticketId/resolution` (gated by `fastify.authenticate`)
- [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)
- [ ] T041 [US5] Replace `registerCleanupWorker()`'s stub body in `src/jobs/cleanup/index.ts`:
- [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)
- [ ] T042 [US5] Run Quickstart Scenario 5 locally and confirm all 4 steps pass
- [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
@@ -265,22 +265,22 @@ works, gated correctly at every step, with both an explicit and a durable-fallba
### Tests for User Story 6
- [ ] T043 [US6] Integration test covering Quickstart Scenario 6 (customer reopen reaches
- [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
- [ ] T044 [US6] Add `TicketsService.reopen(ticketId, actor)` (007/003's existing
- [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)
- [ ] T045 [US6] Add `POST /v1/support/tickets/:ticketId/reopen` (customer, trust boundary) and
- [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)
- [ ] T046 [US6] Run Quickstart Scenario 6 locally and confirm all 4 steps pass
- [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.
@@ -289,10 +289,10 @@ from first investigation through resolution, confirmation, auto-close, and reope
## Phase 9: Polish & Cross-Cutting Concerns
- [ ] T047 [P] Update `specs/009-problem-resolution/checklists/requirements.md` Notes with any
- [x] T047 [P] Update `specs/009-problem-resolution/checklists/requirements.md` Notes with any
implementation-time findings
- [ ] T048 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T049 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke
- [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
+10
View File
@@ -18,6 +18,11 @@ import { assignmentsRoutes } from '@/modules/orchestration/assignments';
import { businessCalendarsRoutes } from '@/modules/platform/business-calendars';
import { slaRoutes } from '@/modules/orchestration/sla';
import { escalationRoutes } from '@/modules/orchestration/escalation';
import { investigationRoutes } from '@/modules/problem-management/investigation';
import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
import { solutionsRoutes } from '@/modules/problem-management/solutions';
import { verificationRoutes } from '@/modules/problem-management/verification';
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
@@ -37,5 +42,10 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
await app.register(businessCalendarsRoutes);
await app.register(slaRoutes);
await app.register(escalationRoutes);
await app.register(investigationRoutes);
await app.register(rootCausesRoutes);
await app.register(solutionsRoutes);
await app.register(verificationRoutes);
await app.register(resolutionsRoutes);
// Further domain module routes will be registered here as feature modules are wired up
}
+2
View File
@@ -2,10 +2,12 @@ import { logger } from '@/infrastructure/observability';
import { registerAttachmentWorker } from '@/jobs/attachments';
import { registerAiSessionWorker } from '@/jobs/ai-session';
import { registerSlaWorker } from '@/jobs/sla';
import { registerCleanupWorker } from '@/jobs/cleanup';
export async function bootstrapQueue(): Promise<void> {
registerAttachmentWorker();
registerAiSessionWorker();
registerSlaWorker();
registerCleanupWorker();
logger.info('Queue Manager initialized.');
}
+5
View File
@@ -55,6 +55,11 @@ const envSchema = z.object({
// ticket's context (FR-004/spec.md) — configurable, never hardcoded (Constitution Principle
// II), consistent with every other policy default in this codebase.
ORCHESTRATION_DEFAULT_STRATEGY: z.string().default('ROUND_ROBIN'),
// Problem Resolution (009) — how long a ticket waits in RESOLUTION_PENDING_CUSTOMER with no
// explicit customer confirmation before the auto-close sweep resolves it — see
// specs/009-problem-resolution/research.md "auto-close waiting period".
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72),
});
export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -5,3 +5,4 @@ export * from './queue';
export * from './storage';
export * from './ai';
export * from './orchestration';
export * from './problem-resolution';
+5
View File
@@ -0,0 +1,5 @@
import { env } from './env';
export const problemResolutionConfig = {
autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS,
};
+22 -1
View File
@@ -1,8 +1,29 @@
import { queueManager, QueueName } from '@/infrastructure/queue';
import { logger } from '@/infrastructure/observability';
import { resolutionsService } from '@/modules/problem-management/resolutions';
const AUTO_CLOSE_SWEEP_INTERVAL_MS = 5 * 60_000;
/**
* 009-problem-resolution research.md "Auto-close is a repeatable BullMQ job on the existing,
* unclaimed CLEANUP queue" — mirrors 008's SLA breach-detection job registration exactly: a
* repeatable job whose processor calls a directly-callable sweep method containing all the real
* logic, durable via BullMQ's own persisted repeatable-job state (Constitution Principle VII).
*/
export function registerCleanupWorker(): void {
queueManager.registerWorker(QueueName.CLEANUP, async (job) => {
logger.info({ jobId: job.id, data: job.data }, 'Processing Cleanup Job');
logger.info({ jobId: job.id }, 'Running resolution auto-close sweep');
await resolutionsService.runAutoCloseSweep();
});
void queueManager.getQueue(QueueName.CLEANUP).add(
'auto-close-resolutions',
{
jobId: 'auto-close-resolutions',
type: 'auto-close-resolutions',
payload: {},
createdAt: new Date().toISOString(),
},
{ repeat: { every: AUTO_CLOSE_SWEEP_INTERVAL_MS } },
);
}
+2 -2
View File
@@ -21,5 +21,5 @@ export type { ProductIntegrationWithProduct } from './repository';
export { decryptCredential, encryptCredential, generateCredentialSecret } from './mapper';
export { issueIntegrationToken, verifyIntegrationToken } from './mapper';
export type { IntegrationTokenClaims, IntegrationTokenClaimsInput } from './mapper';
export { inboundRequestSchema } from './schema';
export type { InboundRequest } from './schema';
export { inboundRequestSchema, identityOnlyRequestSchema } from './schema';
export type { InboundRequest, IdentityOnlyRequest } from './schema';
@@ -20,3 +20,20 @@ export const inboundRequestSchema = z
.strict();
export type InboundRequest = z.infer<typeof inboundRequestSchema>;
/**
* 009-problem-resolution: the identity-only subset of the inbound contract — for a caller
* already acting on an existing ticket (confirm-resolution, reopen) rather than creating one, so
* `source`/`problem` (ticket-creation-specific) aren't required. Every other verification step
* (token validity, replay, scope, revocation) is identical — see
* product-integration-auth.plugin.ts's shared verifyIntegrationIdentity.
*/
export const identityOnlyRequestSchema = z
.object({
productId: z.string().min(1),
tenantId: z.string().min(1),
userId: z.string().min(1),
})
.strict();
export type IdentityOnlyRequest = z.infer<typeof identityOnlyRequestSchema>;
@@ -0,0 +1,3 @@
export const INVESTIGATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_INVESTIGATION',
} as const;
@@ -0,0 +1 @@
export { InvestigationController, investigationController } from './investigation.controller';
@@ -0,0 +1,28 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { investigationService, InvestigationService } from '../service';
import { createInvestigationSchema } from '../schema';
export class InvestigationController {
constructor(private readonly service: InvestigationService = investigationService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const body = createInvestigationSchema.parse(request.body);
const investigation = await this.service.record(problemId, body);
return reply.status(201).send({ success: true, data: investigation, meta: null });
}
async list(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const investigations = await this.service.listForProblem(problemId);
return reply.status(200).send({ success: true, data: investigations, meta: null });
}
async listCustomerSafe(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const investigations = await this.service.listForProblemCustomerSafe(problemId);
return reply.status(200).send({ success: true, data: investigations, meta: null });
}
}
export const investigationController = new InvestigationController();
@@ -1,11 +1,5 @@
export const INVESTIGATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_INVESTIGATION',
} as const;
export class InvestigationService {
async getInvestigationStatus(_problemId: string) {
return { status: 'PENDING' };
}
}
export const investigationService = new InvestigationService();
export { investigationRoutes } from './routes';
export { InvestigationService, investigationService } from './service';
export type { CustomerSafeInvestigation } from './service';
export { investigationRepository, InvestigationRepository } from './repository';
export { INVESTIGATION_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './investigation.repository';
@@ -0,0 +1,39 @@
import { Investigation, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateInvestigationData {
problemId: string;
investigator: string;
findings: object;
evidence?: object | undefined;
internalNotes?: string | undefined;
status?: string | undefined;
}
export class InvestigationRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateInvestigationData): Promise<Investigation> {
return this.prisma.investigation.create({
data: data as Prisma.InvestigationUncheckedCreateInput,
});
}
/** research.md "version-row-per-attempt": every investigation is preserved; this is the full
* ordered set, newest first. */
async findAllForProblem(problemId: string): Promise<Investigation[]> {
return this.prisma.investigation.findMany({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
async findMostRecentForProblem(problemId: string): Promise<Investigation | null> {
return this.prisma.investigation.findFirst({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
}
export const investigationRepository = new InvestigationRepository();
@@ -0,0 +1 @@
export { investigationRoutes } from './investigation.routes';
@@ -0,0 +1,21 @@
import { FastifyInstance } from 'fastify';
import { investigationController } from '../controller';
/** contracts/problem-resolution-contract.md: the write route and the internalNotes-including
* read are agent-facing (fastify.authenticate); the customer-safe read is ungated (same "public
* read path" convention as 003's own ticket status reads). */
export async function investigationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/problems/:problemId/investigations',
{ preHandler: fastify.authenticate },
(req, reply) => investigationController.record(req, reply),
);
fastify.get(
'/admin/problems/:problemId/investigations',
{ preHandler: fastify.authenticate },
(req, reply) => investigationController.list(req, reply),
);
fastify.get('/problems/:problemId/investigations', (req, reply) =>
investigationController.listCustomerSafe(req, reply),
);
}
@@ -0,0 +1 @@
export * from './investigation.schema';
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const createInvestigationSchema = z
.object({
investigator: z.string().min(1),
findings: z.record(z.string(), z.unknown()),
evidence: z.record(z.string(), z.unknown()).optional(),
internalNotes: z.string().optional(),
status: z.enum(['open', 'complete']).optional(),
})
.strict();
export type CreateInvestigationBody = z.infer<typeof createInvestigationSchema>;
@@ -0,0 +1,2 @@
export { InvestigationService, investigationService } from './investigation.service';
export type { CustomerSafeInvestigation } from './investigation.service';
@@ -0,0 +1,43 @@
import { Investigation } from '@prisma/client';
import { NotFoundError } from '@/common/errors';
import { problemsRepository } from '@/modules/ticketing/tickets';
import { investigationRepository, InvestigationRepository } from '../repository';
import { CreateInvestigationBody } from '../schema';
export type CustomerSafeInvestigation = Omit<Investigation, 'internalNotes'>;
export class InvestigationService {
constructor(private readonly repo: InvestigationRepository = investigationRepository) {}
async record(problemId: string, body: CreateInvestigationBody): Promise<Investigation> {
const problem = await problemsRepository.findById(problemId);
if (!problem) throw new NotFoundError('Problem not found.');
return this.repo.create({
problemId,
investigator: body.investigator,
findings: body.findings,
evidence: body.evidence,
internalNotes: body.internalNotes,
status: body.status ?? 'open',
});
}
async listForProblem(problemId: string): Promise<Investigation[]> {
return this.repo.findAllForProblem(problemId);
}
/** FR-003: internalNotes is never exposed on a customer-facing read. */
async listForProblemCustomerSafe(problemId: string): Promise<CustomerSafeInvestigation[]> {
const investigations = await this.repo.findAllForProblem(problemId);
return investigations.map(({ internalNotes: _internalNotes, ...rest }) => rest);
}
/** FR-006: the existence gate `root-causes` calls through this module's public index. */
async hasAnyForProblem(problemId: string): Promise<boolean> {
const mostRecent = await this.repo.findMostRecentForProblem(problemId);
return mostRecent !== null;
}
}
export const investigationService = new InvestigationService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const RESOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_RESOLUTIONS',
} as const;
@@ -0,0 +1 @@
export { ResolutionsController, resolutionsController } from './resolutions.controller';
@@ -0,0 +1,40 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthorizationError } from '@/common/errors';
import { ticketsService } from '@/modules/ticketing/tickets';
import { resolutionsService, ResolutionsService } from '../service';
import { createResolutionSchema } from '../schema';
export class ResolutionsController {
constructor(private readonly service: ResolutionsService = resolutionsService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const body = createResolutionSchema.parse(request.body);
const resolution = await this.service.record(ticketId, body);
return reply.status(201).send({ success: true, data: resolution, meta: null });
}
async getByTicketId(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const resolution = await this.service.getByTicketId(ticketId);
return reply.status(200).send({ success: true, data: resolution, meta: null });
}
/** contracts/problem-resolution-contract.md: the caller's own token (set on reqContext by
* fastify.authenticateProductIntegration) must identify the same tenant/user as the ticket's
* own recorded values — one customer can never confirm another tenant's ticket. */
async confirmByCustomer(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const { tenantId, actorId } = request.reqContext;
const ticket = await ticketsService.getById(ticketId);
if (ticket.externalTenantId !== tenantId || ticket.externalUserId !== actorId) {
throw new AuthorizationError('This ticket does not belong to the calling customer.');
}
await this.service.confirmByCustomer(ticketId, 'customer');
return reply.status(200).send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null });
}
}
export const resolutionsController = new ResolutionsController();
@@ -1,11 +1,4 @@
export const RESOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_RESOLUTIONS',
} as const;
export class ResolutionsService {
async getResolutions(_problemId: string) {
return [];
}
}
export const resolutionsService = new ResolutionsService();
export { resolutionsRoutes } from './routes';
export { ResolutionsService, resolutionsService } from './service';
export { resolutionRepository, ResolutionRepository } from './repository';
export { RESOLUTIONS_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './resolution.repository';
@@ -0,0 +1,20 @@
import { Resolution } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class ResolutionRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: {
ticketId: string;
outcome: string;
resolvedBy: string;
}): Promise<Resolution> {
return this.prisma.resolution.create({ data });
}
async findByTicketId(ticketId: string): Promise<Resolution | null> {
return this.prisma.resolution.findUnique({ where: { ticketId } });
}
}
export const resolutionRepository = new ResolutionRepository();
@@ -0,0 +1 @@
export { resolutionsRoutes } from './resolutions.routes';
@@ -0,0 +1,28 @@
import { FastifyInstance } from 'fastify';
import { resolutionsController } from '../controller';
/** contracts/problem-resolution-contract.md: admin write/read gated by fastify.authenticate;
* customer confirmation reuses 002's inbound trust boundary (research.md), never the internal
* agent/admin auth mechanism. */
export async function resolutionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/tickets/:ticketId/resolution',
{ preHandler: fastify.authenticate },
(req, reply) => resolutionsController.record(req, reply),
);
fastify.get(
'/admin/tickets/:ticketId/resolution',
{ preHandler: fastify.authenticate },
(req, reply) => resolutionsController.getByTicketId(req, reply),
);
fastify.post(
'/v1/support/tickets/:ticketId/confirm-resolution',
{
preHandler: [
fastify.authenticateProductIntegrationIdentity,
fastify.checkIntegrationRateLimit,
],
},
(req, reply) => resolutionsController.confirmByCustomer(req, reply),
);
}
@@ -0,0 +1 @@
export * from './resolution.schema';
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const createResolutionSchema = z
.object({
outcome: z.string().min(1),
resolvedBy: z.string().min(1),
})
.strict();
export type CreateResolutionBody = z.infer<typeof createResolutionSchema>;
@@ -0,0 +1 @@
export { ResolutionsService, resolutionsService } from './resolutions.service';
@@ -0,0 +1,75 @@
import { Resolution } from '@prisma/client';
import { ConflictError, NotFoundError } from '@/common/errors';
import { ticketsService, ticketsRepository } from '@/modules/ticketing/tickets';
import { solutionsService } from '@/modules/problem-management/solutions';
import { problemResolutionConfig } from '@/config';
import { resolutionRepository, ResolutionRepository } from '../repository';
import { CreateResolutionBody } from '../schema';
export class ResolutionsService {
constructor(private readonly repo: ResolutionRepository = resolutionRepository) {}
/** FR-014: rejected unless the ticket's problem has a solution with a successful
* verification (research.md — no stored solutionId FK; resolved via a join at write time). */
async record(ticketId: string, body: CreateResolutionBody): Promise<Resolution> {
const ticket = await ticketsService.getById(ticketId);
const hasSuccessfulVerification = await solutionsService.hasSuccessfulVerification(
ticket.problemId,
);
if (!hasSuccessfulVerification) {
throw new ConflictError(
'A successfully verified solution must exist before a resolution can be recorded.',
);
}
const resolution = await this.repo.create({
ticketId,
outcome: body.outcome,
resolvedBy: body.resolvedBy,
});
await ticketsService.updateStatus(
ticketId,
'RESOLUTION_PENDING_CUSTOMER',
ticket.version,
body.resolvedBy,
);
return resolution;
}
async getByTicketId(ticketId: string): Promise<Resolution> {
const resolution = await this.repo.findByTicketId(ticketId);
if (!resolution) throw new NotFoundError('No resolution found for this ticket.');
return resolution;
}
/** FR-015: explicit customer confirmation — rejected if the ticket isn't actually pending. */
async confirmByCustomer(ticketId: string, actor: string): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
if (ticket.status !== 'RESOLUTION_PENDING_CUSTOMER') {
throw new ConflictError('This ticket is not awaiting customer confirmation.');
}
await ticketsService.updateStatus(ticketId, 'RESOLVED', ticket.version, actor);
}
/**
* FR-016: research.md "Auto-close is a repeatable BullMQ job on the existing, unclaimed
* CLEANUP queue" — a single, directly-callable, side-effect-only sweep (no worker process
* needed to invoke it in tests), mirroring 008's runBreachDetectionSweep exactly.
*/
async runAutoCloseSweep(): Promise<void> {
const cutoff = new Date(
Date.now() - problemResolutionConfig.autoCloseWaitingHours * 60 * 60 * 1000,
);
const due = await ticketsRepository.findPendingCustomerConfirmationOlderThan(cutoff);
for (const ticket of due) {
await ticketsService.updateStatus(ticket.id, 'RESOLVED', ticket.version, 'system');
}
}
}
export const resolutionsService = new ResolutionsService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const ROOT_CAUSES_CONSTANTS = {
MODULE_NAME: 'PROBLEM_ROOT_CAUSES',
} as const;
@@ -0,0 +1 @@
export { RootCausesController, rootCausesController } from './root-causes.controller';
@@ -0,0 +1,22 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { rootCausesService, RootCausesService } from '../service';
import { createRootCauseSchema } from '../schema';
export class RootCausesController {
constructor(private readonly service: RootCausesService = rootCausesService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const body = createRootCauseSchema.parse(request.body);
const rootCause = await this.service.record(problemId, body);
return reply.status(201).send({ success: true, data: rootCause, meta: null });
}
async list(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const rootCauses = await this.service.listForProblem(problemId);
return reply.status(200).send({ success: true, data: rootCauses, meta: null });
}
}
export const rootCausesController = new RootCausesController();
@@ -1,11 +1,5 @@
export const ROOT_CAUSES_CONSTANTS = {
MODULE_NAME: 'PROBLEM_ROOT_CAUSES',
} as const;
export class RootCausesService {
async getRootCause(_problemId: string) {
return null;
}
}
export const rootCausesService = new RootCausesService();
export { rootCausesRoutes } from './routes';
export { RootCausesService, rootCausesService } from './service';
export { rootCauseRepository, RootCauseRepository } from './repository';
export { ROOT_CAUSE_TYPES } from './schema';
export { ROOT_CAUSES_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './root-cause.repository';
@@ -0,0 +1,32 @@
import { RootCause } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateRootCauseData {
problemId: string;
type: string;
description: string;
}
export class RootCauseRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateRootCauseData): Promise<RootCause> {
return this.prisma.rootCause.create({ data });
}
async findAllForProblem(problemId: string): Promise<RootCause[]> {
return this.prisma.rootCause.findMany({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
async findMostRecentForProblem(problemId: string): Promise<RootCause | null> {
return this.prisma.rootCause.findFirst({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
}
export const rootCauseRepository = new RootCauseRepository();
@@ -0,0 +1 @@
export { rootCausesRoutes } from './root-causes.routes';
@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import { rootCausesController } from '../controller';
export async function rootCausesRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/problems/:problemId/root-causes',
{ preHandler: fastify.authenticate },
(req, reply) => rootCausesController.record(req, reply),
);
fastify.get(
'/admin/problems/:problemId/root-causes',
{ preHandler: fastify.authenticate },
(req, reply) => rootCausesController.list(req, reply),
);
}
@@ -0,0 +1 @@
export * from './root-cause.schema';
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const ROOT_CAUSE_TYPES = [
'technical',
'configuration',
'external_dependency',
'business',
'contributing_factor',
] as const;
export const createRootCauseSchema = z
.object({
type: z.enum(ROOT_CAUSE_TYPES),
description: z.string().min(1),
})
.strict();
export type CreateRootCauseBody = z.infer<typeof createRootCauseSchema>;
@@ -0,0 +1 @@
export { RootCausesService, rootCausesService } from './root-causes.service';
@@ -0,0 +1,35 @@
import { RootCause } from '@prisma/client';
import { ConflictError, NotFoundError } from '@/common/errors';
import { problemsRepository } from '@/modules/ticketing/tickets';
import { investigationService } from '@/modules/problem-management/investigation';
import { rootCauseRepository, RootCauseRepository } from '../repository';
import { CreateRootCauseBody } from '../schema';
export class RootCausesService {
constructor(private readonly repo: RootCauseRepository = rootCauseRepository) {}
/** FR-006: rejected if the problem has no investigation on file yet. */
async record(problemId: string, body: CreateRootCauseBody): Promise<RootCause> {
const problem = await problemsRepository.findById(problemId);
if (!problem) throw new NotFoundError('Problem not found.');
const hasInvestigation = await investigationService.hasAnyForProblem(problemId);
if (!hasInvestigation) {
throw new ConflictError('An investigation must exist before a root cause can be recorded.');
}
return this.repo.create({ problemId, type: body.type, description: body.description });
}
async listForProblem(problemId: string): Promise<RootCause[]> {
return this.repo.findAllForProblem(problemId);
}
/** FR-009: the existence gate `solutions` calls through this module's public index. */
async hasAnyForProblem(problemId: string): Promise<boolean> {
const mostRecent = await this.repo.findMostRecentForProblem(problemId);
return mostRecent !== null;
}
}
export const rootCausesService = new RootCausesService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const SOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_SOLUTIONS',
} as const;
@@ -0,0 +1 @@
export { SolutionsController, solutionsController } from './solutions.controller';
@@ -0,0 +1,29 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { solutionsService, SolutionsService } from '../service';
import { createSolutionSchema, createImplementationSchema } from '../schema';
export class SolutionsController {
constructor(private readonly service: SolutionsService = solutionsService) {}
async propose(request: FastifyRequest, reply: FastifyReply) {
const { problemId } = request.params as { problemId: string };
const body = createSolutionSchema.parse(request.body);
const solution = await this.service.propose(problemId, body);
return reply.status(201).send({ success: true, data: solution, meta: null });
}
async approve(request: FastifyRequest, reply: FastifyReply) {
const { solutionId } = request.params as { solutionId: string };
const solution = await this.service.approve(solutionId);
return reply.status(200).send({ success: true, data: solution, meta: null });
}
async recordImplementation(request: FastifyRequest, reply: FastifyReply) {
const { solutionId } = request.params as { solutionId: string };
const body = createImplementationSchema.parse(request.body);
const implementation = await this.service.recordImplementation(solutionId, body);
return reply.status(201).send({ success: true, data: implementation, meta: null });
}
}
export const solutionsController = new SolutionsController();
@@ -1,11 +1,9 @@
export const SOLUTIONS_CONSTANTS = {
MODULE_NAME: 'PROBLEM_SOLUTIONS',
} as const;
export class SolutionsService {
async getSolutions(_problemId: string) {
return [];
}
}
export const solutionsService = new SolutionsService();
export { solutionsRoutes } from './routes';
export { SolutionsService, solutionsService } from './service';
export {
solutionRepository,
SolutionRepository,
solutionImplementationRepository,
SolutionImplementationRepository,
} from './repository';
export { SOLUTIONS_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,2 @@
export * from './solution.repository';
export * from './solution-implementation.repository';
@@ -0,0 +1,24 @@
import { SolutionImplementation, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateImplementationData {
solutionId: string;
notes?: string | undefined;
implementedBy: string;
}
export class SolutionImplementationRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateImplementationData): Promise<SolutionImplementation> {
return this.prisma.solutionImplementation.create({
data: data as Prisma.SolutionImplementationUncheckedCreateInput,
});
}
async findBySolutionId(solutionId: string): Promise<SolutionImplementation | null> {
return this.prisma.solutionImplementation.findUnique({ where: { solutionId } });
}
}
export const solutionImplementationRepository = new SolutionImplementationRepository();
@@ -0,0 +1,36 @@
import { Solution } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export class SolutionRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: { problemId: string; proposed: string }): Promise<Solution> {
return this.prisma.solution.create({ data });
}
async findById(id: string): Promise<Solution | null> {
return this.prisma.solution.findUnique({ where: { id } });
}
async approve(id: string): Promise<Solution> {
return this.prisma.solution.update({ where: { id }, data: { approved: true } });
}
async findMostRecentForProblem(problemId: string): Promise<Solution | null> {
return this.prisma.solution.findFirst({
where: { problemId },
orderBy: { createdAt: 'desc' },
});
}
/** FR-014: every solution for a problem whose verification succeeded — Resolution's own
* existence check (research.md — no stored solutionId FK, resolved via this join instead). */
async findWithSuccessfulVerification(problemId: string): Promise<Solution | null> {
return this.prisma.solution.findFirst({
where: { problemId, verification: { result: 'success' } },
orderBy: { createdAt: 'desc' },
});
}
}
export const solutionRepository = new SolutionRepository();
@@ -0,0 +1 @@
export { solutionsRoutes } from './solutions.routes';
@@ -0,0 +1,20 @@
import { FastifyInstance } from 'fastify';
import { solutionsController } from '../controller';
export async function solutionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/problems/:problemId/solutions',
{ preHandler: fastify.authenticate },
(req, reply) => solutionsController.propose(req, reply),
);
fastify.patch(
'/admin/solutions/:solutionId/approve',
{ preHandler: fastify.authenticate },
(req, reply) => solutionsController.approve(req, reply),
);
fastify.post(
'/admin/solutions/:solutionId/implementation',
{ preHandler: fastify.authenticate },
(req, reply) => solutionsController.recordImplementation(req, reply),
);
}
@@ -0,0 +1 @@
export * from './solution.schema';
@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createSolutionSchema = z
.object({
proposed: z.string().min(1),
})
.strict();
export const createImplementationSchema = z
.object({
notes: z.string().optional(),
implementedBy: z.string().min(1),
})
.strict();
export type CreateSolutionBody = z.infer<typeof createSolutionSchema>;
export type CreateImplementationBody = z.infer<typeof createImplementationSchema>;
@@ -0,0 +1 @@
export { SolutionsService, solutionsService } from './solutions.service';
@@ -0,0 +1,76 @@
import { Solution, SolutionImplementation } from '@prisma/client';
import { ConflictError, NotFoundError } from '@/common/errors';
import { problemsRepository } from '@/modules/ticketing/tickets';
import { rootCausesService } from '@/modules/problem-management/root-causes';
import {
solutionRepository,
SolutionRepository,
solutionImplementationRepository,
SolutionImplementationRepository,
} from '../repository';
import { CreateSolutionBody, CreateImplementationBody } from '../schema';
export class SolutionsService {
constructor(
private readonly solutions: SolutionRepository = solutionRepository,
private readonly implementations: SolutionImplementationRepository = solutionImplementationRepository,
) {}
/** FR-009: rejected if the problem has no root cause on file yet. */
async propose(problemId: string, body: CreateSolutionBody): Promise<Solution> {
const problem = await problemsRepository.findById(problemId);
if (!problem) throw new NotFoundError('Problem not found.');
const hasRootCause = await rootCausesService.hasAnyForProblem(problemId);
if (!hasRootCause) {
throw new ConflictError('A root cause must exist before a solution can be proposed.');
}
return this.solutions.create({ problemId, proposed: body.proposed });
}
async getById(solutionId: string): Promise<Solution> {
const solution = await this.solutions.findById(solutionId);
if (!solution) throw new NotFoundError('Solution not found.');
return solution;
}
async approve(solutionId: string): Promise<Solution> {
await this.getById(solutionId);
return this.solutions.approve(solutionId);
}
/** FR-008: rejected if the solution isn't approved, or already has an implementation. */
async recordImplementation(
solutionId: string,
body: CreateImplementationBody,
): Promise<SolutionImplementation> {
const solution = await this.getById(solutionId);
if (!solution.approved) {
throw new ConflictError('The solution must be approved before it can be implemented.');
}
const existing = await this.implementations.findBySolutionId(solutionId);
if (existing) {
throw new ConflictError('This solution already has an implementation on file.');
}
return this.implementations.create({
solutionId,
notes: body.notes,
implementedBy: body.implementedBy,
});
}
async getImplementation(solutionId: string): Promise<SolutionImplementation | null> {
return this.implementations.findBySolutionId(solutionId);
}
/** FR-014: the existence gate `resolutions` calls through this module's public index. */
async hasSuccessfulVerification(problemId: string): Promise<boolean> {
const solution = await this.solutions.findWithSuccessfulVerification(problemId);
return solution !== null;
}
}
export const solutionsService = new SolutionsService();
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,3 @@
export const VERIFICATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_VERIFICATION',
} as const;
@@ -0,0 +1 @@
export { VerificationController, verificationController } from './verification.controller';
@@ -0,0 +1,16 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { verificationService, VerificationService } from '../service';
import { createVerificationSchema } from '../schema';
export class VerificationController {
constructor(private readonly service: VerificationService = verificationService) {}
async record(request: FastifyRequest, reply: FastifyReply) {
const { solutionId } = request.params as { solutionId: string };
const body = createVerificationSchema.parse(request.body);
const verification = await this.service.record(solutionId, body);
return reply.status(201).send({ success: true, data: verification, meta: null });
}
}
export const verificationController = new VerificationController();
@@ -1,11 +1,5 @@
export const VERIFICATION_CONSTANTS = {
MODULE_NAME: 'PROBLEM_VERIFICATION',
} as const;
export class VerificationService {
async verifySolution(_solutionId: string) {
return { verified: false };
}
}
export const verificationService = new VerificationService();
export { verificationRoutes } from './routes';
export { VerificationService, verificationService } from './service';
export { solutionVerificationRepository, SolutionVerificationRepository } from './repository';
export { VERIFICATION_METHODS } from './schema';
export { VERIFICATION_CONSTANTS } from './constants';
@@ -0,0 +1 @@
export {};
@@ -0,0 +1 @@
export * from './solution-verification.repository';
@@ -0,0 +1,25 @@
import { SolutionVerification, Prisma } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database';
export interface CreateVerificationData {
solutionId: string;
method: string;
result: string;
evidence?: object | undefined;
}
export class SolutionVerificationRepository {
constructor(private readonly prisma = prismaClient) {}
async create(data: CreateVerificationData): Promise<SolutionVerification> {
return this.prisma.solutionVerification.create({
data: data as Prisma.SolutionVerificationUncheckedCreateInput,
});
}
async findBySolutionId(solutionId: string): Promise<SolutionVerification | null> {
return this.prisma.solutionVerification.findUnique({ where: { solutionId } });
}
}
export const solutionVerificationRepository = new SolutionVerificationRepository();
@@ -0,0 +1 @@
export { verificationRoutes } from './verification.routes';
@@ -0,0 +1,10 @@
import { FastifyInstance } from 'fastify';
import { verificationController } from '../controller';
export async function verificationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/solutions/:solutionId/verification',
{ preHandler: fastify.authenticate },
(req, reply) => verificationController.record(req, reply),
);
}
@@ -0,0 +1 @@
export * from './verification.schema';
@@ -0,0 +1,18 @@
import { z } from 'zod';
export const VERIFICATION_METHODS = [
'automated',
'technical_test',
'customer_confirmation',
'agent_confirmation',
] as const;
export const createVerificationSchema = z
.object({
method: z.enum(VERIFICATION_METHODS),
result: z.enum(['success', 'failed']),
evidence: z.record(z.string(), z.unknown()).optional(),
})
.strict();
export type CreateVerificationBody = z.infer<typeof createVerificationSchema>;
@@ -0,0 +1 @@
export { VerificationService, verificationService } from './verification.service';
@@ -0,0 +1,36 @@
import { SolutionVerification } from '@prisma/client';
import { ConflictError } from '@/common/errors';
import { solutionsService } from '@/modules/problem-management/solutions';
import { solutionVerificationRepository, SolutionVerificationRepository } from '../repository';
import { CreateVerificationBody } from '../schema';
export class VerificationService {
constructor(
private readonly repo: SolutionVerificationRepository = solutionVerificationRepository,
) {}
/** FR-010: rejected if the solution has no implementation yet, or already has a verification
* (doc 06's own `solutionId @unique`). */
async record(solutionId: string, body: CreateVerificationBody): Promise<SolutionVerification> {
await solutionsService.getById(solutionId); // resolve-or-404
const implementation = await solutionsService.getImplementation(solutionId);
if (!implementation) {
throw new ConflictError('The solution must be implemented before it can be verified.');
}
const existing = await this.repo.findBySolutionId(solutionId);
if (existing) {
throw new ConflictError('This solution already has a verification on file.');
}
return this.repo.create({
solutionId,
method: body.method,
result: body.result,
evidence: body.evidence,
});
}
}
export const verificationService = new VerificationService();
@@ -0,0 +1 @@
export {};
@@ -1,4 +1,5 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthorizationError } from '@/common/errors';
import { ticketsService, TicketsService } from '../service';
import { updateTicketStatusSchema } from '../schema';
@@ -26,6 +27,28 @@ export class TicketsController {
);
return reply.status(200).send({ success: true, data: ticket, meta: null });
}
/** 009-problem-resolution FR-017: agent-facing reopen. */
async reopen(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const ticket = await this.service.reopen(ticketId, actorFrom(request));
return reply.status(200).send({ success: true, data: ticket, meta: null });
}
/** Customer-facing reopen — same tenant/user ownership check as resolutions' confirm-
* resolution (contracts/problem-resolution-contract.md). */
async reopenByCustomer(request: FastifyRequest, reply: FastifyReply) {
const { ticketId } = request.params as { ticketId: string };
const { tenantId, actorId } = request.reqContext;
const ticket = await this.service.getById(ticketId);
if (ticket.externalTenantId !== tenantId || ticket.externalUserId !== actorId) {
throw new AuthorizationError('This ticket does not belong to the calling customer.');
}
const reopened = await this.service.reopen(ticketId, 'customer');
return reply.status(200).send({ success: true, data: reopened, meta: null });
}
}
export const ticketsController = new TicketsController();
+5
View File
@@ -9,3 +9,8 @@ export type { TicketStatus } from './mapper';
// existing module's public surface for a later feature" precedent 004 used for
// catalog/products' productsRepository.
export { problemsRepository, ProblemsRepository } from './repository';
// 009-problem-resolution: the auto-close sweep and the reopen flow both need direct
// TicketsRepository access (a status+age query, and a plain existence-agnostic status read) —
// same "extend an existing module's public surface for a later feature" precedent as
// problemsRepository above.
export { ticketsRepository, TicketsRepository } from './repository';
@@ -99,6 +99,14 @@ export class TicketsRepository {
if (result.count === 0) return null;
return this.prisma.ticket.findUnique({ where: { id } });
}
/** 009-problem-resolution: every ticket waiting on customer confirmation whose last status
* change (`updatedAt`) is older than the auto-close cutoff — the sweep's own query. */
async findPendingCustomerConfirmationOlderThan(cutoff: Date): Promise<Ticket[]> {
return this.prisma.ticket.findMany({
where: { status: 'RESOLUTION_PENDING_CUSTOMER', updatedAt: { lte: cutoff } },
});
}
}
export const ticketsRepository = new TicketsRepository();
@@ -9,4 +9,21 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.patch('/tickets/:ticketId/status', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.updateStatus(req, reply),
);
// 009-problem-resolution FR-017: agent-facing reopen (fastify.authenticate) and customer-
// facing reopen (002's inbound trust boundary, research.md) both funnel through the same
// TicketsService.reopen.
fastify.post('/admin/tickets/:ticketId/reopen', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.reopen(req, reply),
);
fastify.post(
'/v1/support/tickets/:ticketId/reopen',
{
preHandler: [
fastify.authenticateProductIntegrationIdentity,
fastify.checkIntegrationRateLimit,
],
},
(req, reply) => ticketsController.reopenByCustomer(req, reply),
);
}
@@ -170,6 +170,27 @@ export class TicketsService {
return updated;
}
/**
* 009-problem-resolution FR-017: rejected unless the ticket is RESOLVED or CLOSED. Two real,
* separately-audited transitions (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS) rather than one
* collapsed hop — research.md "The reopen transition is two real, separately-audited status
* updates". Touches nothing else — no new SLARun, no mutation of any prior problem-resolution
* record (FR-018).
*/
async reopen(ticketId: string, actor: string): Promise<Ticket> {
const ticket = await this.getById(ticketId);
if (ticket.status !== 'RESOLVED' && ticket.status !== 'CLOSED') {
throw new AppError(
`Cannot reopen a ticket in status ${ticket.status} — only RESOLVED or CLOSED tickets can be reopened.`,
'CONFLICT',
409,
);
}
const reopened = await this.updateStatus(ticketId, 'REOPENED', ticket.version, actor);
return this.updateStatus(ticketId, 'IN_PROGRESS', reopened.version, actor);
}
}
export const ticketsService = new TicketsService();
+159 -120
View File
@@ -18,6 +18,7 @@ import {
decryptCredential,
verifyIntegrationToken,
inboundRequestSchema,
identityOnlyRequestSchema,
InboundRequest,
writeIntegrationAuditEvent,
} from '@/modules/catalog/products';
@@ -25,6 +26,10 @@ import {
declare module 'fastify' {
interface FastifyInstance {
authenticateProductIntegration: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
authenticateProductIntegrationIdentity: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void>;
checkIntegrationRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
interface FastifyRequest {
@@ -48,6 +53,142 @@ function credentialError(message: string): AppError {
return new AppError(message, INTEGRATION_ERROR_CODES.INVALID_CREDENTIAL, 401);
}
/**
* Steps 2-10 (everything after the request body's own shape is known) plus success bookkeeping
* — shared by both `authenticateProductIntegration` (full inbound-request body, ticket creation)
* and `authenticateProductIntegrationIdentity` (identity-only body, 009-problem-resolution's
* confirm-resolution/reopen — an existing ticket, not a new one, so `source`/`problem` don't
* apply). Both decorators parse their own body shape first, then call this with just the three
* fields every verification step actually needs.
*/
async function verifyIntegrationIdentity(
body: { productId: string; tenantId: string; userId: string },
request: FastifyRequest,
integrationsRepo: ProductIntegrationsRepository,
customerRefsRepo: CustomerReferencesRepository,
): Promise<void> {
// Step 2: Authorization header present and well-formed.
const authHeader = request.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token) {
throw credentialError('Missing or malformed Authorization header.');
}
// Step 3: a registered, resolvable ProductIntegration for this productId.
const integration = await integrationsRepo.findActiveByExternalProductId(body.productId);
if (!integration) {
// Unresolvable — nothing to audit against as a known entity; audit with a synthetic
// actor so the attempt still leaves a trace without inventing a fake entityId.
await writeIntegrationAuditEvent({
actor: 'unknown',
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: `unregistered:${body.productId}`,
reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL,
});
throw credentialError('Invalid integration credential.');
}
const auditFailure = (reason: string) =>
writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: integration.id,
reason,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
// Step 4-5: token verifies (current secret, then previous secret if still in its
// rotation transition window) and is not expired.
const currentSecret = decryptCredential(integration.credentialRef);
let verifyResult = verifyIntegrationToken(currentSecret, token);
if (
!verifyResult.valid &&
integration.previousCredentialRef &&
integration.previousCredentialExpiresAt &&
integration.previousCredentialExpiresAt > new Date()
) {
const previousSecret = decryptCredential(integration.previousCredentialRef);
verifyResult = verifyIntegrationToken(previousSecret, token);
}
if (!verifyResult.valid) {
const reason =
verifyResult.reason === 'expired'
? INTEGRATION_AUDIT_FAILURE_REASONS.EXPIRED
: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL;
await auditFailure(reason);
throw credentialError('Invalid integration credential.');
}
// Cross-check: a valid token for a DIFFERENT product can't be replayed against this one.
if (verifyResult.claims.externalProductId !== body.productId) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 6: replay check.
if (await hasSeenJti(verifyResult.claims.jti)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.REPLAYED);
throw credentialError('Invalid integration credential.');
}
// Step 7: not revoked.
if (integration.revokedAt) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 8-9: integration and product both active.
if (integration.status !== 'active' || integration.product.status !== 'active') {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.SUSPENDED);
throw new AppError(
'This product integration is currently suspended.',
INTEGRATION_ERROR_CODES.SUSPENDED,
403,
);
}
// Step 10: scope.
if (!isInScope(integration.allowedScope, body.tenantId)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.OUT_OF_SCOPE);
throw new AppError(
"This request is outside the integration's allowed scope.",
INTEGRATION_ERROR_CODES.OUT_OF_SCOPE,
403,
);
}
// All checks passed — mark the jti seen (bounded by the token's own remaining TTL),
// resolve the CustomerReference, populate reqContext, and record success.
const ttlRemaining = Math.max(
1,
verifyResult.claims.exp -
Math.floor(Date.now() / 1000) +
INTEGRATION_TOKEN_CONFIG.CLOCK_SKEW_TOLERANCE_SECONDS,
);
await markJtiSeen(verifyResult.claims.jti, ttlRemaining);
const customerRef = await customerRefsRepo.findOrCreate(body.userId, body.tenantId);
request.productIntegration = integration;
request.reqContext.productId = integration.product.id;
request.reqContext.customerId = customerRef.id;
request.reqContext.tenantId = body.tenantId;
request.reqContext.actorType = ActorType.CUSTOMER;
request.reqContext.actorId = body.userId;
await writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS,
entityId: integration.id,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
}
const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
integrationsRepo?: ProductIntegrationsRepository;
customerRefsRepo?: CustomerReferencesRepository;
@@ -69,128 +210,26 @@ const productIntegrationAuthPluginCallback: FastifyPluginAsync<{
);
}
const body = parsed.data;
// Step 2: Authorization header present and well-formed.
const authHeader = request.headers.authorization;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token) {
throw credentialError('Missing or malformed Authorization header.');
}
// Step 3: a registered, resolvable ProductIntegration for this productId.
const integration = await integrationsRepo.findActiveByExternalProductId(body.productId);
if (!integration) {
// Unresolvable — nothing to audit against as a known entity; audit with a synthetic
// actor so the attempt still leaves a trace without inventing a fake entityId.
await writeIntegrationAuditEvent({
actor: 'unknown',
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: `unregistered:${body.productId}`,
reason: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL,
});
throw credentialError('Invalid integration credential.');
}
const auditFailure = (reason: string) =>
writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_FAILURE,
entityId: integration.id,
reason,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
// Step 4-5: token verifies (current secret, then previous secret if still in its
// rotation transition window) and is not expired.
const currentSecret = decryptCredential(integration.credentialRef);
let verifyResult = verifyIntegrationToken(currentSecret, token);
if (
!verifyResult.valid &&
integration.previousCredentialRef &&
integration.previousCredentialExpiresAt &&
integration.previousCredentialExpiresAt > new Date()
) {
const previousSecret = decryptCredential(integration.previousCredentialRef);
verifyResult = verifyIntegrationToken(previousSecret, token);
}
if (!verifyResult.valid) {
const reason =
verifyResult.reason === 'expired'
? INTEGRATION_AUDIT_FAILURE_REASONS.EXPIRED
: INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL;
await auditFailure(reason);
throw credentialError('Invalid integration credential.');
}
// Cross-check: a valid token for a DIFFERENT product can't be replayed against this one.
if (verifyResult.claims.externalProductId !== body.productId) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 6: replay check.
if (await hasSeenJti(verifyResult.claims.jti)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.REPLAYED);
throw credentialError('Invalid integration credential.');
}
// Step 7: not revoked.
if (integration.revokedAt) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.INVALID_CREDENTIAL);
throw credentialError('Invalid integration credential.');
}
// Step 8-9: integration and product both active.
if (integration.status !== 'active' || integration.product.status !== 'active') {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.SUSPENDED);
throw new AppError(
'This product integration is currently suspended.',
INTEGRATION_ERROR_CODES.SUSPENDED,
403,
);
}
// Step 10: scope.
if (!isInScope(integration.allowedScope, body.tenantId)) {
await auditFailure(INTEGRATION_AUDIT_FAILURE_REASONS.OUT_OF_SCOPE);
throw new AppError(
"This request is outside the integration's allowed scope.",
INTEGRATION_ERROR_CODES.OUT_OF_SCOPE,
403,
);
}
// All checks passed — mark the jti seen (bounded by the token's own remaining TTL),
// resolve the CustomerReference, populate reqContext, and record success.
const ttlRemaining = Math.max(
1,
verifyResult.claims.exp -
Math.floor(Date.now() / 1000) +
INTEGRATION_TOKEN_CONFIG.CLOCK_SKEW_TOLERANCE_SECONDS,
);
await markJtiSeen(verifyResult.claims.jti, ttlRemaining);
const customerRef = await customerRefsRepo.findOrCreate(body.userId, body.tenantId);
request.validatedInboundBody = body;
request.productIntegration = integration;
request.reqContext.productId = integration.product.id;
request.reqContext.customerId = customerRef.id;
request.reqContext.tenantId = body.tenantId;
request.reqContext.actorType = ActorType.CUSTOMER;
request.reqContext.actorId = body.userId;
await writeIntegrationAuditEvent({
actor: integration.id,
actorType: 'system',
action: INTEGRATION_AUDIT_ACTIONS.AUTH_SUCCESS,
entityId: integration.id,
metadata: { externalUserId: body.userId, externalTenantId: body.tenantId },
});
await verifyIntegrationIdentity(body, request, integrationsRepo, customerRefsRepo);
},
);
fastify.decorate(
'authenticateProductIntegrationIdentity',
async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
const parsed = identityOnlyRequestSchema.safeParse(request.body);
if (!parsed.success) {
throw new AppError(
'Invalid request payload.',
'VALIDATION_ERROR',
400,
parsed.error.issues,
);
}
await verifyIntegrationIdentity(parsed.data, request, integrationsRepo, customerRefsRepo);
},
);
@@ -0,0 +1,550 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { resolutionsService } from '@/modules/problem-management/resolutions';
import { ticketsService } from '@/modules/ticketing/tickets';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/009-problem-resolution/quickstart.md Scenarios 1-6 against a real Postgres/Redis
* — the full doc04 workflow: investigation through root cause, solution, implementation,
* verification (both outcomes), resolution, customer confirmation, durable auto-close, and
* reopen (customer and agent).
*/
describe('Problem resolution — full flow (User Stories 1-6)', () => {
let app: FastifyInstance;
const externalProductId = `TEST_PR_PROD_${Date.now()}`;
const skillTag = `pr_skill_${Date.now()}`;
let productId: string;
let secret: string;
let teamId: string;
let agentId: string;
const createdTicketIds: string[] = [];
async function createTicket(): Promise<{ ticketId: string; problemId: string }> {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: `Needs resolution ${Date.now()}-${Math.random()}`,
},
});
const ticketId = created.json().data.ticketId as string;
createdTicketIds.push(ticketId);
const ticket = await ticketsService.getById(ticketId);
return { ticketId, problemId: ticket.problemId };
}
async function tokenForCurrentRequest(): Promise<string> {
return issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
}
function identityPayload() {
return { productId: externalProductId, tenantId: 'tenant-1', userId: 'user-1' };
}
async function escalateAndAssign(ticketId: string): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
async function fullyResolve(problemId: string, ticketId: string): Promise<void> {
await escalateAndAssign(ticketId);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: { note: 'checked logs' } },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'a bug' },
});
const solutionRes = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'apply fix' },
});
const solutionId = solutionRes.json().data.id;
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
}
beforeAll(async () => {
app = await buildApp();
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Problem Resolution Test Product', status: 'active' },
});
productId = product.id;
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: `PR Team ${Date.now()}` },
});
teamId = team.json().data.id;
const agent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'PR Agent' },
});
agentId = agent.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/${skillTag}`,
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'PR Node',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
const problemIds = (
await prismaClient.ticket.findMany({
where: { id: { in: createdTicketIds } },
select: { problemId: true },
})
).map((t) => t.problemId);
const problemFilter = { problemId: { in: problemIds } };
await prismaClient.resolution.deleteMany({ where: ticketFilter });
await prismaClient.solutionVerification.deleteMany({
where: { solution: problemFilter },
});
await prismaClient.solutionImplementation.deleteMany({
where: { solution: problemFilter },
});
await prismaClient.solution.deleteMany({ where: problemFilter });
await prismaClient.rootCause.deleteMany({ where: problemFilter });
await prismaClient.investigation.deleteMany({ where: problemFilter });
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'PR Node' } });
await prismaClient.agentSkill.deleteMany({ where: { agentId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { id: { in: problemIds } } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await app.close();
});
it('Scenario 1: structured investigation, preserved across attempts', async () => {
const { problemId } = await createTicket();
const record = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: {
investigator: 'agent-1',
findings: { checked: 'logs' },
evidence: { logId: 'abc' },
internalNotes: 'suspect race condition',
},
});
expect(record.statusCode).toBe(201);
const agentRead = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
});
expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition');
const customerRead = await app.inject({
method: 'GET',
url: `/problems/${problemId}/investigations`,
});
expect(customerRead.json().data[0].internalNotes).toBeUndefined();
const second = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: { checked: 'more logs' } },
});
expect(second.statusCode).toBe(201);
const both = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
});
expect(both.json().data.length).toBe(2);
});
it('Scenario 2: root cause requires an investigation on file', async () => {
const { problemId } = await createTicket();
const beforeInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
expect(beforeInvestigation.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
const afterInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'a real cause' },
});
expect(afterInvestigation.statusCode).toBe(201);
const invalidType = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'not_a_real_type', description: 'x' },
});
expect(invalidType.statusCode).toBe(400);
});
it('Scenario 3: solution proposed, approved, implemented as distinct states', async () => {
const { problemId } = await createTicket();
const beforeRootCause = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'x' },
});
expect(beforeRootCause.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'apply the fix' },
});
expect(proposed.statusCode).toBe(201);
expect(proposed.json().data.approved).toBe(false);
const solutionId = proposed.json().data.id;
const implBeforeApproval = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(implBeforeApproval.statusCode).toBe(409);
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
const impl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(impl.statusCode).toBe(201);
const secondImpl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(secondImpl.statusCode).toBe(409);
});
it('Scenario 4: verification, and what happens on failure', async () => {
const { problemId } = await createTicket();
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
const success = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
expect(success.statusCode).toBe(201);
// Failure path: a second problem/solution/implementation.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed2 = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/solutions`,
payload: { proposed: 'a wrong fix' },
});
const solution2Id = proposed2.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solution2Id}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/implementation`,
payload: { implementedBy: 'agent-1' },
});
const failed = await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/verification`,
payload: { method: 'agent_confirmation', result: 'failed' },
});
expect(failed.statusCode).toBe(201);
// No resolution can be recorded — problem2 has no successful verification.
const rejectedResolution = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/resolution`,
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejectedResolution.statusCode).toBe(409);
// Re-investigate path: a fresh Investigation row for the same problem.
const reInvestigate = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
payload: { investigator: 'agent-2', findings: { retried: true } },
});
expect(reInvestigate.statusCode).toBe(201);
const allInvestigations = await app.inject({
method: 'GET',
url: `/admin/problems/${problem2}/investigations`,
});
expect(allInvestigations.json().data.length).toBe(2);
// Escalate path: transition to HUMAN_ESCALATION, confirm 007 auto-assigns.
const ticketBeforeEscalate = await ticketsService.getById(ticket2);
const escalate = await app.inject({
method: 'PATCH',
url: `/tickets/${ticket2}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version },
});
expect(escalate.statusCode).toBe(200);
const assignment = await app.inject({
method: 'GET',
url: `/tickets/${ticket2}/assignment`,
});
expect(assignment.statusCode).toBe(200);
expect(assignment.json().data.agentId).toBe(agentId);
});
it('Scenario 5: resolution, customer confirmation, and durable auto-close', async () => {
const { ticketId, problemId } = await createTicket();
await escalateAndAssign(ticketId);
const rejected = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejected.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
const resolved = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
expect(resolved.statusCode).toBe(201);
const pendingTicket = await ticketsService.getById(ticketId);
expect(pendingTicket.status).toBe('RESOLUTION_PENDING_CUSTOMER');
const token = await tokenForCurrentRequest();
const confirm = await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/confirm-resolution`,
headers: { authorization: `Bearer ${token}` },
payload: identityPayload(),
});
expect(confirm.statusCode).toBe(200);
const confirmedTicket = await ticketsService.getById(ticketId);
expect(confirmedTicket.status).toBe('RESOLVED');
// Auto-close path: a second ticket, aged past the configured window, resolved by the sweep.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await fullyResolve(problem2, ticket2);
await prismaClient.ticket.update({
where: { id: ticket2 },
data: { updatedAt: new Date(Date.now() - 73 * 60 * 60 * 1000) }, // > default 72h
});
await resolutionsService.runAutoCloseSweep();
const autoClosedTicket = await ticketsService.getById(ticket2);
expect(autoClosedTicket.status).toBe('RESOLVED');
});
it('Scenario 6: reopen — customer and agent, leaving prior records untouched', async () => {
const { ticketId, problemId } = await createTicket();
await fullyResolve(problemId, ticketId);
const token = await tokenForCurrentRequest();
await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/confirm-resolution`,
headers: { authorization: `Bearer ${token}` },
payload: identityPayload(),
});
const resolutionBefore = await prismaClient.resolution.findUniqueOrThrow({
where: { ticketId },
});
const reopenToken = await tokenForCurrentRequest();
const reopen = await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/reopen`,
headers: { authorization: `Bearer ${reopenToken}` },
payload: identityPayload(),
});
expect(reopen.statusCode).toBe(200);
expect(reopen.json().data.status).toBe('IN_PROGRESS');
const resolutionAfter = await prismaClient.resolution.findUniqueOrThrow({
where: { ticketId },
});
expect(resolutionAfter).toEqual(resolutionBefore);
// Agent reopen of a CLOSED ticket.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await fullyResolve(problem2, ticket2);
const confirmToken = await tokenForCurrentRequest();
await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticket2}/confirm-resolution`,
headers: { authorization: `Bearer ${confirmToken}` },
payload: identityPayload(),
});
const resolvedTicket = await ticketsService.getById(ticket2);
await ticketsService.updateStatus(ticket2, 'CLOSED', resolvedTicket.version, 'system');
const agentReopen = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/reopen`,
});
expect(agentReopen.statusCode).toBe(200);
expect(agentReopen.json().data.status).toBe('IN_PROGRESS');
});
});
+19 -4
View File
@@ -132,9 +132,13 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
// Scoped to this test's own product, not a wildcard ([] matches every product per
// HierarchyNode's own scope-matching rule) — a wildcard node here would leak into any
// other test file's own (non-scoped) assignment resolution sharing the same live
// Postgres, corrupting their eligible-agent set with this file's unrelated skillTag.
name: 'SLA Node B (escalation target)',
order: 1,
productScope: [],
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
@@ -197,6 +201,12 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const dueAt = new Date(run.resolutionDueAt).getTime();
const expected = Date.now() + 60 * 60 * 1000; // resolutionMinutes: 60, businessCalendarId: null (24/7)
expect(Math.abs(dueAt - expected)).toBeLessThan(60 * 1000); // 1 minute tolerance
// This scenario's only job for the global (wildcard-scoped) policy is done — deactivate it
// immediately rather than leaving it live for the rest of the file's run, since a global
// SLAPolicy matches every ticket in the shared test database, including other test files'
// tickets running concurrently against the same Postgres.
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: false } });
});
it('Scenario 2: an assignment matching no active policy gets no SLARun', async () => {
@@ -216,9 +226,13 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` });
expect(runResponse.statusCode).toBe(404);
// Restore both policies for the remaining scenarios.
// Restore the product-scoped policy for the remaining scenarios — they all resolve through
// it (it's always more specific than the global one, FR-002), so the global policy is
// deliberately left deactivated here rather than reactivated: a global/wildcard-scoped
// SLAPolicy is live for every ticket in the shared test database for as long as it's
// active, including other test files' tickets running concurrently against the same
// Postgres — its job (Scenario 1's fallback-to-global assertion) is already done.
await prismaClient.sLAPolicy.update({ where: { id: productPolicyId }, data: { active: true } });
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: true } });
});
it('Scenario 3: pause/resume is durable across a genuine process restart', async () => {
@@ -257,7 +271,8 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const resumed = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } });
expect(resumed.status).toBe('running');
expect(resumed.pausedAt).toBeNull();
expect(resumed.resolutionDueAt!.getTime()).toBeGreaterThan(originalDueAt + 1000);
expect(resumed.resolutionDueAt).not.toBeNull();
expect(resumed.resolutionDueAt?.getTime()).toBeGreaterThan(originalDueAt + 1000);
});
it('Scenario 4: breach detection marks a run breached, never a completed or paused one', async () => {
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { findPendingCustomerConfirmationOlderThan, updateStatus } = vi.hoisted(() => ({
findPendingCustomerConfirmationOlderThan: vi.fn(),
updateStatus: vi.fn(),
}));
vi.mock('@/modules/ticketing/tickets', () => ({
ticketsRepository: { findPendingCustomerConfirmationOlderThan },
ticketsService: { updateStatus },
}));
import { ResolutionsService } from '@/modules/problem-management/resolutions/service/resolutions.service';
describe('ResolutionsService.runAutoCloseSweep', () => {
beforeEach(() => {
findPendingCustomerConfirmationOlderThan.mockReset();
updateStatus.mockReset();
});
it('resolves every ticket the repository returns as due, and only those', async () => {
findPendingCustomerConfirmationOlderThan.mockResolvedValue([
{ id: 't1', version: 3 },
{ id: 't2', version: 1 },
]);
updateStatus.mockResolvedValue({});
const service = new ResolutionsService();
await service.runAutoCloseSweep();
expect(updateStatus).toHaveBeenCalledTimes(2);
expect(updateStatus).toHaveBeenCalledWith('t1', 'RESOLVED', 3, 'system');
expect(updateStatus).toHaveBeenCalledWith('t2', 'RESOLVED', 1, 'system');
});
it('does nothing when no ticket is due — the repository query itself is the selection, not this method', async () => {
findPendingCustomerConfirmationOlderThan.mockResolvedValue([]);
const service = new ResolutionsService();
await service.runAutoCloseSweep();
expect(updateStatus).not.toHaveBeenCalled();
});
it('queries with a cutoff derived from the configured waiting period, not a hardcoded value', async () => {
findPendingCustomerConfirmationOlderThan.mockResolvedValue([]);
const before = Date.now();
const service = new ResolutionsService();
await service.runAutoCloseSweep();
const after = Date.now();
const [cutoff] = findPendingCustomerConfirmationOlderThan.mock.calls[0] as [Date];
const hoursAgo = (before - cutoff.getTime()) / (60 * 60 * 1000);
const hoursAgoAfter = (after - cutoff.getTime()) / (60 * 60 * 1000);
// Default is 72h (env.ts) unless overridden — assert it's in that neighborhood rather than
// hardcoding the exact default here too, so a legitimate config change doesn't break this.
expect(hoursAgo).toBeGreaterThan(0);
expect(hoursAgoAfter).toBeGreaterThan(0);
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { createRootCauseSchema, ROOT_CAUSE_TYPES } from '@/modules/problem-management/root-causes/schema/root-cause.schema';
describe('createRootCauseSchema', () => {
it('accepts every documented root cause type', () => {
for (const type of ROOT_CAUSE_TYPES) {
const result = createRootCauseSchema.safeParse({ type, description: 'some cause' });
expect(result.success).toBe(true);
}
});
it('rejects a type outside the five documented values', () => {
const result = createRootCauseSchema.safeParse({
type: 'user_error',
description: 'not a real type',
});
expect(result.success).toBe(false);
});
it('rejects an empty description', () => {
const result = createRootCauseSchema.safeParse({ type: 'technical', description: '' });
expect(result.success).toBe(false);
});
it('rejects unknown extra fields (strict schema)', () => {
const result = createRootCauseSchema.safeParse({
type: 'technical',
description: 'x',
extra: 'not allowed',
});
expect(result.success).toBe(false);
});
});