Files
support_backend/src/modules/problem-management/resolutions/controller/resolutions.controller.ts
T
saqib mirandClaude Sonnet 5 16daf8d32d 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>
2026-09-03 15:09:29 +05:30

41 lines
2.0 KiB
TypeScript

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();