Files
support_backend/specs/003-ticketing/contracts/ticket-lifecycle-contract.md
T

62 lines
3.3 KiB
Markdown
Raw Normal View History

# 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).