Files
support_backend/specs/003-ticketing/tasks.md
T
saqib mirandClaude Sonnet 5 2edfbacf82 feat: implement ticket creation, messages & attachments (003-ticketing)
Implements all 39 tasks from specs/003-ticketing/tasks.md across all
three user stories -- Phase 5 of the roadmap.

Schema (prisma/schema.prisma + migration):
- Ticket (code, status, version for optimistic concurrency,
  idempotencyKey, customerId FK), Problem, TicketMessage,
  TicketAttachment per docs/06, with Product/Category/
  CustomerReference back-relations.

User Story 1 -- ticket/problem creation (P1, MVP):
- Explicit 12-state lifecycle adjacency table
  (ticket-state-machine.ts), not "any transition allowed."
- Ticket code generation (<PRODUCT_CODE>-<YEAR>-<SEQUENCE>) scoped
  by the actual code prefix, not productId -- see the collision bug
  fixed below.
- Idempotency-key enforcement via atomic create-then-catch-conflict
  (never a read-then-write race), completing the FR-012 placeholder
  from 002-saas-integration.
- Explicit-reference-only recurring-problem linking (no fuzzy
  matching -- that's a future AI-support concern).
- POST /v1/support/requests (002-saas-integration) now creates a
  real ticket instead of echoing context back.
- PATCH /tickets/:id/status with expectedVersion-based optimistic
  concurrency (409 on stale version, 400 on an invalid transition).

User Story 2 -- typed messages (P2):
- Message type -> visibleToCustomer mapping is a fixed constant map,
  never caller-supplied; customer-scoped reads filter at the query
  layer so an internal note is never fetched, not just hidden.
- POST/GET /tickets/:id/messages (customer-scoped) and
  GET /agent/tickets/:id/messages (agent-scoped).

User Story 3 -- attachment pipeline (P3):
- Presigned-PUT upload (new getPresignedUploadUrl on the existing
  storageService) -- file bytes never transit this API.
- A MalwareScanner interface with a fail-closed placeholder
  (UnimplementedPlaceholderScanner) since no scanner exists in this
  stack -- it always reports 'infected', never silently 'clean'.
- The existing attachments-queue job stub now actually calls the
  scanner and updates scanStatus; registerAttachmentWorker() is
  wired into bootstrapQueue() (previously defined but never called).
- Downloads are gated on scanStatus === 'clean' -- currently always
  refused until a real scanner replaces the placeholder.
- MinIO added to docker-compose.{test,development}.yml for local/CI
  S3-compatible storage, matching doc 04's explicit guidance.

Two real bugs found and fixed via integration testing against a
live Postgres/Redis/MinIO (not just typechecked):
- Ticket codes could collide across different products: the
  sequence counter was scoped by internal productId, but the code
  column's uniqueness is global, and deriveProductCode's 4-character
  truncation means different products can share a prefix. Fixed by
  counting against the actual code prefix instead.
- Three existing 002-saas-integration integration tests' cleanup
  started failing an FK RESTRICT check once ticket creation was
  wired in (deleting a Product before the Ticket/Problem that now
  reference it). Fixed their afterAll ordering.

All 9 integration test files (24 tests, spanning this feature and
the pre-existing suite) verified passing against real Postgres,
Redis, and MinIO, including a genuine presigned-PUT/GET round trip.
Full quality gate (typecheck/lint/format/architecture/unit tests)
passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:21:37 +05:30

265 lines
14 KiB
Markdown

---
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
- [X] 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")
- [X] 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.
- [X] 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`)
- [X] T004 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T003 (depends on T003)
- [X] 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
- [X] 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
- [X] 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
- [X] 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
- [X] 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`
- [X] T010 [P] [US1] Unit tests for `ticket-code.ts` (format, per-product-per-year sequencing) in
`tests/unit/ticketing/ticket-code.test.ts`
- [X] 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
- [X] 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)
- [X] 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)
- [X] 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)
- [X] 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)
- [X] 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)
- [X] 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)
- [X] 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)
- [X] 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
- [X] 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`
- [X] 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
- [X] 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)
- [X] 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)
- [X] 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)
- [X] 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
- [X] T026 [P] [US3] Unit tests for `UnimplementedPlaceholderScanner` (always resolves
`'infected'`, never throws) in `tests/unit/ticketing/malware-scanner.test.ts`
- [X] 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
- [X] T028 [US3] Add `AttachmentsRepository` (create with `scanStatus: 'pending'`; findById;
`updateScanStatus`) in
`src/modules/ticketing/attachments/repository/attachments.repository.ts` (depends on T004)
- [X] 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
- [X] 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)
- [X] 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
- [X] 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
- [X] T033 [US3] Wire `registerAttachmentWorker()` into `src/bootstrap/queue.bootstrap.ts` (it's
currently defined but never called anywhere) — depends on T032
- [X] 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)
- [X] 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
- [X] 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)
- [X] T037 [P] Update `specs/003-ticketing/checklists/requirements.md` Notes with any
implementation-time findings (e.g. concurrency edge cases discovered while testing T011)
- [X] 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
- [X] 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