feat: implement problem resolution (009)

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-03 15:09:29 +05:30
co-authored by Claude Sonnet 5
parent aaa51ef475
commit 16daf8d32d
94 changed files with 2080 additions and 245 deletions
@@ -0,0 +1,550 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { resolutionsService } from '@/modules/problem-management/resolutions';
import { ticketsService } from '@/modules/ticketing/tickets';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/009-problem-resolution/quickstart.md Scenarios 1-6 against a real Postgres/Redis
* — the full doc04 workflow: investigation through root cause, solution, implementation,
* verification (both outcomes), resolution, customer confirmation, durable auto-close, and
* reopen (customer and agent).
*/
describe('Problem resolution — full flow (User Stories 1-6)', () => {
let app: FastifyInstance;
const externalProductId = `TEST_PR_PROD_${Date.now()}`;
const skillTag = `pr_skill_${Date.now()}`;
let productId: string;
let secret: string;
let teamId: string;
let agentId: string;
const createdTicketIds: string[] = [];
async function createTicket(): Promise<{ ticketId: string; problemId: string }> {
const token = issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
const created = await app.inject({
method: 'POST',
url: '/v1/support/requests',
headers: { authorization: `Bearer ${token}` },
payload: {
productId: externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
source: 'test',
problem: `Needs resolution ${Date.now()}-${Math.random()}`,
},
});
const ticketId = created.json().data.ticketId as string;
createdTicketIds.push(ticketId);
const ticket = await ticketsService.getById(ticketId);
return { ticketId, problemId: ticket.problemId };
}
async function tokenForCurrentRequest(): Promise<string> {
return issueIntegrationToken(secret, {
externalProductId,
tenantId: 'tenant-1',
userId: 'user-1',
});
}
function identityPayload() {
return { productId: externalProductId, tenantId: 'tenant-1', userId: 'user-1' };
}
async function escalateAndAssign(ticketId: string): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
async function fullyResolve(problemId: string, ticketId: string): Promise<void> {
await escalateAndAssign(ticketId);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: { note: 'checked logs' } },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'a bug' },
});
const solutionRes = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'apply fix' },
});
const solutionId = solutionRes.json().data.id;
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
}
beforeAll(async () => {
app = await buildApp();
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Problem Resolution Test Product', status: 'active' },
});
productId = product.id;
secret = generateCredentialSecret();
await prismaClient.productIntegration.create({
data: {
productId,
credentialRef: encryptCredential(secret),
authMechanism: 'signed_token',
allowedScope: { tenantIds: ['tenant-1'] },
status: 'active',
rateLimitPerMinute: 1000,
rateLimitPerUserPerMinute: 1000,
},
});
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
payload: { name: `PR Team ${Date.now()}` },
});
teamId = team.json().data.id;
const agent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'PR Agent' },
});
agentId = agent.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/${skillTag}`,
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
payload: {
name: 'PR Node',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
const problemIds = (
await prismaClient.ticket.findMany({
where: { id: { in: createdTicketIds } },
select: { problemId: true },
})
).map((t) => t.problemId);
const problemFilter = { problemId: { in: problemIds } };
await prismaClient.resolution.deleteMany({ where: ticketFilter });
await prismaClient.solutionVerification.deleteMany({
where: { solution: problemFilter },
});
await prismaClient.solutionImplementation.deleteMany({
where: { solution: problemFilter },
});
await prismaClient.solution.deleteMany({ where: problemFilter });
await prismaClient.rootCause.deleteMany({ where: problemFilter });
await prismaClient.investigation.deleteMany({ where: problemFilter });
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'PR Node' } });
await prismaClient.agentSkill.deleteMany({ where: { agentId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { id: { in: problemIds } } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await app.close();
});
it('Scenario 1: structured investigation, preserved across attempts', async () => {
const { problemId } = await createTicket();
const record = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: {
investigator: 'agent-1',
findings: { checked: 'logs' },
evidence: { logId: 'abc' },
internalNotes: 'suspect race condition',
},
});
expect(record.statusCode).toBe(201);
const agentRead = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
});
expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition');
const customerRead = await app.inject({
method: 'GET',
url: `/problems/${problemId}/investigations`,
});
expect(customerRead.json().data[0].internalNotes).toBeUndefined();
const second = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: { checked: 'more logs' } },
});
expect(second.statusCode).toBe(201);
const both = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
});
expect(both.json().data.length).toBe(2);
});
it('Scenario 2: root cause requires an investigation on file', async () => {
const { problemId } = await createTicket();
const beforeInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
expect(beforeInvestigation.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
const afterInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'a real cause' },
});
expect(afterInvestigation.statusCode).toBe(201);
const invalidType = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'not_a_real_type', description: 'x' },
});
expect(invalidType.statusCode).toBe(400);
});
it('Scenario 3: solution proposed, approved, implemented as distinct states', async () => {
const { problemId } = await createTicket();
const beforeRootCause = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'x' },
});
expect(beforeRootCause.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'apply the fix' },
});
expect(proposed.statusCode).toBe(201);
expect(proposed.json().data.approved).toBe(false);
const solutionId = proposed.json().data.id;
const implBeforeApproval = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(implBeforeApproval.statusCode).toBe(409);
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
const impl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(impl.statusCode).toBe(201);
const secondImpl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
expect(secondImpl.statusCode).toBe(409);
});
it('Scenario 4: verification, and what happens on failure', async () => {
const { problemId } = await createTicket();
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
const success = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
expect(success.statusCode).toBe(201);
// Failure path: a second problem/solution/implementation.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed2 = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/solutions`,
payload: { proposed: 'a wrong fix' },
});
const solution2Id = proposed2.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solution2Id}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/implementation`,
payload: { implementedBy: 'agent-1' },
});
const failed = await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/verification`,
payload: { method: 'agent_confirmation', result: 'failed' },
});
expect(failed.statusCode).toBe(201);
// No resolution can be recorded — problem2 has no successful verification.
const rejectedResolution = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/resolution`,
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejectedResolution.statusCode).toBe(409);
// Re-investigate path: a fresh Investigation row for the same problem.
const reInvestigate = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
payload: { investigator: 'agent-2', findings: { retried: true } },
});
expect(reInvestigate.statusCode).toBe(201);
const allInvestigations = await app.inject({
method: 'GET',
url: `/admin/problems/${problem2}/investigations`,
});
expect(allInvestigations.json().data.length).toBe(2);
// Escalate path: transition to HUMAN_ESCALATION, confirm 007 auto-assigns.
const ticketBeforeEscalate = await ticketsService.getById(ticket2);
const escalate = await app.inject({
method: 'PATCH',
url: `/tickets/${ticket2}/status`,
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version },
});
expect(escalate.statusCode).toBe(200);
const assignment = await app.inject({
method: 'GET',
url: `/tickets/${ticket2}/assignment`,
});
expect(assignment.statusCode).toBe(200);
expect(assignment.json().data.agentId).toBe(agentId);
});
it('Scenario 5: resolution, customer confirmation, and durable auto-close', async () => {
const { ticketId, problemId } = await createTicket();
await escalateAndAssign(ticketId);
const rejected = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejected.statusCode).toBe(409);
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
payload: { method: 'agent_confirmation', result: 'success' },
});
const resolved = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
expect(resolved.statusCode).toBe(201);
const pendingTicket = await ticketsService.getById(ticketId);
expect(pendingTicket.status).toBe('RESOLUTION_PENDING_CUSTOMER');
const token = await tokenForCurrentRequest();
const confirm = await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/confirm-resolution`,
headers: { authorization: `Bearer ${token}` },
payload: identityPayload(),
});
expect(confirm.statusCode).toBe(200);
const confirmedTicket = await ticketsService.getById(ticketId);
expect(confirmedTicket.status).toBe('RESOLVED');
// Auto-close path: a second ticket, aged past the configured window, resolved by the sweep.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await fullyResolve(problem2, ticket2);
await prismaClient.ticket.update({
where: { id: ticket2 },
data: { updatedAt: new Date(Date.now() - 73 * 60 * 60 * 1000) }, // > default 72h
});
await resolutionsService.runAutoCloseSweep();
const autoClosedTicket = await ticketsService.getById(ticket2);
expect(autoClosedTicket.status).toBe('RESOLVED');
});
it('Scenario 6: reopen — customer and agent, leaving prior records untouched', async () => {
const { ticketId, problemId } = await createTicket();
await fullyResolve(problemId, ticketId);
const token = await tokenForCurrentRequest();
await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/confirm-resolution`,
headers: { authorization: `Bearer ${token}` },
payload: identityPayload(),
});
const resolutionBefore = await prismaClient.resolution.findUniqueOrThrow({
where: { ticketId },
});
const reopenToken = await tokenForCurrentRequest();
const reopen = await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticketId}/reopen`,
headers: { authorization: `Bearer ${reopenToken}` },
payload: identityPayload(),
});
expect(reopen.statusCode).toBe(200);
expect(reopen.json().data.status).toBe('IN_PROGRESS');
const resolutionAfter = await prismaClient.resolution.findUniqueOrThrow({
where: { ticketId },
});
expect(resolutionAfter).toEqual(resolutionBefore);
// Agent reopen of a CLOSED ticket.
const { ticketId: ticket2, problemId: problem2 } = await createTicket();
await fullyResolve(problem2, ticket2);
const confirmToken = await tokenForCurrentRequest();
await app.inject({
method: 'POST',
url: `/v1/support/tickets/${ticket2}/confirm-resolution`,
headers: { authorization: `Bearer ${confirmToken}` },
payload: identityPayload(),
});
const resolvedTicket = await ticketsService.getById(ticket2);
await ticketsService.updateStatus(ticket2, 'CLOSED', resolvedTicket.version, 'system');
const agentReopen = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/reopen`,
});
expect(agentReopen.statusCode).toBe(200);
expect(agentReopen.json().data.status).toBe('IN_PROGRESS');
});
});