docs: task breakdown for ticketing feature

/speckit-tasks output for 003-ticketing: 39 tasks across 6 phases.
MVP scope is Setup+Foundational+US1 (T001-T019) -- every trusted
inbound request producing a real, durable, idempotent,
concurrency-safe ticket, before messages or attachments exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-02 14:56:05 +05:30
co-authored by Claude Sonnet 5
parent 4751cf3164
commit 954152bd7b
+264
View File
@@ -0,0 +1,264 @@
---
description: "Task list for 003-ticketing"
---
# Tasks: Ticket Creation, Messages & Attachments
**Input**: Design documents from `specs/003-ticketing/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md), [contracts/ticket-lifecycle-contract.md](./contracts/ticket-lifecycle-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks (state machine, idempotency, and visibility enforcement
are exactly the kind of "MUST" requirements regressions silently break), same approach as
002-saas-integration.
**Organization**: Tasks are grouped by user story (US1 = P1 ticket/problem creation, US2 = P2
messages, US3 = P3 attachments).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [ ] T001 Add a `minio` service to `docker-compose.test.yml` and
`docker-compose.development.yml` (image `minio/minio`, console + API ports), and set
`AWS_S3_ENDPOINT`/`AWS_S3_BUCKET`/`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` in
`.env.test`/`.env.development` to point at it (research.md "local/test object storage")
- [ ] T002 [P] Add `getPresignedUploadUrl(objectName, contentType, expirySeconds?)` to
`src/infrastructure/storage/storage.service.ts`, mirroring the existing
`getPresignedUrl`/`PutObjectCommand` pattern already used by `uploadFile`
**Checkpoint**: Object storage is reachable locally/in CI; the service can mint both upload and
download presigned URLs.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema and shared primitives (state machine, visibility map, scanner interface)
every user story depends on.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [ ] T003 Add `Ticket`, `Problem`, `TicketMessage`, `TicketAttachment` models to
`prisma/schema.prisma` per `data-model.md` (including `Ticket.version`,
`Ticket.idempotencyKey`, `Ticket.customerId` FK to the existing `CustomerReference`,
`Ticket.categoryId` FK to the existing `Category`)
- [ ] T004 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T003 (depends on T003)
- [ ] T005 [P] Define the 12-state adjacency table and a pure `isValidTransition(from, to):
boolean` function in `src/modules/ticketing/tickets/mapper/ticket-state-machine.ts` per
research.md's exact edge list
- [ ] T006 [P] Define the message type→visibility constant map and a pure
`isVisibleToCustomer(type): boolean` function in
`src/modules/ticketing/messages/mapper/message-visibility.ts` per research.md
- [ ] T007 [P] Define the `MalwareScanner` interface and the fail-closed
`UnimplementedPlaceholderScanner` in
`src/modules/ticketing/attachments/mapper/malware-scanner.ts` per research.md — logs a
loud warning on every call
- [ ] T008 [P] Add a ticket-code generator (`generateTicketCode(externalProductId, sequence):
string`) in `src/modules/ticketing/tickets/mapper/ticket-code.ts` per research.md's format
**Checkpoint**: Schema migrated; state machine, visibility map, scanner interface, and code
generator exist and are independently unit-testable. User stories can now be built.
---
## Phase 3: User Story 1 - A trusted request creates a durable ticket immediately (Priority: P1) 🎯 MVP
**Goal**: `POST /v1/support/requests` (002-saas-integration) creates a real `Ticket`+`Problem`
instead of echoing context back; ticket status transitions are validated and concurrency-safe.
**Independent Test**: Quickstart Scenarios 1, 2, 3, 6.
### Tests for User Story 1
- [ ] T009 [P] [US1] Unit tests for `ticket-state-machine.ts` (every valid edge accepted, a
sample of invalid edges rejected) in `tests/unit/ticketing/ticket-state-machine.test.ts`
- [ ] T010 [P] [US1] Unit tests for `ticket-code.ts` (format, per-product-per-year sequencing) in
`tests/unit/ticketing/ticket-code.test.ts`
- [ ] T011 [US1] Integration test covering Quickstart Scenarios 1, 2, 3, 6 (creation, idempotent
retry, recurring-problem linking via `referenceIds`, concurrent status-update rejection)
against a real Postgres in `tests/integration/ticket-creation.test.ts`
### Implementation for User Story 1
- [ ] T012 [US1] Add `ProblemsRepository` (create; find-by-reference using an explicit prior
ticket/problem id — research.md's explicit-reference-only rule) in
`src/modules/ticketing/tickets/repository/problems.repository.ts` (depends on T004)
- [ ] T013 [US1] Add `TicketsRepository` (atomic `create` with idempotency-key upsert per
data-model.md's `@@unique([productId, idempotencyKey])`; `findById`; `findByCode`;
`updateStatus` using the `version`-based optimistic-concurrency `UPDATE ... WHERE version =
?` from research.md) in `src/modules/ticketing/tickets/repository/tickets.repository.ts`,
replacing the old placeholder `findAllProducts`-style stub (depends on T004)
- [ ] T014 [US1] Add `TicketMessagesRepository.create` (used internally for the `SYSTEM_EVENT`
creation/transition record — full messages CRUD is User Story 2) in
`src/modules/ticketing/messages/repository/messages.repository.ts` (depends on T004, T006)
- [ ] T015 [US1] Add `TicketsService.createFromInboundRequest(reqContext, body)`: resolves/creates
the `Problem` (T012), creates/fetches the `Ticket` (T013), writes the creation
`SYSTEM_EVENT` message (T014) — all synchronous within one request (FR-001) — in
`src/modules/ticketing/tickets/service/tickets.service.ts` (depends on T012, T013, T014)
- [ ] T016 [US1] Add `TicketsService.updateStatus(ticketId, newStatus, expectedVersion, actor)`:
validates the transition via `isValidTransition` (T005), calls the repository's optimistic
update, writes a `SYSTEM_EVENT` message on success, throws `409 CONFLICT` on version
mismatch and `400 INVALID_TRANSITION` on an invalid edge (depends on T005, T013, T014)
- [ ] T017 [US1] Replace `src/modules/catalog/products/routes/inbound-request.routes.ts`'s stub
handler: call `TicketsService.createFromInboundRequest` and respond with
`{ ticketId, code, status, problemId }` instead of echoing `reqContext` back (depends on
T015)
- [ ] T018 [US1] Add `PATCH /tickets/:ticketId/status` (body `{ status, expectedVersion }`) and
`GET /tickets/:ticketId` routes, tenant-scoped per FR-015, in
`src/modules/ticketing/tickets/routes/tickets.routes.ts`, replacing the old placeholder
`GET /tickets` list stub; register the module's routes from `src/api/routes.ts` (depends on
T016)
- [ ] T019 [US1] Run Quickstart Scenarios 1, 2, 3, 6 locally and confirm all four pass
**Checkpoint**: User Story 1 is fully functional — every trusted inbound request produces a real,
concurrency-safe, idempotent ticket. This is a deployable/demoable increment even before
messages/attachments exist.
---
## Phase 4: User Story 2 - Ticket messages are typed, and internal notes are never visible to customers (Priority: P2)
**Goal**: Full message CRUD with visibility enforced at the query layer.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 2
- [ ] T020 [P] [US2] Unit tests for `message-visibility.ts` (every type maps correctly, including
that the mapping can't be overridden by caller input at the type level) in
`tests/unit/ticketing/message-visibility.test.ts`
- [ ] T021 [US2] Integration test covering Quickstart Scenario 4 (post one of each type; confirm
customer-scoped read excludes internal types entirely; confirm agent-scoped read includes
all) against a real Postgres in `tests/integration/ticket-messages.test.ts`
### Implementation for User Story 2
- [ ] T022 [US2] Extend `TicketMessagesRepository` with `findVisibleToCustomer(ticketId)`
(`WHERE visibleToCustomer = true`, per data-model.md's index) and `findAll(ticketId)`
(agent-scope) — both tenant-scoped per FR-015 (depends on T014)
- [ ] T023 [US2] Add `MessagesService.post(ticketId, actor, type, body)` (sets
`visibleToCustomer` from `isVisibleToCustomer(type)` — never from request input, FR-008)
and `.listForCustomer`/`.listForAgent` in
`src/modules/ticketing/messages/service/messages.service.ts` (depends on T006, T022)
- [ ] T024 [US2] Add routes in `src/modules/ticketing/messages/routes/messages.routes.ts`:
`POST /tickets/:ticketId/messages` (customer/agent both post, gated by
`fastify.authenticate`), `GET /tickets/:ticketId/messages` (customer-scoped),
`GET /agent/tickets/:ticketId/messages` (agent-scoped) — register from `src/api/routes.ts`
(depends on T023)
- [ ] T025 [US2] Run Quickstart Scenario 4 locally and confirm it passes
**Checkpoint**: Both User Story 1 and 2 work together — a created ticket now has a real,
correctly-scoped message timeline.
---
## Phase 5: User Story 3 - Attachments are safely stored and only downloadable through expiring, authorized URLs (Priority: P3)
**Goal**: Presigned-PUT upload → confirm → async scan → scan-gated presigned-GET download.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 3
- [ ] T026 [P] [US3] Unit tests for `UnimplementedPlaceholderScanner` (always resolves
`'infected'`, never throws) in `tests/unit/ticketing/malware-scanner.test.ts`
- [ ] T027 [US3] Integration test covering Quickstart Scenario 5 (upload-url → confirm → download
refused while `pending` → download refused after the placeholder scanner marks
`infected`) against a real Postgres/Redis/MinIO in
`tests/integration/ticket-attachments.test.ts`
### Implementation for User Story 3
- [ ] T028 [US3] Add `AttachmentsRepository` (create with `scanStatus: 'pending'`; findById;
`updateScanStatus`) in
`src/modules/ticketing/attachments/repository/attachments.repository.ts` (depends on T004)
- [ ] T029 [US3] Add `AttachmentsService.requestUploadUrl(ticketId, fileName, mimeType,
sizeBytes)`: validates type/size against configured limits (FR-012) before calling
`storageService.getPresignedUploadUrl` (T002) — depends on T002
- [ ] T030 [US3] Add `AttachmentsService.confirmUpload(ticketId, storageKey, fileName, mimeType,
sizeBytes, uploadedBy)`: creates the `TicketAttachment` row (T028) and enqueues a job on
`QueueName.ATTACHMENTS` via the existing `queueManager` (depends on T028)
- [ ] T031 [US3] Add `AttachmentsService.requestDownloadUrl(ticketId, attachmentId)`: returns
`storageService.getPresignedUrl` only when `scanStatus === 'clean'`, else throws `409` with
the current status (FR-013/FR-014) — depends on T028
- [ ] T032 [US3] Replace the log-only stub in `src/jobs/attachments/index.ts`: call the bound
`MalwareScanner` (T007), then `AttachmentsRepository.updateScanStatus` with the result —
depends on T007, T028
- [ ] T033 [US3] Wire `registerAttachmentWorker()` into `src/bootstrap/queue.bootstrap.ts` (it's
currently defined but never called anywhere) — depends on T032
- [ ] T034 [US3] Add routes in
`src/modules/ticketing/attachments/routes/attachments.routes.ts`:
`POST /tickets/:ticketId/attachments/upload-url`,
`POST /tickets/:ticketId/attachments/:attachmentId/confirm`,
`GET /tickets/:ticketId/attachments/:attachmentId/download-url` — register from
`src/api/routes.ts` (depends on T029, T030, T031)
- [ ] T035 [US3] Run Quickstart Scenario 5 locally and confirm it passes
**Checkpoint**: All three user stories work independently and together — a ticket now has
creation, a message timeline, and a securely-gated attachment pipeline.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [ ] T036 [P] Add a "Ticketing" section to `README.md` describing the inbound-to-ticket flow,
the status-transition contract, and the attachment pipeline (including that downloads are
permanently blocked until a real `MalwareScanner` replaces the placeholder)
- [ ] T037 [P] Update `specs/003-ticketing/checklists/requirements.md` Notes with any
implementation-time findings (e.g. concurrency edge cases discovered while testing T011)
- [ ] T038 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` to
confirm the new modules respect existing module-boundary rules
- [ ] T039 Full regression: `npm run test:unit` (scoped to `tests/unit`, per 001/002's fix) to
confirm nothing broke elsewhere
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2/US3
- **User Story 2 (Phase 4)**: Depends on Foundational + a `Ticket` existing to attach messages to
— practically sequenced after US1, though its own repository/service/routes are independent
code
- **User Story 3 (Phase 5)**: Depends on Foundational + a `Ticket` existing — independent of US2
- **Polish (Phase 6)**: Depends on all three user stories
### Parallel Opportunities
- T001/T002 (Setup)
- T005/T006/T007/T008 (independent Foundational primitives)
- T009/T010 (independent unit test files)
- T020 alongside US1's later tasks once T006 exists
- T026 alongside US1/US2's later tasks once T007 exists
- T036/T037 in Polish
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Setup + Foundational (T001-T008)
2. User Story 1 (T009-T019)
3. **STOP and VALIDATE**: Quickstart Scenarios 1, 2, 3, 6 pass — every trusted request now
produces a real, durable, idempotent, concurrency-safe ticket. This alone is a meaningful
product milestone even before messages/attachments exist.
### Incremental Delivery
1. Setup + Foundational → schema migrated, primitives tested
2. Add User Story 1 → tickets are real (MVP)
3. Add User Story 2 → tickets have a correctly-scoped conversation timeline
4. Add User Story 3 → tickets support secure attachments
5. Polish → docs and full regression