238 lines
15 KiB
Markdown
238 lines
15 KiB
Markdown
# Feature Specification: Ticket Creation, Messages & Attachments
|
|||
|
|
|
||
|
|
**Feature Branch**: `003-ticketing`
|
||
|
|
|
||
|
|
**Created**: 2026-09-02
|
||
|
|
|
||
|
|
**Status**: Draft
|
||
|
|
|
||
|
|
**Input**: User description: "Phase 5 of docs/10-implementation-roadmap.md: Ticket + Problem
|
||
|
|
models (kept separate), message types, attachment pipeline (object storage, scanning, expiring
|
||
|
|
URLs), ticket lifecycle state machine. Per docs/04-ticketing-and-problem-management.md and
|
||
|
|
docs/06-database-schema.md."
|
||
|
|
|
||
|
|
## User Scenarios & Testing *(mandatory)*
|
||
|
|
|
||
|
|
### User Story 1 - A trusted request creates a durable ticket immediately (Priority: P1)
|
||
|
|
|
||
|
|
The moment a validated inbound request (from the SaaS integration trust boundary) describes a
|
||
|
|
customer's problem, SupportHub creates a durable ticket right away — before any diagnosis,
|
||
|
|
before any human is involved. The ticket starts in a `NEW` status. If the same underlying problem
|
||
|
|
recurs for the same product/tenant, the new ticket is linked to the existing `Problem` record
|
||
|
|
rather than creating a duplicate; if it's a genuinely new problem, a new `Problem` record is
|
||
|
|
created alongside the ticket. A retried request (same idempotency key) returns the
|
||
|
|
already-created ticket instead of creating a second one.
|
||
|
|
|
||
|
|
**Why this priority**: This is the foundational principle of the whole product ("ticket created
|
||
|
|
at the start of the journey, not after AI gives up") and everything else in this feature — and
|
||
|
|
every future feature (AI, orchestration, resolution) — depends on the ticket/problem records
|
||
|
|
existing first.
|
||
|
|
|
||
|
|
**Independent Test**: Send a valid inbound request through the trust boundary and confirm a
|
||
|
|
`Ticket` (status `NEW`) and a `Problem` exist immediately, correctly linked to the validated
|
||
|
|
product/tenant/user context; send the same request again with the same idempotency key and
|
||
|
|
confirm no second ticket is created.
|
||
|
|
|
||
|
|
**Acceptance Scenarios**:
|
||
|
|
|
||
|
|
1. **Given** a validated inbound request describing a problem, **When** it's processed, **Then**
|
||
|
|
a `Ticket` is created in `NEW` status and a `Problem` is created (or an existing one reused —
|
||
|
|
see Scenario 3), both linked to the validated product/tenant/user context, before any further
|
||
|
|
processing occurs.
|
||
|
|
2. **Given** a ticket was just created, **When** its record is inspected, **Then** it has a
|
||
|
|
human-referenceable code (e.g. `DQB-2026-00567`-style), the originating product, and the
|
||
|
|
trusted customer reference — never a raw, unvalidated value from the request.
|
||
|
|
3. **Given** a customer reports what is recognizably the same underlying problem again (same
|
||
|
|
product, same recognizable symptoms/context) as an existing open `Problem`, **When** a new
|
||
|
|
ticket is created for it, **Then** the new ticket links to the *existing* `Problem` record
|
||
|
|
rather than creating a duplicate one.
|
||
|
|
4. **Given** an inbound request carries an idempotency key that was already used for a
|
||
|
|
successfully created ticket, **When** the request is retried, **Then** the existing ticket is
|
||
|
|
returned and no second ticket or problem is created.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### User Story 2 - Ticket messages are typed, and internal notes are never visible to customers (Priority: P2)
|
||
|
|
|
||
|
|
A ticket accumulates a timeline of messages — from the customer, from AI (in future), from
|
||
|
|
agents, from the system, and internal-only notes (investigation/solution notes, general internal
|
||
|
|
notes). Every message has a type, and the API enforces — not just the UI — that
|
||
|
|
customer-invisible message types can never reach a customer-scoped read.
|
||
|
|
|
||
|
|
**Why this priority**: Without a message timeline there's no record of the interaction to show
|
||
|
|
anyone; without enforced internal-note privacy, an agent's private note becomes a customer-facing
|
||
|
|
leak the moment someone builds a UI that forgets to filter client-side.
|
||
|
|
|
||
|
|
**Independent Test**: Post one of each message type on a ticket, then read the ticket's messages
|
||
|
|
as a customer-scoped caller and confirm only customer-visible types appear; read the same
|
||
|
|
messages as an agent-scoped caller and confirm all types appear.
|
||
|
|
|
||
|
|
**Acceptance Scenarios**:
|
||
|
|
|
||
|
|
1. **Given** a ticket, **When** a message of any defined type is posted to it, **Then** it's
|
||
|
|
stored with its type, author reference, body, and a customer-visibility flag derived from its
|
||
|
|
type (never independently settable per-message in a way that contradicts the type).
|
||
|
|
2. **Given** a ticket has both customer-visible and internal-only messages, **When** its messages
|
||
|
|
are read through a customer-scoped endpoint, **Then** only customer-visible messages are
|
||
|
|
returned — internal notes are absent from the response entirely, not merely hidden by a flag.
|
||
|
|
3. **Given** the same ticket, **When** its messages are read through an agent-scoped endpoint,
|
||
|
|
**Then** every message, including internal notes, is returned.
|
||
|
|
4. **Given** a caller not authorized for a given ticket's tenant, **When** they attempt to read or
|
||
|
|
post a message on it, **Then** the request is rejected regardless of message type.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### User Story 3 - Attachments are safely stored and only ever downloaded through expiring, authorized URLs (Priority: P3)
|
||
|
|
|
||
|
|
A customer or agent can attach a file (screenshot, PDF, log, video, document) to a ticket. The
|
||
|
|
file goes to object storage, never to PostgreSQL — only its metadata and a storage reference are
|
||
|
|
stored in the database. It is not available for download until it has cleared a malware scan.
|
||
|
|
Every download happens through a short-lived, authorization-checked URL scoped to that ticket's
|
||
|
|
tenant/user context — never a permanent or unauthenticated link.
|
||
|
|
|
||
|
|
**Why this priority**: Attachments are common (screenshots, logs) but not required for the
|
||
|
|
minimum ticket flow to work, and getting the security properties right (never in Postgres, never
|
||
|
|
downloadable pre-scan, never a permanent link) matters more than shipping it first.
|
||
|
|
|
||
|
|
**Independent Test**: Upload a file to a ticket and confirm it's rejected for download until scan
|
||
|
|
status clears; confirm a generated download URL stops working after it expires; confirm a caller
|
||
|
|
outside the ticket's tenant cannot generate or use a download URL for it.
|
||
|
|
|
||
|
|
**Acceptance Scenarios**:
|
||
|
|
|
||
|
|
1. **Given** a file upload to a ticket, **When** it's outside the configured type/size limits,
|
||
|
|
**Then** it's rejected before being sent to object storage.
|
||
|
|
2. **Given** an accepted upload, **When** it has not yet cleared malware scanning, **Then** it is
|
||
|
|
not downloadable — its status is visibly `pending`, not silently unavailable.
|
||
|
|
3. **Given** a file that clears scanning, **When** an authorized caller requests to download it,
|
||
|
|
**Then** they receive a time-limited URL that stops working after it expires.
|
||
|
|
4. **Given** a file that fails malware scanning, **When** anyone attempts to download it,
|
||
|
|
**Then** the download is refused and the failure is visible on the attachment's record.
|
||
|
|
5. **Given** a caller outside the ticket's tenant/user context, **When** they attempt to generate
|
||
|
|
or use a download URL for one of its attachments, **Then** the request is rejected.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Edge Cases
|
||
|
|
|
||
|
|
- What happens when two requests for the same new problem arrive concurrently (not a literal
|
||
|
|
retried idempotency key, but a genuine race — e.g. a flaky client double-submits without
|
||
|
|
reusing the idempotency key)? Out of scope to fully solve here beyond the idempotency-key
|
||
|
|
mechanism in User Story 1 — true duplicate-problem detection beyond exact idempotency-key reuse
|
||
|
|
is a knowledge/classification concern for a future AI feature, not this one.
|
||
|
|
- What happens when a ticket's status is updated by two actors at nearly the same time (e.g. a
|
||
|
|
customer reopens while an agent is closing)? The update that observes a stale status MUST be
|
||
|
|
rejected and retried against the current state — not silently overwrite the other actor's
|
||
|
|
change (Constitution Principle VII).
|
||
|
|
- What happens when an attachment upload is interrupted mid-transfer? The attachment record MUST
|
||
|
|
NOT be considered available; a resumed/retried upload is a new attempt, not a partial record
|
||
|
|
left in a downloadable-looking state.
|
||
|
|
- What happens when a malware scan itself fails to run (infrastructure error, not "found
|
||
|
|
malware")? The attachment MUST remain `pending`, never silently promoted to available.
|
||
|
|
- What happens when a message is posted with a type that doesn't exist in the defined set? The
|
||
|
|
request MUST be rejected — message type is not free text.
|
||
|
|
|
||
|
|
## Requirements *(mandatory)*
|
||
|
|
|
||
|
|
### Functional Requirements
|
||
|
|
|
||
|
|
- **FR-001**: The system MUST create a `Ticket` record immediately upon processing a validated
|
||
|
|
inbound request describing a problem — before any diagnosis, classification, or human
|
||
|
|
involvement occurs.
|
||
|
|
- **FR-002**: The system MUST create or reuse a `Problem` record for every ticket: a new `Problem`
|
||
|
|
when none matches, or the existing `Problem` when the ticket represents a recurrence of one
|
||
|
|
already open for the same product/tenant context.
|
||
|
|
- **FR-003**: `Ticket` and `Problem` MUST remain separate, related entities — a ticket references
|
||
|
|
exactly one problem; a problem may have many tickets (Constitution Principle VIII).
|
||
|
|
- **FR-004**: The system MUST honor the inbound request's idempotency key: a retried request
|
||
|
|
carrying a previously-used key returns the already-created ticket rather than creating a new
|
||
|
|
one.
|
||
|
|
- **FR-005**: Every ticket MUST have a human-referenceable code, unique, generated by the system
|
||
|
|
— never supplied by the caller.
|
||
|
|
- **FR-006**: A ticket's status MUST only ever be one of the defined lifecycle states, and MUST
|
||
|
|
only transition through valid state changes (an invalid transition is rejected, not silently
|
||
|
|
coerced).
|
||
|
|
- **FR-007**: A ticket's status update MUST use optimistic concurrency control: an update based on
|
||
|
|
a stale prior status is rejected, not applied on top of a change it didn't observe.
|
||
|
|
- **FR-008**: Every message posted to a ticket MUST have one of the defined message types, and its
|
||
|
|
customer-visibility MUST be determined by its type, not independently settable in a way that
|
||
|
|
contradicts the type.
|
||
|
|
- **FR-009**: The system MUST NOT return customer-invisible message types (internal notes,
|
||
|
|
investigation notes, solution notes) through any customer-scoped read of a ticket's messages —
|
||
|
|
enforced at the API/serialization layer, not left to client-side filtering.
|
||
|
|
- **FR-010**: The system MUST reject reading or posting on a ticket by a caller not authorized for
|
||
|
|
that ticket's tenant/user context, regardless of message type or attachment involved.
|
||
|
|
- **FR-011**: Attachment files MUST be stored in object storage, never in PostgreSQL — the
|
||
|
|
database stores only metadata and a storage reference.
|
||
|
|
- **FR-012**: The system MUST validate an attachment's type and size against configured limits
|
||
|
|
before accepting the upload.
|
||
|
|
- **FR-013**: An uploaded attachment MUST NOT be downloadable until it has cleared malware
|
||
|
|
scanning; its scan status MUST be visible on its record (`pending` / `clean` / `infected` /
|
||
|
|
`rejected`).
|
||
|
|
- **FR-014**: Attachment downloads MUST only be possible through a time-limited,
|
||
|
|
authorization-checked URL scoped to the ticket's tenant/user context — never a permanent or
|
||
|
|
unauthenticated link.
|
||
|
|
- **FR-015**: The system MUST scope every ticket, message, and attachment query by the caller's
|
||
|
|
validated tenant/user context — a caller-supplied identifier alone is never sufficient
|
||
|
|
authorization (Constitution Principle I).
|
||
|
|
|
||
|
|
### Key Entities
|
||
|
|
|
||
|
|
- **Ticket**: The durable, operational record of one support interaction — status, priority,
|
||
|
|
severity, the product/tenant/customer it belongs to, and links to its problem, messages,
|
||
|
|
attachments, and (in later features) AI sessions, assignments, and escalation events.
|
||
|
|
- **Problem**: The underlying issue being solved, which can outlive and span multiple tickets —
|
||
|
|
statement, symptoms, impact, severity, environment. Deliberately separate from `Ticket`.
|
||
|
|
- **Ticket Message**: One entry in a ticket's timeline — typed (`CUSTOMER_MESSAGE`, `AI_MESSAGE`,
|
||
|
|
`AGENT_MESSAGE`, `INTERNAL_NOTE`, `SYSTEM_EVENT`, `INVESTIGATION_NOTE`, `SOLUTION_NOTE`), with
|
||
|
|
an author reference, body, and a visibility derived from its type.
|
||
|
|
- **Ticket Attachment**: Metadata for one uploaded file — storage reference (never the file
|
||
|
|
itself), original filename, MIME type, size, scan status, and who uploaded it.
|
||
|
|
|
||
|
|
## Success Criteria *(mandatory)*
|
||
|
|
|
||
|
|
### Measurable Outcomes
|
||
|
|
|
||
|
|
- **SC-001**: 100% of validated inbound requests result in a ticket existing before any further
|
||
|
|
processing — zero requests that pass the trust boundary without a corresponding ticket.
|
||
|
|
- **SC-002**: A retried request using the same idempotency key never produces more than one
|
||
|
|
ticket, regardless of how many times it's retried.
|
||
|
|
- **SC-003**: Zero internal-only messages ever appear in a customer-scoped read of a ticket's
|
||
|
|
timeline, verified across every defined message type.
|
||
|
|
- **SC-004**: Zero attachment files are ever persisted directly in the database — 100% go to
|
||
|
|
object storage with only a reference stored.
|
||
|
|
- **SC-005**: An attachment that hasn't cleared malware scanning is rejected for download 100% of
|
||
|
|
the time it's attempted.
|
||
|
|
- **SC-006**: A generated attachment download URL becomes unusable after its configured expiry —
|
||
|
|
verified by attempting to use it past that point.
|
||
|
|
- **SC-007**: Two concurrent status updates to the same ticket never both apply silently — exactly
|
||
|
|
one succeeds against the state it observed, and the other is rejected and must retry.
|
||
|
|
|
||
|
|
## Assumptions
|
||
|
|
|
||
|
|
- This feature covers ticket/problem creation, the message timeline, and the attachment pipeline
|
||
|
|
only. It does NOT include: AI diagnosis/classification (Phase 4), investigation/root
|
||
|
|
cause/solution/resolution workflows (Phase 9), orchestration/assignment/SLA (Phases 6-8), or the
|
||
|
|
customer-confirmation/auto-close/reopen *workflow* automation (Phase 9) — though the `REOPENED`
|
||
|
|
status itself is part of the lifecycle state machine this feature defines, since doc 04 lists it
|
||
|
|
as a core ticket status.
|
||
|
|
- "Recognizably the same underlying problem" (FR-002/User Story 1 Scenario 3) is intentionally
|
||
|
|
left without a precise matching algorithm here — real recurring-problem detection is a
|
||
|
|
knowledge/classification capability that belongs to the AI-support feature (Phase 4). For this
|
||
|
|
feature, an explicit, caller-supplied reference (e.g. a prior ticket/problem id in the inbound
|
||
|
|
request's `referenceIds`, per docs/02 §3) is sufficient grounds to link to an existing `Problem`
|
||
|
|
— this feature does not attempt fuzzy/semantic matching on its own.
|
||
|
|
- AI-driven status transitions (`AI_ANALYZING`, `AI_TROUBLESHOOTING`, `AI_VERIFYING`,
|
||
|
|
`AI_RESOLVED`) are part of the lifecycle state machine's defined states (FR-006), but nothing in
|
||
|
|
this feature *automatically drives* a ticket into them — that requires the AI-support feature
|
||
|
|
(Phase 4), which doesn't exist yet. This feature only guarantees the state machine itself is
|
||
|
|
correct and that transitions can be triggered (e.g. by an authorized caller or a future feature)
|
||
|
|
without corrupting ticket state under concurrency.
|
||
|
|
- Row-level security (Postgres RLS) as a defense-in-depth layer under the application-level tenant
|
||
|
|
scoping in FR-015 (per `docs/11-architect-additions-gaps-and-recommendations.md` §A3) is a
|
||
|
|
valuable hardening step but is deliberately deferred — `REQUIRES BUSINESS/PLATFORM
|
||
|
|
CONFIRMATION` on whether/when to adopt it, not invented here. Application-level scoping (FR-015)
|
||
|
|
is the enforced control for this feature.
|
||
|
|
- Malware scanning integration specifics (which scanner/service) are a technical decision left to
|
||
|
|
planning — this spec only requires that the scan gate and its visible states exist.
|