Files
support_backend/specs/003-ticketing/contracts/ticket-lifecycle-contract.md
T
saqib mirandClaude Sonnet 5 2edfbacf82 feat: implement ticket creation, messages & attachments (003-ticketing)
Implements all 39 tasks from specs/003-ticketing/tasks.md across all
three user stories -- Phase 5 of the roadmap.

Schema (prisma/schema.prisma + migration):
- Ticket (code, status, version for optimistic concurrency,
  idempotencyKey, customerId FK), Problem, TicketMessage,
  TicketAttachment per docs/06, with Product/Category/
  CustomerReference back-relations.

User Story 1 -- ticket/problem creation (P1, MVP):
- Explicit 12-state lifecycle adjacency table
  (ticket-state-machine.ts), not "any transition allowed."
- Ticket code generation (<PRODUCT_CODE>-<YEAR>-<SEQUENCE>) scoped
  by the actual code prefix, not productId -- see the collision bug
  fixed below.
- Idempotency-key enforcement via atomic create-then-catch-conflict
  (never a read-then-write race), completing the FR-012 placeholder
  from 002-saas-integration.
- Explicit-reference-only recurring-problem linking (no fuzzy
  matching -- that's a future AI-support concern).
- POST /v1/support/requests (002-saas-integration) now creates a
  real ticket instead of echoing context back.
- PATCH /tickets/:id/status with expectedVersion-based optimistic
  concurrency (409 on stale version, 400 on an invalid transition).

User Story 2 -- typed messages (P2):
- Message type -> visibleToCustomer mapping is a fixed constant map,
  never caller-supplied; customer-scoped reads filter at the query
  layer so an internal note is never fetched, not just hidden.
- POST/GET /tickets/:id/messages (customer-scoped) and
  GET /agent/tickets/:id/messages (agent-scoped).

User Story 3 -- attachment pipeline (P3):
- Presigned-PUT upload (new getPresignedUploadUrl on the existing
  storageService) -- file bytes never transit this API.
- A MalwareScanner interface with a fail-closed placeholder
  (UnimplementedPlaceholderScanner) since no scanner exists in this
  stack -- it always reports 'infected', never silently 'clean'.
- The existing attachments-queue job stub now actually calls the
  scanner and updates scanStatus; registerAttachmentWorker() is
  wired into bootstrapQueue() (previously defined but never called).
- Downloads are gated on scanStatus === 'clean' -- currently always
  refused until a real scanner replaces the placeholder.
- MinIO added to docker-compose.{test,development}.yml for local/CI
  S3-compatible storage, matching doc 04's explicit guidance.

Two real bugs found and fixed via integration testing against a
live Postgres/Redis/MinIO (not just typechecked):
- Ticket codes could collide across different products: the
  sequence counter was scoped by internal productId, but the code
  column's uniqueness is global, and deriveProductCode's 4-character
  truncation means different products can share a prefix. Fixed by
  counting against the actual code prefix instead.
- Three existing 002-saas-integration integration tests' cleanup
  started failing an FK RESTRICT check once ticket creation was
  wired in (deleting a Product before the Ticket/Problem that now
  reference it). Fixed their afterAll ordering.

All 9 integration test files (24 tests, spanning this feature and
the pre-existing suite) verified passing against real Postgres,
Redis, and MinIO, including a genuine presigned-PUT/GET round trip.
Full quality gate (typecheck/lint/format/architecture/unit tests)
passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:21:37 +05:30

3.3 KiB

Contract: Ticket Lifecycle, Messages & Attachments

Ticket creation (via the inbound trust boundary)

POST /v1/support/requests (002-saas-integration) now, after successful auth:

  1. Resolve/create Problem (research.md's explicit-reference-only rule).
  2. Atomic create-or-fetch Ticket on (productId, idempotencyKey) — a retried request returns the same ticket, never a second one (FR-004/SC-002).
  3. Write a SYSTEM_EVENT message.
  4. Respond 202 with { ticketId, code, status, problemId }.

Guarantee: no request that passes the trust boundary ever completes without a ticket existing (FR-001/SC-001) — ticket creation is synchronous within the same request, not queued.

Ticket status transitions

PATCH /tickets/:ticketId/status — body { status: <new status>, expectedVersion: <int> }.

Step Failure
Ticket exists and caller is tenant-authorized 404 / 403
expectedVersion matches the ticket's current version 409 CONFLICT (FR-007/SC-007) — caller must re-read and retry
Requested transition is a valid edge from the current status (research.md's table) 400 INVALID_TRANSITION

On success: status and version (+1) update atomically; a SYSTEM_EVENT message records the transition.

Messages

  • POST /tickets/:ticketId/messages — body { type, body } (authorRef/visibleToCustomer derived server-side, never accepted as input — FR-008).
  • GET /tickets/:ticketId/messages — the caller's scope (customer vs. agent/admin) determines which types are queried; a customer-scoped caller's query never includes visibleToCustomer: false rows (FR-009) — enforced in the repository's WHERE clause, not by filtering an already-fetched list.

Attachments

  1. POST /tickets/:ticketId/attachments/upload-url — body { fileName, mimeType, sizeBytes }, validated against configured limits (FR-012) before a presigned PUT URL is returned, along with the storageKey the caller must echo back in step 3. No TicketAttachment row exists yet at this point.
  2. Caller PUTs the file directly to the returned URL (file bytes never transit this API).
  3. POST /tickets/:ticketId/attachments/confirm — body { storageKey, fileName, mimeType, sizeBytes } (echoing step 1's values) — creates the TicketAttachment row (scanStatus: pending) and enqueues the scan job on attachments-queue. No attachmentId exists before this call, so it isn't a path param here.
  4. GET /tickets/:ticketId/attachments/:attachmentId/download-url — returns a presigned GET URL only if scanStatus == 'clean'; otherwise 409 with the current scan status (FR-013/FR-014).

Guarantees (callable contract)

  1. Ticket existence is synchronous with trust-boundary success — never eventually-consistent.
  2. Idempotency key reuse never creates a second ticket, regardless of retry count (SC-002).
  3. No internal-only message type is ever returned to a customer-scoped read, verified per type (SC-003).
  4. No attachment file byte ever reaches PostgreSQL — only storageKey metadata (SC-004).
  5. No attachment is downloadable before scanStatus: clean, every time it's attempted (SC-005).
  6. A concurrent, stale-version status update is rejected, never silently overwritten (SC-007).