/speckit-plan output for 003-ticketing: technical context and constitution gate check (all PASS), Phase 0 research (9 decisions: ticket code format, explicit 12-state lifecycle transition table, optimistic concurrency via version column, idempotency-key upsert reusing 002's CustomerReference pattern, explicit-reference-only recurring-problem linking, config-driven message visibility mapping, presigned-PUT attachment pipeline, a fail-closed placeholder malware scanner since none exists in this stack, and adding MinIO to Docker Compose for local/test S3-compatible storage), Phase 1 data model (Ticket/Problem/TicketMessage/TicketAttachment plus the inbound request -> ticket creation behavior), the lifecycle/messages/ attachments contract, and a 6-scenario quickstart. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.0 KiB
Phase 1 Data Model: Ticket Creation, Messages & Attachments
All new models use cuid() ids, matching the convention established in 002-saas-integration.
Relations to not-yet-existing models (AI sessions, assignments, SLA runs, escalation events,
investigations, root causes, solutions — all later phases) are deliberately omitted for now and
added when those phases introduce the models they'd point to; Prisma can't reference a model that
doesn't exist.
Ticket
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| code | String @unique | <PRODUCT_CODE>-<YEAR>-<SEQUENCE> (research.md) |
| productId | String | FK → Product |
| problemId | String | FK → Problem |
| customerId | String | FK → CustomerReference (from 002-saas-integration) — the normalized reference |
| externalUserId | String | Denormalized copy, matches doc 06's literal shape for query convenience without a join |
| externalTenantId | String | Denormalized copy, same rationale |
| status | String | One of the 12 states in research.md's state machine; @default("NEW") |
| priority | String | Free-text for now — PriorityPolicy-driven derivation is Phase 6/orchestration, not this feature |
| severity | String | |
| categoryId | String? | FK → existing Category model (already in schema from the original scaffold) |
| idempotencyKey | String? | Research.md's idempotency mechanism |
| version | Int @default(1) | Optimistic concurrency (research.md) |
| createdAt / updatedAt | DateTime |
Constraints: @@unique([productId, idempotencyKey]) (nullable-excluded — two tickets with
idempotencyKey: null don't conflict). Index on (productId, status) and
(externalTenantId, externalUserId) for the query patterns FR-015 requires (tenant/user-scoped
lookups).
Status transition rule: enforced entirely in the service layer against the explicit adjacency table in research.md — the column itself has no DB-level CHECK constraint beyond "is a known string," since Prisma doesn't model state machines natively and a CHECK constraint would need to be duplicated in code anyway for the "attempted from X" half of transition validation.
Problem
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| statement | String | |
| symptoms | String | |
| impact | String? | |
| productId | String | FK → Product |
| categoryId | String? | FK → existing Category model |
| severity | String | |
| customerImpact | String? | |
| businessImpact | String? | |
| environment | String? | |
| createdAt | DateTime @default(now()) |
Relations added by this feature: tickets Ticket[] (1 problem : many tickets, Constitution
Principle VIII).
TicketMessage
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → Ticket |
| type | String | CUSTOMER_MESSAGE | AI_MESSAGE | AGENT_MESSAGE | INTERNAL_NOTE | SYSTEM_EVENT | INVESTIGATION_NOTE | SOLUTION_NOTE |
| authorRef | String | agentId, "ai", "system", or externalUserId — never a local FK (Constitution Principle I) |
| body | String | |
| visibleToCustomer | Boolean | Set from the type→visibility map at write time (research.md) — never accepted as request input |
| createdAt | DateTime @default(now()) |
Index: (ticketId, visibleToCustomer, createdAt) — the exact shape customer-scoped reads
query on (FR-009).
TicketAttachment
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → Ticket |
| storageKey | String | S3/MinIO object key — never the file itself (FR-011) |
| fileName | String | Original filename, for display only |
| mimeType | String | Validated against an allow-list at upload-confirm time (FR-012) |
| sizeBytes | Int | Validated against a configured max at upload-confirm time |
| scanStatus | String | pending | clean | infected | rejected (research.md's MalwareScanner) |
| uploadedBy | String | agentId or externalUserId — same non-FK convention as TicketMessage.authorRef |
| createdAt | DateTime @default(now()) |
Download rule: a presigned GET URL is generated only when scanStatus == 'clean' — enforced
in the service layer before calling storageService.getPresignedUrl, never left to the caller to
check first (FR-013/FR-014).
Inbound Request → Ticket Creation (behavior, not a new table)
Extends 002-saas-integration's inbound flow. After authenticateProductIntegration succeeds and
populates request.reqContext, the route handler (previously a stub echoing context back) now:
- Resolves or creates a
Problem(research.md's explicit-reference-only linking). - Atomically creates (or, on idempotency-key conflict, fetches) the
TicketinNEWstatus. - Writes a
SYSTEM_EVENTTicketMessagerecording the creation (Constitution Principle VI — durable audit trail via the message timeline itself). - Returns the ticket's
id/code/statusto the caller (still202, now backed by a real record instead of an echo).