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>
14 KiB
description
| description |
|---|
| Task list for 003-ticketing |
Tasks: Ticket Creation, Messages & Attachments
Input: Design documents from specs/003-ticketing/
Prerequisites: plan.md, spec.md, research.md, data-model.md, contracts/ticket-lifecycle-contract.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
minioservice todocker-compose.test.ymlanddocker-compose.development.yml(imageminio/minio, console + API ports), and setAWS_S3_ENDPOINT/AWS_S3_BUCKET/AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYin.env.test/.env.developmentto point at it (research.md "local/test object storage") - T002 [P] Add
getPresignedUploadUrl(objectName, contentType, expirySeconds?)tosrc/infrastructure/storage/storage.service.ts, mirroring the existinggetPresignedUrl/PutObjectCommandpattern already used byuploadFile
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,TicketAttachmentmodels toprisma/schema.prismaperdata-model.md(includingTicket.version,Ticket.idempotencyKey,Ticket.customerIdFK to the existingCustomerReference,Ticket.categoryIdFK to the existingCategory) - T004 Run
npm run prisma:generateand 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): booleanfunction insrc/modules/ticketing/tickets/mapper/ticket-state-machine.tsper research.md's exact edge list - T006 [P] Define the message type→visibility constant map and a pure
isVisibleToCustomer(type): booleanfunction insrc/modules/ticketing/messages/mapper/message-visibility.tsper research.md - T007 [P] Define the
MalwareScannerinterface and the fail-closedUnimplementedPlaceholderScannerinsrc/modules/ticketing/attachments/mapper/malware-scanner.tsper research.md — logs a loud warning on every call - T008 [P] Add a ticket-code generator (
generateTicketCode(externalProductId, sequence): string) insrc/modules/ticketing/tickets/mapper/ticket-code.tsper 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) intests/unit/ticketing/ticket-state-machine.test.ts - T010 [P] [US1] Unit tests for
ticket-code.ts(format, per-product-per-year sequencing) intests/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 intests/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) insrc/modules/ticketing/tickets/repository/problems.repository.ts(depends on T004) - T013 [US1] Add
TicketsRepository(atomiccreatewith idempotency-key upsert per data-model.md's@@unique([productId, idempotencyKey]);findById;findByCode;updateStatususing theversion-based optimistic-concurrencyUPDATE ... WHERE version = ?from research.md) insrc/modules/ticketing/tickets/repository/tickets.repository.ts, replacing the old placeholderfindAllProducts-style stub (depends on T004) - T014 [US1] Add
TicketMessagesRepository.create(used internally for theSYSTEM_EVENTcreation/transition record — full messages CRUD is User Story 2) insrc/modules/ticketing/messages/repository/messages.repository.ts(depends on T004, T006) - T015 [US1] Add
TicketsService.createFromInboundRequest(reqContext, body): resolves/creates theProblem(T012), creates/fetches theTicket(T013), writes the creationSYSTEM_EVENTmessage (T014) — all synchronous within one request (FR-001) — insrc/modules/ticketing/tickets/service/tickets.service.ts(depends on T012, T013, T014) - T016 [US1] Add
TicketsService.updateStatus(ticketId, newStatus, expectedVersion, actor): validates the transition viaisValidTransition(T005), calls the repository's optimistic update, writes aSYSTEM_EVENTmessage on success, throws409 CONFLICTon version mismatch and400 INVALID_TRANSITIONon an invalid edge (depends on T005, T013, T014) - T017 [US1] Replace
src/modules/catalog/products/routes/inbound-request.routes.ts's stub handler: callTicketsService.createFromInboundRequestand respond with{ ticketId, code, status, problemId }instead of echoingreqContextback (depends on T015) - T018 [US1] Add
PATCH /tickets/:ticketId/status(body{ status, expectedVersion }) andGET /tickets/:ticketIdroutes, tenant-scoped per FR-015, insrc/modules/ticketing/tickets/routes/tickets.routes.ts, replacing the old placeholderGET /ticketslist stub; register the module's routes fromsrc/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) intests/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
TicketMessagesRepositorywithfindVisibleToCustomer(ticketId)(WHERE visibleToCustomer = true, per data-model.md's index) andfindAll(ticketId)(agent-scope) — both tenant-scoped per FR-015 (depends on T014) - T023 [US2] Add
MessagesService.post(ticketId, actor, type, body)(setsvisibleToCustomerfromisVisibleToCustomer(type)— never from request input, FR-008) and.listForCustomer/.listForAgentinsrc/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 byfastify.authenticate),GET /tickets/:ticketId/messages(customer-scoped),GET /agent/tickets/:ticketId/messages(agent-scoped) — register fromsrc/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) intests/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 marksinfected) against a real Postgres/Redis/MinIO intests/integration/ticket-attachments.test.ts
Implementation for User Story 3
- T028 [US3] Add
AttachmentsRepository(create withscanStatus: 'pending'; findById;updateScanStatus) insrc/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 callingstorageService.getPresignedUploadUrl(T002) — depends on T002 - T030 [US3] Add
AttachmentsService.confirmUpload(ticketId, storageKey, fileName, mimeType, sizeBytes, uploadedBy): creates theTicketAttachmentrow (T028) and enqueues a job onQueueName.ATTACHMENTSvia the existingqueueManager(depends on T028) - T031 [US3] Add
AttachmentsService.requestDownloadUrl(ticketId, attachmentId): returnsstorageService.getPresignedUrlonly whenscanStatus === 'clean', else throws409with 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 boundMalwareScanner(T007), thenAttachmentsRepository.updateScanStatuswith the result — depends on T007, T028 - T033 [US3] Wire
registerAttachmentWorker()intosrc/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 fromsrc/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.mddescribing the inbound-to-ticket flow, the status-transition contract, and the attachment pipeline (including that downloads are permanently blocked until a realMalwareScannerreplaces the placeholder) - T037 [P] Update
specs/003-ticketing/checklists/requirements.mdNotes with any implementation-time findings (e.g. concurrency edge cases discovered while testing T011) - T038 Run
npx tsx scripts/check-architecture.tsandnpm run lint/npm run typecheckto confirm the new modules respect existing module-boundary rules - T039 Full regression:
npm run test:unit(scoped totests/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
Ticketexisting 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
Ticketexisting — 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)
- Setup + Foundational (T001-T008)
- User Story 1 (T009-T019)
- 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
- Setup + Foundational → schema migrated, primitives tested
- Add User Story 1 → tickets are real (MVP)
- Add User Story 2 → tickets have a correctly-scoped conversation timeline
- Add User Story 3 → tickets support secure attachments
- Polish → docs and full regression