157 lines
10 KiB
Markdown
157 lines
10 KiB
Markdown
# 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.
|