Files
support_backend/specs/003-ticketing/research.md
T

157 lines
10 KiB
Markdown
Raw Normal View History

# Phase 0 Research: Ticket Creation, Messages & Attachments
## Decision: Ticket code format
- **Decision**: `<PRODUCT_CODE>-<YEAR>-<SEQUENCE>`, e.g. `DQB-2026-00567` (matches doc 01/04's own
example exactly). `PRODUCT_CODE` is derived from the `Product.externalProductId` (first
alphabetic segment, uppercased, max 4 chars, falling back to a generic prefix if the external
id doesn't yield one cleanly) and `SEQUENCE` is a per-product-per-year monotonic counter.
- **Rationale**: Doc 01/04 use this exact shape as the running example throughout the guide;
matching it keeps generated codes recognizable against the spec's own illustrations. A
per-product-per-year counter (not a global one) keeps codes short and stable even as ticket
volume grows across many products.
- **Alternatives considered**: A UUID-derived short code — rejected, not human-referenceable the
way doc 01's own example implies support agents need ("DQB-2026-00567" is meant to be readable
and speakable, not just unique).
## Decision: Ticket lifecycle state machine
- **Decision**: States, exactly as doc 04 §1 and doc 06's `Ticket.status` comment list them:
`NEW, AI_ANALYZING, AI_TROUBLESHOOTING, AI_VERIFYING, AI_RESOLVED, HUMAN_ESCALATION,
IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED`.
Valid transitions are defined as an explicit adjacency table (not "anything can go anywhere"):
- `NEW``AI_ANALYZING`, `HUMAN_ESCALATION`
- `AI_ANALYZING``AI_TROUBLESHOOTING`, `HUMAN_ESCALATION`
- `AI_TROUBLESHOOTING``AI_VERIFYING`, `HUMAN_ESCALATION`
- `AI_VERIFYING``AI_RESOLVED`, `HUMAN_ESCALATION`
- `AI_RESOLVED``RESOLUTION_PENDING_CUSTOMER`, `RESOLVED`, `HUMAN_ESCALATION` (verification
failure re-escalating, per doc 04 §7)
- `HUMAN_ESCALATION``IN_PROGRESS`
- `IN_PROGRESS``WAITING_FOR_CUSTOMER`, `RESOLUTION_PENDING_CUSTOMER`, `HUMAN_ESCALATION`
(re-escalation)
- `WAITING_FOR_CUSTOMER``IN_PROGRESS`
- `RESOLUTION_PENDING_CUSTOMER``RESOLVED`, `IN_PROGRESS` (customer disputes, reopens
investigation per doc 04 §7)
- `RESOLVED``CLOSED`, `REOPENED`
- `CLOSED``REOPENED`
- `REOPENED``IN_PROGRESS`, `AI_ANALYZING`
- **Rationale**: An explicit table is what makes FR-006 ("only valid transitions") actually
enforceable and testable, rather than a status field anyone can set to anything. The specific
edges are read directly off doc 04's described flows (§1 happy path, §3 human flow, §7
verification-failure branches, §9 resolve/close/reopen).
- **Alternatives considered**: No enforced table (any transition allowed) — rejected, directly
contradicts FR-006; a generic workflow-engine library — rejected as disproportionate for a
fixed, spec-defined state set with no per-tenant customization need at this phase.
## Decision: Ticket status optimistic concurrency
- **Decision**: `Ticket` gains an `Int` `version` column (default `1`, matching doc 06's general
guidance to add this to mutable shared-state tables). A status update runs
`UPDATE tickets SET status = ?, version = version + 1 WHERE id = ? AND version = ?`; zero rows
affected means the caller's view was stale, and the update is rejected (`409 CONFLICT`, caller
must re-read and retry).
- **Rationale**: This is the standard optimistic-locking pattern and satisfies FR-007/SC-007
without needing row-level locks or a distributed lock service — Postgres's own atomic
`UPDATE ... WHERE` already guarantees exactly one concurrent writer wins.
- **Alternatives considered**: `SELECT ... FOR UPDATE` pessimistic locking — rejected, holds a
transaction open across what could be a slow caller round-trip; optimistic locking only holds
the lock for the single atomic statement.
## Decision: Idempotency-key enforcement
- **Decision**: `Ticket` gains a nullable, unique-per-product `idempotencyKey` column
(`@@unique([productId, idempotencyKey])`, nullable values excluded from the constraint per
Postgres's standard NULL-handling). Ticket creation is an atomic
`INSERT ... ON CONFLICT (productId, idempotencyKey) DO NOTHING RETURNING *`-style upsert; a
conflict means the key was already used, and the existing ticket is looked up and returned
instead.
- **Rationale**: Same atomic-upsert pattern already used for `CustomerReference.findOrCreate` in
002-saas-integration — proven, race-safe under concurrent retries, no separate idempotency-key
cache/table needed. Scoped per-product (not globally unique) because two different products'
clients could coincidentally generate the same key value.
- **Alternatives considered**: A separate `IdempotencyKey` tracking table — rejected as
unnecessary indirection when the key can live directly on the row it's deduplicating.
## Decision: Recurring-problem linking
- **Decision**: Per spec.md's Assumptions, this feature links to an existing `Problem` only when
the inbound request's `referenceIds` (docs/02 §3) or the ticket-creation call explicitly
supplies a known prior ticket/problem id; otherwise a new `Problem` is always created. No
fuzzy/semantic matching is attempted here.
- **Rationale**: Real recurring-problem detection needs product-aware understanding of symptoms —
that's the AI-support feature's job (Phase 4), not this one's. Building a heuristic here would
either be too naive to be useful or scope-creep into Phase 4's actual responsibility.
- **Alternatives considered**: Simple text-similarity matching on `Problem.statement` — rejected,
would produce false-positive links (different problems, similar wording) with no way to correct
them until Phase 4 exists to do it properly.
## Decision: Message type → visibility mapping
- **Decision**: A single source-of-truth constant map (not per-message logic):
```
CUSTOMER_MESSAGE: true, AI_MESSAGE: true, AGENT_MESSAGE: true, SYSTEM_EVENT: true,
INTERNAL_NOTE: false, INVESTIGATION_NOTE: false, SOLUTION_NOTE: false
```
`TicketMessage.visibleToCustomer` (doc 06's own field) is *set from this map at write time*,
never accepted as caller input — and customer-scoped reads filter `WHERE visibleToCustomer =
true` at the repository/query layer, not by trimming the response after fetching everything.
- **Rationale**: Directly satisfies FR-008 (visibility derived from type, not independently
settable) and FR-009 (enforced at the query layer, so a serialization bug can't leak a note that
was never fetched in the first place).
- **Alternatives considered**: Accepting `visibleToCustomer` as an API input — rejected outright,
this is exactly the "trust the client" mistake FR-008 exists to prevent.
## Decision: Attachment pipeline shape
- **Decision**: Two-phase upload — (1) caller requests a presigned PUT URL for a given
filename/content-type (`storageService` already has the S3 client wired, needs a presigned-PUT
method added alongside its existing presigned-GET `getPresignedUrl`); (2) caller PUTs the file
directly to object storage, then confirms the upload, which creates the `TicketAttachment` row
(`scanStatus: pending`) and enqueues a scan job on the existing `attachments-queue`
(`src/jobs/attachments/index.ts`, currently a log-only stub). Downloads use the existing
`storageService.getPresignedUrl` (GET), but only after the repository confirms
`scanStatus: clean` — the presigned URL is never generated for a `pending`/`infected`/`rejected`
attachment.
- **Rationale**: A presigned-PUT upload means file bytes never transit the Fastify process at all
(satisfies FR-011 more strongly than a server-side proxy-upload would, and avoids adding
multipart-body handling to this feature). The existing `attachments-queue` scaffold is exactly
where the scan step belongs per doc 07's own module layout.
- **Alternatives considered**: Server-side proxy upload (client → Fastify → S3) — rejected as
unnecessary complexity/latency when presigned PUT achieves the same security properties with
less code.
## Decision: Malware scanning — no scanner exists in this stack yet
- **Decision**: Define a small `MalwareScanner` interface (`scan(objectKey): Promise<'clean' |
'infected'>`) and ship one implementation now: a clearly-named
`UnimplementedPlaceholderScanner` that always returns `'infected'` (fails closed, never
`'clean'`) and logs a loud warning — so attachments are correctly gated as never-downloadable
until a real scanner (e.g. ClamAV via a sidecar, or a cloud provider's scanning API) is wired
in as a follow-up. The job worker calls whichever implementation is bound at startup.
- **Rationale**: No antivirus/scanning service exists anywhere in this codebase or its
dependencies, and standing one up (e.g. deploying ClamAV) is real infrastructure work outside
this feature's scope. The alternative — a scanner that always returns `'clean'` — would satisfy
the code path but silently defeat FR-013's actual security purpose; failing closed means the
pipeline is honest about "attachments aren't actually safe to download yet" rather than
pretending they are. This mirrors the same honesty principle as the credential-storage
placeholder decision in 002-saas-integration's research.md.
- **Alternatives considered**: Always-`'clean'` stub — rejected, defeats the feature's own
purpose and would be easy to forget to replace since nothing would ever surface the gap.
Skipping the scan step's implementation entirely (leave the job as its current log-only stub)
— rejected, FR-013 requires attachments be gated on scan status, and a permanently-`pending`
attachment (nothing ever calls the job) makes attachments unusable rather than correctly gated.
## Decision: Local/test object storage — add MinIO to Docker Compose
- **Decision**: Add a `minio` service to `docker-compose.test.yml` and
`docker-compose.development.yml`, and set `AWS_S3_ENDPOINT` in `.env.test`/`.env.development`
to point at it. `storageClient.ts` already branches on `storageConfig.endpoint` being set
(`forcePathStyle: true` for MinIO compatibility) — no client code changes needed, only compose
wiring and env values.
- **Rationale**: Doc 04 explicitly calls for "MinIO for local dev," and the client already
anticipated this (the `endpoint`/`forcePathStyle` branch exists in code that predates this
feature) — this decision just finishes wiring what was already half-built.
- **Alternatives considered**: Mocking S3 calls in tests instead of running real MinIO — rejected,
this feature's whole point includes verifying presigned URLs actually work and expire, which a
mock can't meaningfully verify.