From 16daf8d32dc6359f26b1e9c6ae99c2fd18159d44 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Thu, 3 Sep 2026 15:09:29 +0530 Subject: [PATCH] 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 --- .../migration.sql | 105 ++++ prisma/schema.prisma | 83 +++ .../checklists/requirements.md | 28 + specs/009-problem-resolution/research.md | 43 +- specs/009-problem-resolution/tasks.md | 98 ++-- src/api/routes.ts | 10 + src/bootstrap/queue.bootstrap.ts | 2 + src/config/env.ts | 5 + src/config/index.ts | 1 + src/config/problem-resolution.ts | 5 + src/jobs/cleanup/index.ts | 23 +- src/modules/catalog/products/index.ts | 4 +- .../products/schema/inbound-request.schema.ts | 17 + .../investigation/constants/index.ts | 3 + .../investigation/controller/index.ts | 1 + .../controller/investigation.controller.ts | 28 + .../problem-management/investigation/index.ts | 16 +- .../investigation/mapper/index.ts | 1 + .../investigation/repository/index.ts | 1 + .../repository/investigation.repository.ts | 39 ++ .../investigation/routes/index.ts | 1 + .../routes/investigation.routes.ts | 21 + .../investigation/schema/index.ts | 1 + .../schema/investigation.schema.ts | 13 + .../investigation/service/index.ts | 2 + .../service/investigation.service.ts | 43 ++ .../investigation/types/index.ts | 1 + .../resolutions/constants/index.ts | 3 + .../resolutions/controller/index.ts | 1 + .../controller/resolutions.controller.ts | 40 ++ .../problem-management/resolutions/index.ts | 15 +- .../resolutions/mapper/index.ts | 1 + .../resolutions/repository/index.ts | 1 + .../repository/resolution.repository.ts | 20 + .../resolutions/routes/index.ts | 1 + .../resolutions/routes/resolutions.routes.ts | 28 + .../resolutions/schema/index.ts | 1 + .../resolutions/schema/resolution.schema.ts | 10 + .../resolutions/service/index.ts | 1 + .../service/resolutions.service.ts | 75 +++ .../resolutions/types/index.ts | 1 + .../root-causes/constants/index.ts | 3 + .../root-causes/controller/index.ts | 1 + .../controller/root-causes.controller.ts | 22 + .../problem-management/root-causes/index.ts | 16 +- .../root-causes/mapper/index.ts | 1 + .../root-causes/repository/index.ts | 1 + .../repository/root-cause.repository.ts | 32 + .../root-causes/routes/index.ts | 1 + .../root-causes/routes/root-causes.routes.ts | 15 + .../root-causes/schema/index.ts | 1 + .../root-causes/schema/root-cause.schema.ts | 18 + .../root-causes/service/index.ts | 1 + .../service/root-causes.service.ts | 35 ++ .../root-causes/types/index.ts | 1 + .../solutions/constants/index.ts | 3 + .../solutions/controller/index.ts | 1 + .../controller/solutions.controller.ts | 29 + .../problem-management/solutions/index.ts | 20 +- .../solutions/mapper/index.ts | 1 + .../solutions/repository/index.ts | 2 + .../solution-implementation.repository.ts | 24 + .../repository/solution.repository.ts | 36 ++ .../solutions/routes/index.ts | 1 + .../solutions/routes/solutions.routes.ts | 20 + .../solutions/schema/index.ts | 1 + .../solutions/schema/solution.schema.ts | 17 + .../solutions/service/index.ts | 1 + .../solutions/service/solutions.service.ts | 76 +++ .../solutions/types/index.ts | 1 + .../verification/constants/index.ts | 3 + .../verification/controller/index.ts | 1 + .../controller/verification.controller.ts | 16 + .../problem-management/verification/index.ts | 16 +- .../verification/mapper/index.ts | 1 + .../verification/repository/index.ts | 1 + .../solution-verification.repository.ts | 25 + .../verification/routes/index.ts | 1 + .../routes/verification.routes.ts | 10 + .../verification/schema/index.ts | 1 + .../schema/verification.schema.ts | 18 + .../verification/service/index.ts | 1 + .../service/verification.service.ts | 36 ++ .../verification/types/index.ts | 1 + .../tickets/controller/tickets.controller.ts | 23 + src/modules/ticketing/tickets/index.ts | 5 + .../tickets/repository/tickets.repository.ts | 8 + .../tickets/routes/tickets.routes.ts | 17 + .../tickets/service/tickets.service.ts | 21 + .../product-integration-auth.plugin.ts | 279 +++++---- .../problem-resolution-flow.test.ts | 550 ++++++++++++++++++ tests/integration/sla-escalation-flow.test.ts | 23 +- .../auto-close-sweep.test.ts | 61 ++ .../root-cause-schema.test.ts | 33 ++ 94 files changed, 2080 insertions(+), 245 deletions(-) create mode 100644 prisma/migrations/20260903091044_add_problem_resolution/migration.sql create mode 100644 src/config/problem-resolution.ts create mode 100644 src/modules/problem-management/investigation/constants/index.ts create mode 100644 src/modules/problem-management/investigation/controller/index.ts create mode 100644 src/modules/problem-management/investigation/controller/investigation.controller.ts create mode 100644 src/modules/problem-management/investigation/mapper/index.ts create mode 100644 src/modules/problem-management/investigation/repository/index.ts create mode 100644 src/modules/problem-management/investigation/repository/investigation.repository.ts create mode 100644 src/modules/problem-management/investigation/routes/index.ts create mode 100644 src/modules/problem-management/investigation/routes/investigation.routes.ts create mode 100644 src/modules/problem-management/investigation/schema/index.ts create mode 100644 src/modules/problem-management/investigation/schema/investigation.schema.ts create mode 100644 src/modules/problem-management/investigation/service/index.ts create mode 100644 src/modules/problem-management/investigation/service/investigation.service.ts create mode 100644 src/modules/problem-management/investigation/types/index.ts create mode 100644 src/modules/problem-management/resolutions/constants/index.ts create mode 100644 src/modules/problem-management/resolutions/controller/index.ts create mode 100644 src/modules/problem-management/resolutions/controller/resolutions.controller.ts create mode 100644 src/modules/problem-management/resolutions/mapper/index.ts create mode 100644 src/modules/problem-management/resolutions/repository/index.ts create mode 100644 src/modules/problem-management/resolutions/repository/resolution.repository.ts create mode 100644 src/modules/problem-management/resolutions/routes/index.ts create mode 100644 src/modules/problem-management/resolutions/routes/resolutions.routes.ts create mode 100644 src/modules/problem-management/resolutions/schema/index.ts create mode 100644 src/modules/problem-management/resolutions/schema/resolution.schema.ts create mode 100644 src/modules/problem-management/resolutions/service/index.ts create mode 100644 src/modules/problem-management/resolutions/service/resolutions.service.ts create mode 100644 src/modules/problem-management/resolutions/types/index.ts create mode 100644 src/modules/problem-management/root-causes/constants/index.ts create mode 100644 src/modules/problem-management/root-causes/controller/index.ts create mode 100644 src/modules/problem-management/root-causes/controller/root-causes.controller.ts create mode 100644 src/modules/problem-management/root-causes/mapper/index.ts create mode 100644 src/modules/problem-management/root-causes/repository/index.ts create mode 100644 src/modules/problem-management/root-causes/repository/root-cause.repository.ts create mode 100644 src/modules/problem-management/root-causes/routes/index.ts create mode 100644 src/modules/problem-management/root-causes/routes/root-causes.routes.ts create mode 100644 src/modules/problem-management/root-causes/schema/index.ts create mode 100644 src/modules/problem-management/root-causes/schema/root-cause.schema.ts create mode 100644 src/modules/problem-management/root-causes/service/index.ts create mode 100644 src/modules/problem-management/root-causes/service/root-causes.service.ts create mode 100644 src/modules/problem-management/root-causes/types/index.ts create mode 100644 src/modules/problem-management/solutions/constants/index.ts create mode 100644 src/modules/problem-management/solutions/controller/index.ts create mode 100644 src/modules/problem-management/solutions/controller/solutions.controller.ts create mode 100644 src/modules/problem-management/solutions/mapper/index.ts create mode 100644 src/modules/problem-management/solutions/repository/index.ts create mode 100644 src/modules/problem-management/solutions/repository/solution-implementation.repository.ts create mode 100644 src/modules/problem-management/solutions/repository/solution.repository.ts create mode 100644 src/modules/problem-management/solutions/routes/index.ts create mode 100644 src/modules/problem-management/solutions/routes/solutions.routes.ts create mode 100644 src/modules/problem-management/solutions/schema/index.ts create mode 100644 src/modules/problem-management/solutions/schema/solution.schema.ts create mode 100644 src/modules/problem-management/solutions/service/index.ts create mode 100644 src/modules/problem-management/solutions/service/solutions.service.ts create mode 100644 src/modules/problem-management/solutions/types/index.ts create mode 100644 src/modules/problem-management/verification/constants/index.ts create mode 100644 src/modules/problem-management/verification/controller/index.ts create mode 100644 src/modules/problem-management/verification/controller/verification.controller.ts create mode 100644 src/modules/problem-management/verification/mapper/index.ts create mode 100644 src/modules/problem-management/verification/repository/index.ts create mode 100644 src/modules/problem-management/verification/repository/solution-verification.repository.ts create mode 100644 src/modules/problem-management/verification/routes/index.ts create mode 100644 src/modules/problem-management/verification/routes/verification.routes.ts create mode 100644 src/modules/problem-management/verification/schema/index.ts create mode 100644 src/modules/problem-management/verification/schema/verification.schema.ts create mode 100644 src/modules/problem-management/verification/service/index.ts create mode 100644 src/modules/problem-management/verification/service/verification.service.ts create mode 100644 src/modules/problem-management/verification/types/index.ts create mode 100644 tests/integration/problem-resolution-flow.test.ts create mode 100644 tests/unit/problem-management/auto-close-sweep.test.ts create mode 100644 tests/unit/problem-management/root-cause-schema.test.ts diff --git a/prisma/migrations/20260903091044_add_problem_resolution/migration.sql b/prisma/migrations/20260903091044_add_problem_resolution/migration.sql new file mode 100644 index 0000000..510af82 --- /dev/null +++ b/prisma/migrations/20260903091044_add_problem_resolution/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d554c0..1792d72 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/specs/009-problem-resolution/checklists/requirements.md b/specs/009-problem-resolution/checklists/requirements.md index 7fea366..65d5c3a 100644 --- a/specs/009-problem-resolution/checklists/requirements.md +++ b/specs/009-problem-resolution/checklists/requirements.md @@ -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. diff --git a/specs/009-problem-resolution/research.md b/specs/009-problem-resolution/research.md index c2224a1..ed41e59 100644 --- a/specs/009-problem-resolution/research.md +++ b/specs/009-problem-resolution/research.md @@ -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 diff --git a/specs/009-problem-resolution/tasks.md b/specs/009-problem-resolution/tasks.md index 06ca1e8..0be3273 100644 --- a/specs/009-problem-resolution/tasks.md +++ b/specs/009-problem-resolution/tasks.md @@ -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 diff --git a/src/api/routes.ts b/src/api/routes.ts index a6f82d6..58f9d0d 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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 { await app.register(healthRoutes); @@ -37,5 +42,10 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise 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 } diff --git a/src/bootstrap/queue.bootstrap.ts b/src/bootstrap/queue.bootstrap.ts index 400a6ac..eca1f4e 100644 --- a/src/bootstrap/queue.bootstrap.ts +++ b/src/bootstrap/queue.bootstrap.ts @@ -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 { registerAttachmentWorker(); registerAiSessionWorker(); registerSlaWorker(); + registerCleanupWorker(); logger.info('Queue Manager initialized.'); } diff --git a/src/config/env.ts b/src/config/env.ts index 1ca90ec..a8bd9be 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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; diff --git a/src/config/index.ts b/src/config/index.ts index 5db4de4..622b412 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -5,3 +5,4 @@ export * from './queue'; export * from './storage'; export * from './ai'; export * from './orchestration'; +export * from './problem-resolution'; diff --git a/src/config/problem-resolution.ts b/src/config/problem-resolution.ts new file mode 100644 index 0000000..49daf18 --- /dev/null +++ b/src/config/problem-resolution.ts @@ -0,0 +1,5 @@ +import { env } from './env'; + +export const problemResolutionConfig = { + autoCloseWaitingHours: env.RESOLUTION_AUTO_CLOSE_WAITING_HOURS, +}; diff --git a/src/jobs/cleanup/index.ts b/src/jobs/cleanup/index.ts index 4c226ec..e2f730a 100644 --- a/src/jobs/cleanup/index.ts +++ b/src/jobs/cleanup/index.ts @@ -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 } }, + ); } diff --git a/src/modules/catalog/products/index.ts b/src/modules/catalog/products/index.ts index 9f2f0b4..a188886 100644 --- a/src/modules/catalog/products/index.ts +++ b/src/modules/catalog/products/index.ts @@ -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'; diff --git a/src/modules/catalog/products/schema/inbound-request.schema.ts b/src/modules/catalog/products/schema/inbound-request.schema.ts index c976da7..6f91db4 100644 --- a/src/modules/catalog/products/schema/inbound-request.schema.ts +++ b/src/modules/catalog/products/schema/inbound-request.schema.ts @@ -20,3 +20,20 @@ export const inboundRequestSchema = z .strict(); export type InboundRequest = z.infer; + +/** + * 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; diff --git a/src/modules/problem-management/investigation/constants/index.ts b/src/modules/problem-management/investigation/constants/index.ts new file mode 100644 index 0000000..0678cdf --- /dev/null +++ b/src/modules/problem-management/investigation/constants/index.ts @@ -0,0 +1,3 @@ +export const INVESTIGATION_CONSTANTS = { + MODULE_NAME: 'PROBLEM_INVESTIGATION', +} as const; diff --git a/src/modules/problem-management/investigation/controller/index.ts b/src/modules/problem-management/investigation/controller/index.ts new file mode 100644 index 0000000..0c3636b --- /dev/null +++ b/src/modules/problem-management/investigation/controller/index.ts @@ -0,0 +1 @@ +export { InvestigationController, investigationController } from './investigation.controller'; diff --git a/src/modules/problem-management/investigation/controller/investigation.controller.ts b/src/modules/problem-management/investigation/controller/investigation.controller.ts new file mode 100644 index 0000000..6cbff8f --- /dev/null +++ b/src/modules/problem-management/investigation/controller/investigation.controller.ts @@ -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(); diff --git a/src/modules/problem-management/investigation/index.ts b/src/modules/problem-management/investigation/index.ts index fba3b7b..6431b67 100644 --- a/src/modules/problem-management/investigation/index.ts +++ b/src/modules/problem-management/investigation/index.ts @@ -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'; diff --git a/src/modules/problem-management/investigation/mapper/index.ts b/src/modules/problem-management/investigation/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/investigation/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/investigation/repository/index.ts b/src/modules/problem-management/investigation/repository/index.ts new file mode 100644 index 0000000..9675718 --- /dev/null +++ b/src/modules/problem-management/investigation/repository/index.ts @@ -0,0 +1 @@ +export * from './investigation.repository'; diff --git a/src/modules/problem-management/investigation/repository/investigation.repository.ts b/src/modules/problem-management/investigation/repository/investigation.repository.ts new file mode 100644 index 0000000..6deca20 --- /dev/null +++ b/src/modules/problem-management/investigation/repository/investigation.repository.ts @@ -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 { + 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 { + return this.prisma.investigation.findMany({ + where: { problemId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findMostRecentForProblem(problemId: string): Promise { + return this.prisma.investigation.findFirst({ + where: { problemId }, + orderBy: { createdAt: 'desc' }, + }); + } +} + +export const investigationRepository = new InvestigationRepository(); diff --git a/src/modules/problem-management/investigation/routes/index.ts b/src/modules/problem-management/investigation/routes/index.ts new file mode 100644 index 0000000..b4e4180 --- /dev/null +++ b/src/modules/problem-management/investigation/routes/index.ts @@ -0,0 +1 @@ +export { investigationRoutes } from './investigation.routes'; diff --git a/src/modules/problem-management/investigation/routes/investigation.routes.ts b/src/modules/problem-management/investigation/routes/investigation.routes.ts new file mode 100644 index 0000000..89ab4db --- /dev/null +++ b/src/modules/problem-management/investigation/routes/investigation.routes.ts @@ -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 { + 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), + ); +} diff --git a/src/modules/problem-management/investigation/schema/index.ts b/src/modules/problem-management/investigation/schema/index.ts new file mode 100644 index 0000000..3ddfb64 --- /dev/null +++ b/src/modules/problem-management/investigation/schema/index.ts @@ -0,0 +1 @@ +export * from './investigation.schema'; diff --git a/src/modules/problem-management/investigation/schema/investigation.schema.ts b/src/modules/problem-management/investigation/schema/investigation.schema.ts new file mode 100644 index 0000000..e49c964 --- /dev/null +++ b/src/modules/problem-management/investigation/schema/investigation.schema.ts @@ -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; diff --git a/src/modules/problem-management/investigation/service/index.ts b/src/modules/problem-management/investigation/service/index.ts new file mode 100644 index 0000000..b697bf3 --- /dev/null +++ b/src/modules/problem-management/investigation/service/index.ts @@ -0,0 +1,2 @@ +export { InvestigationService, investigationService } from './investigation.service'; +export type { CustomerSafeInvestigation } from './investigation.service'; diff --git a/src/modules/problem-management/investigation/service/investigation.service.ts b/src/modules/problem-management/investigation/service/investigation.service.ts new file mode 100644 index 0000000..17b66fb --- /dev/null +++ b/src/modules/problem-management/investigation/service/investigation.service.ts @@ -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; + +export class InvestigationService { + constructor(private readonly repo: InvestigationRepository = investigationRepository) {} + + async record(problemId: string, body: CreateInvestigationBody): Promise { + 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 { + return this.repo.findAllForProblem(problemId); + } + + /** FR-003: internalNotes is never exposed on a customer-facing read. */ + async listForProblemCustomerSafe(problemId: string): Promise { + 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 { + const mostRecent = await this.repo.findMostRecentForProblem(problemId); + return mostRecent !== null; + } +} + +export const investigationService = new InvestigationService(); diff --git a/src/modules/problem-management/investigation/types/index.ts b/src/modules/problem-management/investigation/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/investigation/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/resolutions/constants/index.ts b/src/modules/problem-management/resolutions/constants/index.ts new file mode 100644 index 0000000..2e51453 --- /dev/null +++ b/src/modules/problem-management/resolutions/constants/index.ts @@ -0,0 +1,3 @@ +export const RESOLUTIONS_CONSTANTS = { + MODULE_NAME: 'PROBLEM_RESOLUTIONS', +} as const; diff --git a/src/modules/problem-management/resolutions/controller/index.ts b/src/modules/problem-management/resolutions/controller/index.ts new file mode 100644 index 0000000..d792e78 --- /dev/null +++ b/src/modules/problem-management/resolutions/controller/index.ts @@ -0,0 +1 @@ +export { ResolutionsController, resolutionsController } from './resolutions.controller'; diff --git a/src/modules/problem-management/resolutions/controller/resolutions.controller.ts b/src/modules/problem-management/resolutions/controller/resolutions.controller.ts new file mode 100644 index 0000000..574c0e2 --- /dev/null +++ b/src/modules/problem-management/resolutions/controller/resolutions.controller.ts @@ -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(); diff --git a/src/modules/problem-management/resolutions/index.ts b/src/modules/problem-management/resolutions/index.ts index a69c170..b1b2a59 100644 --- a/src/modules/problem-management/resolutions/index.ts +++ b/src/modules/problem-management/resolutions/index.ts @@ -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'; diff --git a/src/modules/problem-management/resolutions/mapper/index.ts b/src/modules/problem-management/resolutions/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/resolutions/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/resolutions/repository/index.ts b/src/modules/problem-management/resolutions/repository/index.ts new file mode 100644 index 0000000..0263256 --- /dev/null +++ b/src/modules/problem-management/resolutions/repository/index.ts @@ -0,0 +1 @@ +export * from './resolution.repository'; diff --git a/src/modules/problem-management/resolutions/repository/resolution.repository.ts b/src/modules/problem-management/resolutions/repository/resolution.repository.ts new file mode 100644 index 0000000..4fcd119 --- /dev/null +++ b/src/modules/problem-management/resolutions/repository/resolution.repository.ts @@ -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 { + return this.prisma.resolution.create({ data }); + } + + async findByTicketId(ticketId: string): Promise { + return this.prisma.resolution.findUnique({ where: { ticketId } }); + } +} + +export const resolutionRepository = new ResolutionRepository(); diff --git a/src/modules/problem-management/resolutions/routes/index.ts b/src/modules/problem-management/resolutions/routes/index.ts new file mode 100644 index 0000000..f1d3906 --- /dev/null +++ b/src/modules/problem-management/resolutions/routes/index.ts @@ -0,0 +1 @@ +export { resolutionsRoutes } from './resolutions.routes'; diff --git a/src/modules/problem-management/resolutions/routes/resolutions.routes.ts b/src/modules/problem-management/resolutions/routes/resolutions.routes.ts new file mode 100644 index 0000000..897daa3 --- /dev/null +++ b/src/modules/problem-management/resolutions/routes/resolutions.routes.ts @@ -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 { + 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), + ); +} diff --git a/src/modules/problem-management/resolutions/schema/index.ts b/src/modules/problem-management/resolutions/schema/index.ts new file mode 100644 index 0000000..fb13321 --- /dev/null +++ b/src/modules/problem-management/resolutions/schema/index.ts @@ -0,0 +1 @@ +export * from './resolution.schema'; diff --git a/src/modules/problem-management/resolutions/schema/resolution.schema.ts b/src/modules/problem-management/resolutions/schema/resolution.schema.ts new file mode 100644 index 0000000..7ff5649 --- /dev/null +++ b/src/modules/problem-management/resolutions/schema/resolution.schema.ts @@ -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; diff --git a/src/modules/problem-management/resolutions/service/index.ts b/src/modules/problem-management/resolutions/service/index.ts new file mode 100644 index 0000000..82f22e6 --- /dev/null +++ b/src/modules/problem-management/resolutions/service/index.ts @@ -0,0 +1 @@ +export { ResolutionsService, resolutionsService } from './resolutions.service'; diff --git a/src/modules/problem-management/resolutions/service/resolutions.service.ts b/src/modules/problem-management/resolutions/service/resolutions.service.ts new file mode 100644 index 0000000..2fe0f22 --- /dev/null +++ b/src/modules/problem-management/resolutions/service/resolutions.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); diff --git a/src/modules/problem-management/resolutions/types/index.ts b/src/modules/problem-management/resolutions/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/resolutions/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/root-causes/constants/index.ts b/src/modules/problem-management/root-causes/constants/index.ts new file mode 100644 index 0000000..120cbc4 --- /dev/null +++ b/src/modules/problem-management/root-causes/constants/index.ts @@ -0,0 +1,3 @@ +export const ROOT_CAUSES_CONSTANTS = { + MODULE_NAME: 'PROBLEM_ROOT_CAUSES', +} as const; diff --git a/src/modules/problem-management/root-causes/controller/index.ts b/src/modules/problem-management/root-causes/controller/index.ts new file mode 100644 index 0000000..f38c58a --- /dev/null +++ b/src/modules/problem-management/root-causes/controller/index.ts @@ -0,0 +1 @@ +export { RootCausesController, rootCausesController } from './root-causes.controller'; diff --git a/src/modules/problem-management/root-causes/controller/root-causes.controller.ts b/src/modules/problem-management/root-causes/controller/root-causes.controller.ts new file mode 100644 index 0000000..752ec7b --- /dev/null +++ b/src/modules/problem-management/root-causes/controller/root-causes.controller.ts @@ -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(); diff --git a/src/modules/problem-management/root-causes/index.ts b/src/modules/problem-management/root-causes/index.ts index 1937634..cd85a10 100644 --- a/src/modules/problem-management/root-causes/index.ts +++ b/src/modules/problem-management/root-causes/index.ts @@ -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'; diff --git a/src/modules/problem-management/root-causes/mapper/index.ts b/src/modules/problem-management/root-causes/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/root-causes/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/root-causes/repository/index.ts b/src/modules/problem-management/root-causes/repository/index.ts new file mode 100644 index 0000000..a8aaeeb --- /dev/null +++ b/src/modules/problem-management/root-causes/repository/index.ts @@ -0,0 +1 @@ +export * from './root-cause.repository'; diff --git a/src/modules/problem-management/root-causes/repository/root-cause.repository.ts b/src/modules/problem-management/root-causes/repository/root-cause.repository.ts new file mode 100644 index 0000000..01942d4 --- /dev/null +++ b/src/modules/problem-management/root-causes/repository/root-cause.repository.ts @@ -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 { + return this.prisma.rootCause.create({ data }); + } + + async findAllForProblem(problemId: string): Promise { + return this.prisma.rootCause.findMany({ + where: { problemId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findMostRecentForProblem(problemId: string): Promise { + return this.prisma.rootCause.findFirst({ + where: { problemId }, + orderBy: { createdAt: 'desc' }, + }); + } +} + +export const rootCauseRepository = new RootCauseRepository(); diff --git a/src/modules/problem-management/root-causes/routes/index.ts b/src/modules/problem-management/root-causes/routes/index.ts new file mode 100644 index 0000000..948ad6a --- /dev/null +++ b/src/modules/problem-management/root-causes/routes/index.ts @@ -0,0 +1 @@ +export { rootCausesRoutes } from './root-causes.routes'; diff --git a/src/modules/problem-management/root-causes/routes/root-causes.routes.ts b/src/modules/problem-management/root-causes/routes/root-causes.routes.ts new file mode 100644 index 0000000..0ecf8be --- /dev/null +++ b/src/modules/problem-management/root-causes/routes/root-causes.routes.ts @@ -0,0 +1,15 @@ +import { FastifyInstance } from 'fastify'; +import { rootCausesController } from '../controller'; + +export async function rootCausesRoutes(fastify: FastifyInstance): Promise { + 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), + ); +} diff --git a/src/modules/problem-management/root-causes/schema/index.ts b/src/modules/problem-management/root-causes/schema/index.ts new file mode 100644 index 0000000..34d071d --- /dev/null +++ b/src/modules/problem-management/root-causes/schema/index.ts @@ -0,0 +1 @@ +export * from './root-cause.schema'; diff --git a/src/modules/problem-management/root-causes/schema/root-cause.schema.ts b/src/modules/problem-management/root-causes/schema/root-cause.schema.ts new file mode 100644 index 0000000..a645bbc --- /dev/null +++ b/src/modules/problem-management/root-causes/schema/root-cause.schema.ts @@ -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; diff --git a/src/modules/problem-management/root-causes/service/index.ts b/src/modules/problem-management/root-causes/service/index.ts new file mode 100644 index 0000000..f2d1af8 --- /dev/null +++ b/src/modules/problem-management/root-causes/service/index.ts @@ -0,0 +1 @@ +export { RootCausesService, rootCausesService } from './root-causes.service'; diff --git a/src/modules/problem-management/root-causes/service/root-causes.service.ts b/src/modules/problem-management/root-causes/service/root-causes.service.ts new file mode 100644 index 0000000..0d2f299 --- /dev/null +++ b/src/modules/problem-management/root-causes/service/root-causes.service.ts @@ -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 { + 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 { + return this.repo.findAllForProblem(problemId); + } + + /** FR-009: the existence gate `solutions` calls through this module's public index. */ + async hasAnyForProblem(problemId: string): Promise { + const mostRecent = await this.repo.findMostRecentForProblem(problemId); + return mostRecent !== null; + } +} + +export const rootCausesService = new RootCausesService(); diff --git a/src/modules/problem-management/root-causes/types/index.ts b/src/modules/problem-management/root-causes/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/root-causes/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/solutions/constants/index.ts b/src/modules/problem-management/solutions/constants/index.ts new file mode 100644 index 0000000..03a46d0 --- /dev/null +++ b/src/modules/problem-management/solutions/constants/index.ts @@ -0,0 +1,3 @@ +export const SOLUTIONS_CONSTANTS = { + MODULE_NAME: 'PROBLEM_SOLUTIONS', +} as const; diff --git a/src/modules/problem-management/solutions/controller/index.ts b/src/modules/problem-management/solutions/controller/index.ts new file mode 100644 index 0000000..6ffa68e --- /dev/null +++ b/src/modules/problem-management/solutions/controller/index.ts @@ -0,0 +1 @@ +export { SolutionsController, solutionsController } from './solutions.controller'; diff --git a/src/modules/problem-management/solutions/controller/solutions.controller.ts b/src/modules/problem-management/solutions/controller/solutions.controller.ts new file mode 100644 index 0000000..a2d4780 --- /dev/null +++ b/src/modules/problem-management/solutions/controller/solutions.controller.ts @@ -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(); diff --git a/src/modules/problem-management/solutions/index.ts b/src/modules/problem-management/solutions/index.ts index a700550..b6b76a6 100644 --- a/src/modules/problem-management/solutions/index.ts +++ b/src/modules/problem-management/solutions/index.ts @@ -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'; diff --git a/src/modules/problem-management/solutions/mapper/index.ts b/src/modules/problem-management/solutions/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/solutions/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/solutions/repository/index.ts b/src/modules/problem-management/solutions/repository/index.ts new file mode 100644 index 0000000..48788e9 --- /dev/null +++ b/src/modules/problem-management/solutions/repository/index.ts @@ -0,0 +1,2 @@ +export * from './solution.repository'; +export * from './solution-implementation.repository'; diff --git a/src/modules/problem-management/solutions/repository/solution-implementation.repository.ts b/src/modules/problem-management/solutions/repository/solution-implementation.repository.ts new file mode 100644 index 0000000..cceeb92 --- /dev/null +++ b/src/modules/problem-management/solutions/repository/solution-implementation.repository.ts @@ -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 { + return this.prisma.solutionImplementation.create({ + data: data as Prisma.SolutionImplementationUncheckedCreateInput, + }); + } + + async findBySolutionId(solutionId: string): Promise { + return this.prisma.solutionImplementation.findUnique({ where: { solutionId } }); + } +} + +export const solutionImplementationRepository = new SolutionImplementationRepository(); diff --git a/src/modules/problem-management/solutions/repository/solution.repository.ts b/src/modules/problem-management/solutions/repository/solution.repository.ts new file mode 100644 index 0000000..be2c842 --- /dev/null +++ b/src/modules/problem-management/solutions/repository/solution.repository.ts @@ -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 { + return this.prisma.solution.create({ data }); + } + + async findById(id: string): Promise { + return this.prisma.solution.findUnique({ where: { id } }); + } + + async approve(id: string): Promise { + return this.prisma.solution.update({ where: { id }, data: { approved: true } }); + } + + async findMostRecentForProblem(problemId: string): Promise { + 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 { + return this.prisma.solution.findFirst({ + where: { problemId, verification: { result: 'success' } }, + orderBy: { createdAt: 'desc' }, + }); + } +} + +export const solutionRepository = new SolutionRepository(); diff --git a/src/modules/problem-management/solutions/routes/index.ts b/src/modules/problem-management/solutions/routes/index.ts new file mode 100644 index 0000000..56490e1 --- /dev/null +++ b/src/modules/problem-management/solutions/routes/index.ts @@ -0,0 +1 @@ +export { solutionsRoutes } from './solutions.routes'; diff --git a/src/modules/problem-management/solutions/routes/solutions.routes.ts b/src/modules/problem-management/solutions/routes/solutions.routes.ts new file mode 100644 index 0000000..6ea0543 --- /dev/null +++ b/src/modules/problem-management/solutions/routes/solutions.routes.ts @@ -0,0 +1,20 @@ +import { FastifyInstance } from 'fastify'; +import { solutionsController } from '../controller'; + +export async function solutionsRoutes(fastify: FastifyInstance): Promise { + 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), + ); +} diff --git a/src/modules/problem-management/solutions/schema/index.ts b/src/modules/problem-management/solutions/schema/index.ts new file mode 100644 index 0000000..026db01 --- /dev/null +++ b/src/modules/problem-management/solutions/schema/index.ts @@ -0,0 +1 @@ +export * from './solution.schema'; diff --git a/src/modules/problem-management/solutions/schema/solution.schema.ts b/src/modules/problem-management/solutions/schema/solution.schema.ts new file mode 100644 index 0000000..1395f58 --- /dev/null +++ b/src/modules/problem-management/solutions/schema/solution.schema.ts @@ -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; +export type CreateImplementationBody = z.infer; diff --git a/src/modules/problem-management/solutions/service/index.ts b/src/modules/problem-management/solutions/service/index.ts new file mode 100644 index 0000000..26950b8 --- /dev/null +++ b/src/modules/problem-management/solutions/service/index.ts @@ -0,0 +1 @@ +export { SolutionsService, solutionsService } from './solutions.service'; diff --git a/src/modules/problem-management/solutions/service/solutions.service.ts b/src/modules/problem-management/solutions/service/solutions.service.ts new file mode 100644 index 0000000..7212168 --- /dev/null +++ b/src/modules/problem-management/solutions/service/solutions.service.ts @@ -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 { + 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 { + const solution = await this.solutions.findById(solutionId); + if (!solution) throw new NotFoundError('Solution not found.'); + return solution; + } + + async approve(solutionId: string): Promise { + 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 { + 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 { + return this.implementations.findBySolutionId(solutionId); + } + + /** FR-014: the existence gate `resolutions` calls through this module's public index. */ + async hasSuccessfulVerification(problemId: string): Promise { + const solution = await this.solutions.findWithSuccessfulVerification(problemId); + return solution !== null; + } +} + +export const solutionsService = new SolutionsService(); diff --git a/src/modules/problem-management/solutions/types/index.ts b/src/modules/problem-management/solutions/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/solutions/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/verification/constants/index.ts b/src/modules/problem-management/verification/constants/index.ts new file mode 100644 index 0000000..00bb4e1 --- /dev/null +++ b/src/modules/problem-management/verification/constants/index.ts @@ -0,0 +1,3 @@ +export const VERIFICATION_CONSTANTS = { + MODULE_NAME: 'PROBLEM_VERIFICATION', +} as const; diff --git a/src/modules/problem-management/verification/controller/index.ts b/src/modules/problem-management/verification/controller/index.ts new file mode 100644 index 0000000..f66bb44 --- /dev/null +++ b/src/modules/problem-management/verification/controller/index.ts @@ -0,0 +1 @@ +export { VerificationController, verificationController } from './verification.controller'; diff --git a/src/modules/problem-management/verification/controller/verification.controller.ts b/src/modules/problem-management/verification/controller/verification.controller.ts new file mode 100644 index 0000000..e5da903 --- /dev/null +++ b/src/modules/problem-management/verification/controller/verification.controller.ts @@ -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(); diff --git a/src/modules/problem-management/verification/index.ts b/src/modules/problem-management/verification/index.ts index 12e843e..d82f3a3 100644 --- a/src/modules/problem-management/verification/index.ts +++ b/src/modules/problem-management/verification/index.ts @@ -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'; diff --git a/src/modules/problem-management/verification/mapper/index.ts b/src/modules/problem-management/verification/mapper/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/verification/mapper/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/problem-management/verification/repository/index.ts b/src/modules/problem-management/verification/repository/index.ts new file mode 100644 index 0000000..4b72562 --- /dev/null +++ b/src/modules/problem-management/verification/repository/index.ts @@ -0,0 +1 @@ +export * from './solution-verification.repository'; diff --git a/src/modules/problem-management/verification/repository/solution-verification.repository.ts b/src/modules/problem-management/verification/repository/solution-verification.repository.ts new file mode 100644 index 0000000..7e92cee --- /dev/null +++ b/src/modules/problem-management/verification/repository/solution-verification.repository.ts @@ -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 { + return this.prisma.solutionVerification.create({ + data: data as Prisma.SolutionVerificationUncheckedCreateInput, + }); + } + + async findBySolutionId(solutionId: string): Promise { + return this.prisma.solutionVerification.findUnique({ where: { solutionId } }); + } +} + +export const solutionVerificationRepository = new SolutionVerificationRepository(); diff --git a/src/modules/problem-management/verification/routes/index.ts b/src/modules/problem-management/verification/routes/index.ts new file mode 100644 index 0000000..cd2b368 --- /dev/null +++ b/src/modules/problem-management/verification/routes/index.ts @@ -0,0 +1 @@ +export { verificationRoutes } from './verification.routes'; diff --git a/src/modules/problem-management/verification/routes/verification.routes.ts b/src/modules/problem-management/verification/routes/verification.routes.ts new file mode 100644 index 0000000..5168c99 --- /dev/null +++ b/src/modules/problem-management/verification/routes/verification.routes.ts @@ -0,0 +1,10 @@ +import { FastifyInstance } from 'fastify'; +import { verificationController } from '../controller'; + +export async function verificationRoutes(fastify: FastifyInstance): Promise { + fastify.post( + '/admin/solutions/:solutionId/verification', + { preHandler: fastify.authenticate }, + (req, reply) => verificationController.record(req, reply), + ); +} diff --git a/src/modules/problem-management/verification/schema/index.ts b/src/modules/problem-management/verification/schema/index.ts new file mode 100644 index 0000000..0170a91 --- /dev/null +++ b/src/modules/problem-management/verification/schema/index.ts @@ -0,0 +1 @@ +export * from './verification.schema'; diff --git a/src/modules/problem-management/verification/schema/verification.schema.ts b/src/modules/problem-management/verification/schema/verification.schema.ts new file mode 100644 index 0000000..2d28203 --- /dev/null +++ b/src/modules/problem-management/verification/schema/verification.schema.ts @@ -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; diff --git a/src/modules/problem-management/verification/service/index.ts b/src/modules/problem-management/verification/service/index.ts new file mode 100644 index 0000000..e58a166 --- /dev/null +++ b/src/modules/problem-management/verification/service/index.ts @@ -0,0 +1 @@ +export { VerificationService, verificationService } from './verification.service'; diff --git a/src/modules/problem-management/verification/service/verification.service.ts b/src/modules/problem-management/verification/service/verification.service.ts new file mode 100644 index 0000000..4564dec --- /dev/null +++ b/src/modules/problem-management/verification/service/verification.service.ts @@ -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 { + 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(); diff --git a/src/modules/problem-management/verification/types/index.ts b/src/modules/problem-management/verification/types/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/modules/problem-management/verification/types/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/modules/ticketing/tickets/controller/tickets.controller.ts b/src/modules/ticketing/tickets/controller/tickets.controller.ts index 823356d..fd92e15 100644 --- a/src/modules/ticketing/tickets/controller/tickets.controller.ts +++ b/src/modules/ticketing/tickets/controller/tickets.controller.ts @@ -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(); diff --git a/src/modules/ticketing/tickets/index.ts b/src/modules/ticketing/tickets/index.ts index 8f2dc7b..d378e6e 100644 --- a/src/modules/ticketing/tickets/index.ts +++ b/src/modules/ticketing/tickets/index.ts @@ -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'; diff --git a/src/modules/ticketing/tickets/repository/tickets.repository.ts b/src/modules/ticketing/tickets/repository/tickets.repository.ts index 4e81ea3..8348919 100644 --- a/src/modules/ticketing/tickets/repository/tickets.repository.ts +++ b/src/modules/ticketing/tickets/repository/tickets.repository.ts @@ -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 { + return this.prisma.ticket.findMany({ + where: { status: 'RESOLUTION_PENDING_CUSTOMER', updatedAt: { lte: cutoff } }, + }); + } } export const ticketsRepository = new TicketsRepository(); diff --git a/src/modules/ticketing/tickets/routes/tickets.routes.ts b/src/modules/ticketing/tickets/routes/tickets.routes.ts index 4e31fed..3c89b98 100644 --- a/src/modules/ticketing/tickets/routes/tickets.routes.ts +++ b/src/modules/ticketing/tickets/routes/tickets.routes.ts @@ -9,4 +9,21 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise { 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), + ); } diff --git a/src/modules/ticketing/tickets/service/tickets.service.ts b/src/modules/ticketing/tickets/service/tickets.service.ts index 4c6cf01..3848c81 100644 --- a/src/modules/ticketing/tickets/service/tickets.service.ts +++ b/src/modules/ticketing/tickets/service/tickets.service.ts @@ -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 { + 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(); diff --git a/src/plugins/product-integration-auth.plugin.ts b/src/plugins/product-integration-auth.plugin.ts index e0429db..c922a68 100644 --- a/src/plugins/product-integration-auth.plugin.ts +++ b/src/plugins/product-integration-auth.plugin.ts @@ -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; + authenticateProductIntegrationIdentity: ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise; checkIntegrationRateLimit: (request: FastifyRequest, reply: FastifyReply) => Promise; } 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 { + // 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 => { + 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); }, ); diff --git a/tests/integration/problem-resolution-flow.test.ts b/tests/integration/problem-resolution-flow.test.ts new file mode 100644 index 0000000..4e21e7b --- /dev/null +++ b/tests/integration/problem-resolution-flow.test.ts @@ -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 { + 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 { + 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 { + 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'); + }); +}); diff --git a/tests/integration/sla-escalation-flow.test.ts b/tests/integration/sla-escalation-flow.test.ts index 0543869..948f760 100644 --- a/tests/integration/sla-escalation-flow.test.ts +++ b/tests/integration/sla-escalation-flow.test.ts @@ -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 () => { diff --git a/tests/unit/problem-management/auto-close-sweep.test.ts b/tests/unit/problem-management/auto-close-sweep.test.ts new file mode 100644 index 0000000..101ed6e --- /dev/null +++ b/tests/unit/problem-management/auto-close-sweep.test.ts @@ -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); + }); +}); diff --git a/tests/unit/problem-management/root-cause-schema.test.ts b/tests/unit/problem-management/root-cause-schema.test.ts new file mode 100644 index 0000000..7d3350f --- /dev/null +++ b/tests/unit/problem-management/root-cause-schema.test.ts @@ -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); + }); +});