2026-09-02 14:53:53 +05:30
|
|
|
# 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 }`,
|
2026-09-02 15:21:37 +05:30
|
|
|
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.
|
2026-09-02 14:53:53 +05:30
|
|
|
2. Caller PUTs the file directly to the returned URL (file bytes never transit this API).
|
2026-09-02 15:21:37 +05:30
|
|
|
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.
|
2026-09-02 14:53:53 +05:30
|
|
|
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).
|