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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
954152bd7b
commit
2edfbacf82
+2
-1
@@ -25,8 +25,9 @@ JWT_REFRESH_EXPIRES=7d
|
||||
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=CHANGE_ME_64_HEX_CHARACTERS
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# AWS S3 / storage
|
||||
# AWS S3 / storage (MinIO locally — see docker-compose.development.yml)
|
||||
AWS_REGION=us-east-1
|
||||
AWS_S3_BUCKET=supporthub-attachments
|
||||
AWS_ACCESS_KEY_ID=CHANGE_ME
|
||||
AWS_SECRET_ACCESS_KEY=CHANGE_ME
|
||||
AWS_S3_ENDPOINT=http://minio:9000
|
||||
|
||||
Vendored
+4
-1
@@ -99,7 +99,7 @@ pipeline {
|
||||
|
||||
stage('Integration test') {
|
||||
steps {
|
||||
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml up -d --wait postgres redis"
|
||||
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml up -d --wait postgres redis minio"
|
||||
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm --build app npm run test:integration"
|
||||
}
|
||||
}
|
||||
@@ -214,8 +214,11 @@ REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
AWS_REGION=us-east-1
|
||||
AWS_S3_BUCKET=supporthub-attachments-${target}
|
||||
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
|
||||
${target == 'prod' ? '' : 'AWS_S3_ENDPOINT=http://minio:9000'}
|
||||
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=${INTEGRATION_CREDENTIAL_ENCRYPTION_KEY}
|
||||
CORS_ORIGINS=${target == 'prod' ? 'https://app.supporthub.com,https://admin.supporthub.com' : 'http://localhost:3000'}
|
||||
""".stripIndent().trim()
|
||||
|
||||
@@ -44,3 +44,23 @@ module; don't expose them outside a trusted network until that's implemented.
|
||||
Rate limits (`rateLimitPerMinute`, `rateLimitPerUserPerMinute`) are set per integration at
|
||||
registration time and enforced via a Redis-backed fixed-window counter, independent of the
|
||||
global `@fastify/rate-limit` floor already applied to every route.
|
||||
|
||||
# Ticketing
|
||||
A validated inbound request (see "SaaS Integration" above) creates a `Ticket` and `Problem`
|
||||
immediately — before any diagnosis. See `specs/003-ticketing/contracts/ticket-lifecycle-contract.md`
|
||||
for the full lifecycle state machine, message-visibility rules, and attachment pipeline, and
|
||||
`specs/003-ticketing/quickstart.md` for runnable scenarios.
|
||||
|
||||
- **Status transitions**: `PATCH /tickets/:ticketId/status` requires `expectedVersion` (optimistic
|
||||
concurrency — a stale version is rejected with `409`, never silently overwritten) and only
|
||||
accepts transitions defined in the state machine (`400 INVALID_TRANSITION` otherwise).
|
||||
- **Messages**: `POST/GET /tickets/:ticketId/messages` (customer-scoped — internal note types are
|
||||
never returned) and `GET /agent/tickets/:ticketId/messages` (agent-scoped — everything). A
|
||||
message's customer-visibility is always derived from its type, never caller-supplied.
|
||||
- **Attachments**: presigned-PUT upload (`POST .../attachments/upload-url` →
|
||||
`POST .../attachments/confirm`) against MinIO/S3 — file bytes never transit this API. **Nothing
|
||||
is downloadable yet** (`GET .../attachments/:attachmentId/download-url` always returns `409`):
|
||||
the malware scanner is a placeholder that fails closed until a real one
|
||||
(`src/modules/ticketing/attachments/mapper/malware-scanner.ts`) replaces it.
|
||||
- Local/test object storage is MinIO — see the `minio` service in `docker-compose.development.yml`
|
||||
/ `docker-compose.test.yml` and the `AWS_S3_ENDPOINT` value in the corresponding `.env.*` file.
|
||||
|
||||
@@ -27,6 +27,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -84,6 +86,33 @@ services:
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
|
||||
container_name: minio-development
|
||||
|
||||
command: server /data --console-address ":9001"
|
||||
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
|
||||
volumes:
|
||||
- minio_development_data:/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_development_data:
|
||||
redis_development_data:
|
||||
minio_development_data:
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -77,6 +79,27 @@ services:
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
|
||||
command: server /data --console-address ":9001"
|
||||
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
|
||||
volumes:
|
||||
- minio_test_data:/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_test_data:
|
||||
redis_test_data:
|
||||
minio_test_data:
|
||||
@@ -0,0 +1,104 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "problems" (
|
||||
"id" TEXT NOT NULL,
|
||||
"statement" TEXT NOT NULL,
|
||||
"symptoms" TEXT NOT NULL,
|
||||
"impact" TEXT,
|
||||
"productId" TEXT NOT NULL,
|
||||
"categoryId" TEXT,
|
||||
"severity" TEXT NOT NULL,
|
||||
"customerImpact" TEXT,
|
||||
"businessImpact" TEXT,
|
||||
"environment" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "problems_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tickets" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"problemId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"externalUserId" TEXT NOT NULL,
|
||||
"externalTenantId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'NEW',
|
||||
"priority" TEXT NOT NULL,
|
||||
"severity" TEXT NOT NULL,
|
||||
"categoryId" TEXT,
|
||||
"idempotencyKey" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticket_messages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"authorRef" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"visibleToCustomer" BOOLEAN NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ticket_messages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ticket_attachments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ticketId" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"sizeBytes" INTEGER NOT NULL,
|
||||
"scanStatus" TEXT NOT NULL DEFAULT 'pending',
|
||||
"uploadedBy" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ticket_attachments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tickets_code_key" ON "tickets"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tickets_productId_status_idx" ON "tickets"("productId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tickets_externalTenantId_externalUserId_idx" ON "tickets"("externalTenantId", "externalUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tickets_productId_idempotencyKey_key" ON "tickets"("productId", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ticket_messages_ticketId_visibleToCustomer_createdAt_idx" ON "ticket_messages"("ticketId", "visibleToCustomer", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "problems" ADD CONSTRAINT "problems_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "problems" ADD CONSTRAINT "problems_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customer_references"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticket_messages" ADD CONSTRAINT "ticket_messages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+92
-1
@@ -36,6 +36,8 @@ model Product {
|
||||
|
||||
categories Category[]
|
||||
integration ProductIntegration?
|
||||
problems Problem[]
|
||||
tickets Ticket[]
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
@@ -69,6 +71,8 @@ model CustomerReference {
|
||||
externalTenantId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tickets Ticket[]
|
||||
|
||||
@@unique([externalUserId, externalTenantId])
|
||||
@@map("customer_references")
|
||||
}
|
||||
@@ -81,11 +85,98 @@ model Category {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
problems Problem[]
|
||||
tickets Ticket[]
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model Problem {
|
||||
id String @id @default(cuid())
|
||||
statement String
|
||||
symptoms String
|
||||
impact String?
|
||||
productId String
|
||||
categoryId String?
|
||||
severity String
|
||||
customerImpact String?
|
||||
businessImpact String?
|
||||
environment String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
tickets Ticket[]
|
||||
|
||||
@@map("problems")
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
code String @unique // <PRODUCT_CODE>-<YEAR>-<SEQUENCE> — see
|
||||
// specs/003-ticketing/research.md "Ticket code format"
|
||||
productId String
|
||||
problemId String
|
||||
customerId String
|
||||
externalUserId String // denormalized copy of CustomerReference's field, for query
|
||||
externalTenantId String // convenience without a join — see data-model.md
|
||||
status String @default("NEW") // one of the 12 lifecycle states — see
|
||||
// specs/003-ticketing/research.md "Ticket lifecycle state machine"
|
||||
priority String
|
||||
severity String
|
||||
categoryId String?
|
||||
idempotencyKey String?
|
||||
version Int @default(1) // optimistic concurrency — see research.md
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
problem Problem @relation(fields: [problemId], references: [id])
|
||||
customer CustomerReference @relation(fields: [customerId], references: [id])
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
messages TicketMessage[]
|
||||
attachments TicketAttachment[]
|
||||
|
||||
@@unique([productId, idempotencyKey])
|
||||
@@index([productId, status])
|
||||
@@index([externalTenantId, externalUserId])
|
||||
@@map("tickets")
|
||||
}
|
||||
|
||||
model TicketMessage {
|
||||
id String @id @default(cuid())
|
||||
ticketId String
|
||||
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
|
||||
body String
|
||||
visibleToCustomer Boolean // set from the type->visibility map at write time — see
|
||||
// specs/003-ticketing/research.md "Message type -> visibility mapping"
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||
|
||||
@@index([ticketId, visibleToCustomer, createdAt])
|
||||
@@map("ticket_messages")
|
||||
}
|
||||
|
||||
model TicketAttachment {
|
||||
id String @id @default(cuid())
|
||||
ticketId String
|
||||
storageKey String // S3/MinIO object key — never the file itself
|
||||
fileName String
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
scanStatus String @default("pending") // pending | clean | infected | rejected
|
||||
uploadedBy String // agentId or externalUserId — same non-FK convention as authorRef
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id])
|
||||
|
||||
@@map("ticket_attachments")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
actor String // ProductIntegration id, agent id, "system", "ai", etc. — never a local
|
||||
|
||||
@@ -39,3 +39,31 @@
|
||||
- Malware-scanner choice and RLS adoption are left to `/speckit-plan` / business confirmation
|
||||
respectively, not decided here.
|
||||
- All items pass; no revision iterations were needed.
|
||||
|
||||
## Implementation notes (added during /speckit-implement)
|
||||
|
||||
- **Found and fixed a real cross-product ticket-code collision bug**: `deriveProductCode`
|
||||
truncates to 4 alphabetic characters, so different products can legitimately derive the same
|
||||
prefix (e.g. every test product in this repo's own test suite starts with `TEST...`, all
|
||||
deriving `"TEST"`). The initial sequence-counting query (`countForProductAndYear`) was scoped
|
||||
by internal `productId`, but the `code` column's uniqueness is global — two different products
|
||||
sharing a prefix would each independently compute sequence `1` and collide. Fixed by rescoping
|
||||
the count to the actual code prefix (`countForCodePrefix`, `WHERE code LIKE 'PREFIX-YEAR-%'`),
|
||||
which correctly reflects what the unique constraint actually guards. The existing retry-on-
|
||||
conflict loop (`isTicketCodeConflict`, `MAX_CODE_RETRIES`) still exists as the concurrency
|
||||
backstop for the rare race between two concurrent creates computing the same count-based
|
||||
sequence simultaneously — confirmed exercising this retry path for real during the verification
|
||||
run below (visible as caught-and-retried `P2002` errors in the test log, not test failures).
|
||||
- **Found and fixed a second-order issue this feature introduces for the existing 002-saas-
|
||||
integration test suite**: three of its integration tests' `afterAll` cleanup deleted
|
||||
`ProductIntegration` then `Product` directly. Now that a successful `/v1/support/requests` call
|
||||
also creates a `Ticket`/`Problem` (this feature), deleting the `Product` first failed on the
|
||||
`problems_productId_fkey` RESTRICT constraint. Fixed by adding `ticketMessage`/`ticket`/
|
||||
`problem` cleanup before the existing steps in
|
||||
`tests/integration/product-integration-auth.test.ts`,
|
||||
`tests/integration/product-integrations-admin.test.ts`, and
|
||||
`tests/integration/inbound-rate-limit.test.ts`.
|
||||
- All 9 integration test files (24 tests total, spanning both this feature and the pre-existing
|
||||
002-saas-integration suite) were run and passed against a real Postgres, Redis, and MinIO
|
||||
(temporary Docker containers) — including a real presigned-PUT upload and presigned-GET
|
||||
download round-trip against MinIO, not a mock.
|
||||
|
||||
@@ -38,11 +38,14 @@ transition.
|
||||
## Attachments
|
||||
|
||||
1. `POST /tickets/:ticketId/attachments/upload-url` — body `{ fileName, mimeType, sizeBytes }`,
|
||||
validated against configured limits (FR-012) before a presigned PUT URL is returned. No
|
||||
`TicketAttachment` row exists yet at this point.
|
||||
validated against configured limits (FR-012) before a presigned PUT URL is returned, along
|
||||
with the `storageKey` the caller must echo back in step 3. No `TicketAttachment` row exists
|
||||
yet at this point.
|
||||
2. Caller PUTs the file directly to the returned URL (file bytes never transit this API).
|
||||
3. `POST /tickets/:ticketId/attachments/:attachmentId/confirm` — creates the `TicketAttachment`
|
||||
row (`scanStatus: pending`) and enqueues the scan job on `attachments-queue`.
|
||||
3. `POST /tickets/:ticketId/attachments/confirm` — body
|
||||
`{ storageKey, fileName, mimeType, sizeBytes }` (echoing step 1's values) — creates the
|
||||
`TicketAttachment` row (`scanStatus: pending`) and enqueues the scan job on
|
||||
`attachments-queue`. No `attachmentId` exists before this call, so it isn't a path param here.
|
||||
4. `GET /tickets/:ticketId/attachments/:attachmentId/download-url` — returns a presigned GET URL
|
||||
only if `scanStatus == 'clean'`; otherwise `409` with the current scan status (FR-013/FR-014).
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ All file paths are relative to `supporthub-api/` (repo root).
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [ ] T001 Add a `minio` service to `docker-compose.test.yml` and
|
||||
- [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")
|
||||
- [ ] T002 [P] Add `getPresignedUploadUrl(objectName, contentType, expirySeconds?)` to
|
||||
- [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`
|
||||
|
||||
@@ -45,23 +45,23 @@ 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
|
||||
- [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`)
|
||||
- [ ] T004 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
|
||||
- [X] 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):
|
||||
- [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
|
||||
- [ ] T006 [P] Define the message type→visibility constant map and a pure
|
||||
- [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
|
||||
- [ ] T007 [P] Define the `MalwareScanner` interface and the fail-closed
|
||||
- [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
|
||||
- [ ] T008 [P] Add a ticket-code generator (`generateTicketCode(externalProductId, sequence):
|
||||
- [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
|
||||
@@ -78,45 +78,45 @@ instead of echoing context back; ticket status transitions are validated and con
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [ ] T009 [P] [US1] Unit tests for `ticket-state-machine.ts` (every valid edge accepted, a
|
||||
- [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`
|
||||
- [ ] T010 [P] [US1] Unit tests for `ticket-code.ts` (format, per-product-per-year sequencing) in
|
||||
- [X] 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
|
||||
- [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
|
||||
|
||||
- [ ] T012 [US1] Add `ProblemsRepository` (create; find-by-reference using an explicit prior
|
||||
- [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)
|
||||
- [ ] T013 [US1] Add `TicketsRepository` (atomic `create` with idempotency-key upsert per
|
||||
- [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)
|
||||
- [ ] T014 [US1] Add `TicketMessagesRepository.create` (used internally for the `SYSTEM_EVENT`
|
||||
- [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)
|
||||
- [ ] T015 [US1] Add `TicketsService.createFromInboundRequest(reqContext, body)`: resolves/creates
|
||||
- [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)
|
||||
- [ ] T016 [US1] Add `TicketsService.updateStatus(ticketId, newStatus, expectedVersion, actor)`:
|
||||
- [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)
|
||||
- [ ] T017 [US1] Replace `src/modules/catalog/products/routes/inbound-request.routes.ts`'s stub
|
||||
- [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)
|
||||
- [ ] T018 [US1] Add `PATCH /tickets/:ticketId/status` (body `{ status, expectedVersion }`) and
|
||||
- [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)
|
||||
- [ ] T019 [US1] Run Quickstart Scenarios 1, 2, 3, 6 locally and confirm all four pass
|
||||
- [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
|
||||
@@ -132,28 +132,28 @@ messages/attachments exist.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [ ] T020 [P] [US2] Unit tests for `message-visibility.ts` (every type maps correctly, including
|
||||
- [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`
|
||||
- [ ] T021 [US2] Integration test covering Quickstart Scenario 4 (post one of each type; confirm
|
||||
- [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
|
||||
|
||||
- [ ] T022 [US2] Extend `TicketMessagesRepository` with `findVisibleToCustomer(ticketId)`
|
||||
- [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)
|
||||
- [ ] T023 [US2] Add `MessagesService.post(ticketId, actor, type, body)` (sets
|
||||
- [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)
|
||||
- [ ] T024 [US2] Add routes in `src/modules/ticketing/messages/routes/messages.routes.ts`:
|
||||
- [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)
|
||||
- [ ] T025 [US2] Run Quickstart Scenario 4 locally and confirm it passes
|
||||
- [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.
|
||||
@@ -168,39 +168,39 @@ correctly-scoped message timeline.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [ ] T026 [P] [US3] Unit tests for `UnimplementedPlaceholderScanner` (always resolves
|
||||
- [X] 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
|
||||
- [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
|
||||
|
||||
- [ ] T028 [US3] Add `AttachmentsRepository` (create with `scanStatus: 'pending'`; findById;
|
||||
- [X] 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,
|
||||
- [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
|
||||
- [ ] T030 [US3] Add `AttachmentsService.confirmUpload(ticketId, storageKey, fileName, mimeType,
|
||||
- [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)
|
||||
- [ ] T031 [US3] Add `AttachmentsService.requestDownloadUrl(ticketId, attachmentId)`: returns
|
||||
- [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
|
||||
- [ ] T032 [US3] Replace the log-only stub in `src/jobs/attachments/index.ts`: call the bound
|
||||
- [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
|
||||
- [ ] T033 [US3] Wire `registerAttachmentWorker()` into `src/bootstrap/queue.bootstrap.ts` (it's
|
||||
- [X] 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
|
||||
- [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)
|
||||
- [ ] T035 [US3] Run Quickstart Scenario 5 locally and confirm it passes
|
||||
- [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.
|
||||
@@ -209,14 +209,14 @@ 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,
|
||||
- [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)
|
||||
- [ ] T037 [P] Update `specs/003-ticketing/checklists/requirements.md` Notes with any
|
||||
- [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)
|
||||
- [ ] T038 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` to
|
||||
- [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
|
||||
- [ ] T039 Full regression: `npm run test:unit` (scoped to `tests/unit`, per 001/002's fix) to
|
||||
- [X] T039 Full regression: `npm run test:unit` (scoped to `tests/unit`, per 001/002's fix) to
|
||||
confirm nothing broke elsewhere
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
inboundRequestRoutes,
|
||||
productIntegrationsAdminRoutes,
|
||||
} from '@/modules/catalog/products';
|
||||
import { ticketsRoutes } from '@/modules/ticketing/tickets';
|
||||
import { messagesRoutes } from '@/modules/ticketing/messages';
|
||||
import { attachmentsRoutes } from '@/modules/ticketing/attachments';
|
||||
|
||||
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
||||
await app.register(healthRoutes);
|
||||
@@ -13,5 +16,8 @@ export async function registerGlobalRoutes(app: FastifyInstance): Promise<void>
|
||||
await app.register(productsRoutes);
|
||||
await app.register(inboundRequestRoutes);
|
||||
await app.register(productIntegrationsAdminRoutes);
|
||||
await app.register(ticketsRoutes);
|
||||
await app.register(messagesRoutes);
|
||||
await app.register(attachmentsRoutes);
|
||||
// Further domain module routes will be registered here as feature modules are wired up
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { logger } from '@/infrastructure/observability';
|
||||
import { registerAttachmentWorker } from '@/jobs/attachments';
|
||||
|
||||
export async function bootstrapQueue(): Promise<void> {
|
||||
registerAttachmentWorker();
|
||||
logger.info('Queue Manager initialized.');
|
||||
// Ready to register workers as domain features are introduced
|
||||
}
|
||||
|
||||
@@ -57,6 +57,24 @@ export class StorageService {
|
||||
return getSignedUrl(this.client, command, { expiresIn: expirySeconds });
|
||||
}
|
||||
|
||||
/** Mints a presigned PUT URL so a caller can upload directly to object storage — the file's
|
||||
* bytes never transit this API process (specs/003-ticketing/research.md "Attachment pipeline
|
||||
* shape"). */
|
||||
async getPresignedUploadUrl(
|
||||
objectName: string,
|
||||
contentType: string,
|
||||
expirySeconds = 900,
|
||||
bucketName: string = this.defaultBucket,
|
||||
): Promise<string> {
|
||||
await this.ensureBucketExists(bucketName);
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectName,
|
||||
ContentType: contentType,
|
||||
});
|
||||
return getSignedUrl(this.client, command, { expiresIn: expirySeconds });
|
||||
}
|
||||
|
||||
async deleteFile(objectName: string, bucketName: string = this.defaultBucket): Promise<void> {
|
||||
const command = new DeleteObjectCommand({
|
||||
Bucket: bucketName,
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||
import { logger } from '@/infrastructure/observability';
|
||||
import { attachmentsRepository, malwareScanner } from '@/modules/ticketing/attachments';
|
||||
|
||||
interface ScanAttachmentPayload {
|
||||
attachmentId: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export function registerAttachmentWorker(): void {
|
||||
queueManager.registerWorker(QueueName.ATTACHMENTS, async (job) => {
|
||||
logger.info({ jobId: job.id, data: job.data }, 'Processing Attachment Job');
|
||||
queueManager.registerWorker<ScanAttachmentPayload>(QueueName.ATTACHMENTS, async (job) => {
|
||||
const { attachmentId, storageKey } = job.data.payload;
|
||||
logger.info({ jobId: job.id, attachmentId }, 'Scanning attachment');
|
||||
|
||||
const result = await malwareScanner.scan(storageKey);
|
||||
await attachmentsRepository.updateScanStatus(attachmentId, result);
|
||||
|
||||
logger.info({ jobId: job.id, attachmentId, result }, 'Attachment scan complete');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ticketsService } from '@/modules/ticketing/tickets';
|
||||
import { AppError } from '@/common/errors';
|
||||
|
||||
/**
|
||||
* Minimal inbound endpoint that exercises the product-integration trust boundary
|
||||
* (specs/002-saas-integration). It intentionally does nothing beyond confirming the request
|
||||
* was authenticated/scoped — acting on a trusted request (creating a ticket) belongs to the
|
||||
* ticketing feature, which doesn't exist yet.
|
||||
* The inbound endpoint that exercises the product-integration trust boundary
|
||||
* (specs/002-saas-integration) AND, per specs/003-ticketing, now actually creates the ticket —
|
||||
* "a problem creates a durable support case immediately" (docs/01/04). Acting further on the
|
||||
* ticket (AI diagnosis, orchestration) belongs to later features that don't exist yet.
|
||||
*/
|
||||
export async function inboundRequestRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post(
|
||||
'/v1/support/requests',
|
||||
{ preHandler: [fastify.authenticateProductIntegration, fastify.checkIntegrationRateLimit] },
|
||||
async (request, reply) => {
|
||||
const body = request.validatedInboundBody;
|
||||
const { productId, customerId, tenantId, actorId } = request.reqContext;
|
||||
|
||||
// Unreachable in practice: authenticateProductIntegration is the only way this handler
|
||||
// runs, and it always sets all of these on success.
|
||||
if (!body || !productId || !customerId || !tenantId || !actorId) {
|
||||
throw new AppError(
|
||||
'Request reached the handler without a fully validated context.',
|
||||
'INTERNAL_SERVER_ERROR',
|
||||
500,
|
||||
);
|
||||
}
|
||||
|
||||
const { ticket } = await ticketsService.createFromInboundRequest({
|
||||
productId,
|
||||
externalProductId: body.productId,
|
||||
customerId,
|
||||
externalUserId: actorId,
|
||||
externalTenantId: tenantId,
|
||||
problem: body.problem,
|
||||
referenceIds: body.referenceIds,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
});
|
||||
|
||||
return reply.status(202).send({
|
||||
success: true,
|
||||
data: {
|
||||
productId: request.reqContext.productId,
|
||||
customerId: request.reqContext.customerId,
|
||||
tenantId: request.reqContext.tenantId,
|
||||
ticketId: ticket.id,
|
||||
code: ticket.code,
|
||||
status: ticket.status,
|
||||
problemId: ticket.problemId,
|
||||
},
|
||||
meta: null,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
export const ATTACHMENTS_CONSTANTS = {
|
||||
MODULE_NAME: 'TICKETING_ATTACHMENTS',
|
||||
} as const;
|
||||
|
||||
// FR-012 — configured limits, admin-configurable in a future feature (Constitution Principle
|
||||
// II); a fixed default set here is a reasonable starting point per doc 04 §11's listed types.
|
||||
export const ATTACHMENT_LIMITS = {
|
||||
MAX_SIZE_BYTES: 25 * 1024 * 1024, // 25 MB
|
||||
ALLOWED_MIME_TYPES: [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'video/mp4',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
export const UPLOAD_URL_EXPIRY_SECONDS = 15 * 60;
|
||||
export const DOWNLOAD_URL_EXPIRY_SECONDS = 5 * 60;
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { attachmentsService, AttachmentsService } from '../service';
|
||||
import { requestUploadUrlSchema, confirmUploadSchema } from '../schema';
|
||||
|
||||
function actorFrom(request: FastifyRequest): string {
|
||||
return request.reqContext?.actorId ?? 'unknown';
|
||||
}
|
||||
|
||||
export class AttachmentsController {
|
||||
constructor(private readonly service: AttachmentsService = attachmentsService) {}
|
||||
|
||||
async getAttachments(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const attachments = await this.service.listAttachments();
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
data: attachments,
|
||||
meta: null,
|
||||
});
|
||||
async requestUploadUrl(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const body = requestUploadUrlSchema.parse(request.body);
|
||||
const result = await this.service.requestUploadUrl(ticketId, body);
|
||||
return reply.status(200).send({ success: true, data: result, meta: null });
|
||||
}
|
||||
|
||||
async confirmUpload(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const body = confirmUploadSchema.parse(request.body);
|
||||
const attachment = await this.service.confirmUpload(
|
||||
ticketId,
|
||||
body.storageKey,
|
||||
body.fileName,
|
||||
body.mimeType,
|
||||
body.sizeBytes,
|
||||
actorFrom(request),
|
||||
);
|
||||
return reply.status(201).send({ success: true, data: attachment, meta: null });
|
||||
}
|
||||
|
||||
async requestDownloadUrl(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId, attachmentId } = request.params as {
|
||||
ticketId: string;
|
||||
attachmentId: string;
|
||||
};
|
||||
const downloadUrl = await this.service.requestDownloadUrl(ticketId, attachmentId);
|
||||
return reply.status(200).send({ success: true, data: { downloadUrl }, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { attachmentsRoutes } from './routes';
|
||||
export { AttachmentsService, attachmentsService } from './service';
|
||||
export type { AttachmentDTO } from './types';
|
||||
export { attachmentsRepository, AttachmentsRepository } from './repository';
|
||||
export { malwareScanner, UnimplementedPlaceholderScanner } from './mapper';
|
||||
export type { MalwareScanner, ScanResult } from './mapper';
|
||||
|
||||
@@ -3,3 +3,5 @@ export class AttachmentMapper {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './malware-scanner';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { logger } from '@/infrastructure/observability';
|
||||
|
||||
export type ScanResult = 'clean' | 'infected';
|
||||
|
||||
export interface MalwareScanner {
|
||||
scan(objectKey: string): Promise<ScanResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* No malware-scanning service exists anywhere in this stack yet (specs/003-ticketing/
|
||||
* research.md "Malware scanning — no scanner exists in this stack yet"). This implementation
|
||||
* fails CLOSED — it always reports 'infected', never 'clean' — so the attachment pipeline is
|
||||
* honest that nothing is actually safe to download yet, rather than silently pretending a scan
|
||||
* happened. Replace with a real scanner (e.g. ClamAV, a cloud provider's scanning API) before
|
||||
* attachments can ever be downloaded in production.
|
||||
*/
|
||||
export class UnimplementedPlaceholderScanner implements MalwareScanner {
|
||||
async scan(objectKey: string): Promise<ScanResult> {
|
||||
logger.warn(
|
||||
{ objectKey },
|
||||
'UnimplementedPlaceholderScanner: no real malware scanner is configured — ' +
|
||||
'failing closed (reporting "infected"). Attachments cannot be downloaded until a real ' +
|
||||
'scanner replaces this placeholder.',
|
||||
);
|
||||
return 'infected';
|
||||
}
|
||||
}
|
||||
|
||||
export const malwareScanner: MalwareScanner = new UnimplementedPlaceholderScanner();
|
||||
@@ -1,10 +1,31 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { TicketAttachment } from '@prisma/client';
|
||||
|
||||
export interface CreateAttachmentData {
|
||||
ticketId: string;
|
||||
storageKey: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
uploadedBy: string;
|
||||
}
|
||||
|
||||
export class AttachmentsRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async findAll(): Promise<unknown[]> {
|
||||
return [];
|
||||
async create(data: CreateAttachmentData): Promise<TicketAttachment> {
|
||||
return this.prisma.ticketAttachment.create({ data: { ...data, scanStatus: 'pending' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TicketAttachment | null> {
|
||||
return this.prisma.ticketAttachment.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async updateScanStatus(
|
||||
id: string,
|
||||
scanStatus: 'clean' | 'infected' | 'rejected',
|
||||
): Promise<TicketAttachment> {
|
||||
return this.prisma.ticketAttachment.update({ where: { id }, data: { scanStatus } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,21 @@ import { FastifyInstance } from 'fastify';
|
||||
import { attachmentsController } from '../controller';
|
||||
|
||||
export async function attachmentsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/attachments', (req, reply) => attachmentsController.getAttachments(req, reply));
|
||||
fastify.post(
|
||||
'/tickets/:ticketId/attachments/upload-url',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => attachmentsController.requestUploadUrl(req, reply),
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
'/tickets/:ticketId/attachments/confirm',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => attachmentsController.confirmUpload(req, reply),
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/tickets/:ticketId/attachments/:attachmentId/download-url',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => attachmentsController.requestDownloadUrl(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
import { ATTACHMENT_LIMITS } from '../constants';
|
||||
|
||||
export const attachmentQuerySchema = z.object({
|
||||
ticketId: z.string().uuid().optional(),
|
||||
});
|
||||
export const requestUploadUrlSchema = z
|
||||
.object({
|
||||
fileName: z.string().min(1),
|
||||
mimeType: z.enum(ATTACHMENT_LIMITS.ALLOWED_MIME_TYPES),
|
||||
sizeBytes: z.number().int().positive().max(ATTACHMENT_LIMITS.MAX_SIZE_BYTES),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const confirmUploadSchema = z
|
||||
.object({
|
||||
storageKey: z.string().min(1),
|
||||
fileName: z.string().min(1),
|
||||
mimeType: z.enum(ATTACHMENT_LIMITS.ALLOWED_MIME_TYPES),
|
||||
sizeBytes: z.number().int().positive().max(ATTACHMENT_LIMITS.MAX_SIZE_BYTES),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type RequestUploadUrlBody = z.infer<typeof requestUploadUrlSchema>;
|
||||
export type ConfirmUploadBody = z.infer<typeof confirmUploadSchema>;
|
||||
|
||||
@@ -1,10 +1,92 @@
|
||||
import { TicketAttachment } from '@prisma/client';
|
||||
import { storageService } from '@/infrastructure/storage';
|
||||
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||
import { AppError } from '@/common/errors';
|
||||
import { generateUuid } from '@/common/utils';
|
||||
import { attachmentsRepository, AttachmentsRepository } from '../repository';
|
||||
import {
|
||||
ATTACHMENT_LIMITS,
|
||||
UPLOAD_URL_EXPIRY_SECONDS,
|
||||
DOWNLOAD_URL_EXPIRY_SECONDS,
|
||||
} from '../constants';
|
||||
|
||||
export interface UploadUrlRequest {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export class AttachmentsService {
|
||||
constructor(private readonly repo: AttachmentsRepository = attachmentsRepository) {}
|
||||
|
||||
async listAttachments(): Promise<unknown[]> {
|
||||
return this.repo.findAll();
|
||||
/** FR-012: validated before ever touching object storage. No TicketAttachment row exists yet
|
||||
* at this point — see specs/003-ticketing/contracts/ticket-lifecycle-contract.md. */
|
||||
async requestUploadUrl(
|
||||
ticketId: string,
|
||||
request: UploadUrlRequest,
|
||||
): Promise<{ uploadUrl: string; storageKey: string }> {
|
||||
if (request.sizeBytes > ATTACHMENT_LIMITS.MAX_SIZE_BYTES) {
|
||||
throw new AppError(
|
||||
'Attachment exceeds the maximum allowed size.',
|
||||
'ATTACHMENT_TOO_LARGE',
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!(ATTACHMENT_LIMITS.ALLOWED_MIME_TYPES as readonly string[]).includes(request.mimeType)) {
|
||||
throw new AppError('Attachment type is not allowed.', 'ATTACHMENT_TYPE_NOT_ALLOWED', 400);
|
||||
}
|
||||
|
||||
const storageKey = `tickets/${ticketId}/${generateUuid()}-${request.fileName}`;
|
||||
const uploadUrl = await storageService.getPresignedUploadUrl(
|
||||
storageKey,
|
||||
request.mimeType,
|
||||
UPLOAD_URL_EXPIRY_SECONDS,
|
||||
);
|
||||
return { uploadUrl, storageKey };
|
||||
}
|
||||
|
||||
/** Creates the TicketAttachment row (scanStatus: pending) and enqueues the scan job — the
|
||||
* attachment is not downloadable until that job completes with a 'clean' result. */
|
||||
async confirmUpload(
|
||||
ticketId: string,
|
||||
storageKey: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
sizeBytes: number,
|
||||
uploadedBy: string,
|
||||
): Promise<TicketAttachment> {
|
||||
const attachment = await this.repo.create({
|
||||
ticketId,
|
||||
storageKey,
|
||||
fileName,
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
uploadedBy,
|
||||
});
|
||||
|
||||
await queueManager.addJob(QueueName.ATTACHMENTS, 'scan-attachment', {
|
||||
attachmentId: attachment.id,
|
||||
storageKey: attachment.storageKey,
|
||||
});
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/** FR-013/FR-014: a presigned GET URL is only ever generated for a 'clean' attachment. */
|
||||
async requestDownloadUrl(ticketId: string, attachmentId: string): Promise<string> {
|
||||
const attachment = await this.repo.findById(attachmentId);
|
||||
if (!attachment || attachment.ticketId !== ticketId) {
|
||||
throw new AppError('Attachment not found.', 'NOT_FOUND', 404);
|
||||
}
|
||||
if (attachment.scanStatus !== 'clean') {
|
||||
throw new AppError(
|
||||
`Attachment is not available for download (scanStatus: ${attachment.scanStatus}).`,
|
||||
'ATTACHMENT_NOT_AVAILABLE',
|
||||
409,
|
||||
{ scanStatus: attachment.scanStatus },
|
||||
);
|
||||
}
|
||||
return storageService.getPresignedUrl(attachment.storageKey, DOWNLOAD_URL_EXPIRY_SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export interface AttachmentDTO {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
ticketId: string;
|
||||
storageKey: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
scanStatus: string;
|
||||
uploadedBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { messagesService, MessagesService } from '../service';
|
||||
import { postMessageSchema } from '../schema';
|
||||
|
||||
function actorFrom(request: FastifyRequest): string {
|
||||
return request.reqContext?.actorId ?? 'unknown';
|
||||
}
|
||||
|
||||
export class MessagesController {
|
||||
constructor(private readonly service: MessagesService = messagesService) {}
|
||||
|
||||
async getMessages(
|
||||
request: FastifyRequest<{ Params: { ticketId: string } }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const messages = await this.service.getMessages(request.params.ticketId);
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
data: messages,
|
||||
meta: null,
|
||||
});
|
||||
async post(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const { type, body } = postMessageSchema.parse(request.body);
|
||||
const message = await this.service.post(ticketId, actorFrom(request), type, body);
|
||||
return reply.status(201).send({ success: true, data: message, meta: null });
|
||||
}
|
||||
|
||||
async listForCustomer(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const messages = await this.service.listForCustomer(ticketId);
|
||||
return reply.status(200).send({ success: true, data: messages, meta: null });
|
||||
}
|
||||
|
||||
async listForAgent(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const messages = await this.service.listForAgent(ticketId);
|
||||
return reply.status(200).send({ success: true, data: messages, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,3 +3,5 @@ export class MessageMapper {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './message-visibility';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export const MESSAGE_TYPES = [
|
||||
'CUSTOMER_MESSAGE',
|
||||
'AI_MESSAGE',
|
||||
'AGENT_MESSAGE',
|
||||
'INTERNAL_NOTE',
|
||||
'SYSTEM_EVENT',
|
||||
'INVESTIGATION_NOTE',
|
||||
'SOLUTION_NOTE',
|
||||
] as const;
|
||||
|
||||
export type MessageType = (typeof MESSAGE_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Single source of truth for message-type visibility (specs/003-ticketing/research.md
|
||||
* "Message type -> visibility mapping"). visibleToCustomer is ALWAYS derived from this map at
|
||||
* write time — never accepted as request input (FR-008).
|
||||
*/
|
||||
const VISIBILITY: Record<MessageType, boolean> = {
|
||||
CUSTOMER_MESSAGE: true,
|
||||
AI_MESSAGE: true,
|
||||
AGENT_MESSAGE: true,
|
||||
SYSTEM_EVENT: true,
|
||||
INTERNAL_NOTE: false,
|
||||
INVESTIGATION_NOTE: false,
|
||||
SOLUTION_NOTE: false,
|
||||
};
|
||||
|
||||
export function isValidMessageType(value: string): value is MessageType {
|
||||
return (MESSAGE_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isVisibleToCustomer(type: MessageType): boolean {
|
||||
return VISIBILITY[type];
|
||||
}
|
||||
@@ -1,10 +1,36 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { TicketMessage } from '@prisma/client';
|
||||
import { MessageType } from '../mapper/message-visibility';
|
||||
|
||||
export interface CreateMessageData {
|
||||
ticketId: string;
|
||||
type: MessageType;
|
||||
authorRef: string;
|
||||
body: string;
|
||||
visibleToCustomer: boolean;
|
||||
}
|
||||
|
||||
export class MessagesRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async findByTicketId(_ticketId: string): Promise<unknown[]> {
|
||||
return [];
|
||||
async create(data: CreateMessageData): Promise<TicketMessage> {
|
||||
return this.prisma.ticketMessage.create({ data });
|
||||
}
|
||||
|
||||
/** FR-009: the query itself excludes customer-invisible rows — never fetched, so a
|
||||
* serialization bug can't leak one that was never in the result set. */
|
||||
async findVisibleToCustomer(ticketId: string): Promise<TicketMessage[]> {
|
||||
return this.prisma.ticketMessage.findMany({
|
||||
where: { ticketId, visibleToCustomer: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(ticketId: string): Promise<TicketMessage[]> {
|
||||
return this.prisma.ticketMessage.findMany({
|
||||
where: { ticketId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { messagesController } from '../controller';
|
||||
|
||||
/**
|
||||
* Routes are split by scope rather than inferred from a caller's role, since the identity
|
||||
* modules (customer session, agent session) don't exist yet — see
|
||||
* specs/002-saas-integration/contracts/inbound-request-contract.md's known
|
||||
* fastify.authenticate-is-a-stub limitation, which this inherits.
|
||||
*/
|
||||
export async function messagesRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.post('/tickets/:ticketId/messages', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
messagesController.post(req, reply),
|
||||
);
|
||||
|
||||
fastify.get('/tickets/:ticketId/messages', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
messagesController.listForCustomer(req, reply),
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
'/tickets/:ticketId/messages',
|
||||
(req: FastifyRequest<{ Params: { ticketId: string } }>, reply) =>
|
||||
messagesController.getMessages(req, reply),
|
||||
'/agent/tickets/:ticketId/messages',
|
||||
{ preHandler: fastify.authenticate },
|
||||
(req, reply) => messagesController.listForAgent(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { MESSAGE_TYPES } from '../mapper/message-visibility';
|
||||
|
||||
export const createMessageSchema = z.object({
|
||||
ticketId: z.string().uuid(),
|
||||
content: z.string().min(1),
|
||||
});
|
||||
export const postMessageSchema = z
|
||||
.object({
|
||||
type: z.enum(MESSAGE_TYPES),
|
||||
body: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type PostMessageBody = z.infer<typeof postMessageSchema>;
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import { TicketMessage } from '@prisma/client';
|
||||
import { messagesRepository, MessagesRepository } from '../repository';
|
||||
import { MessageType, isVisibleToCustomer } from '../mapper';
|
||||
|
||||
export class MessagesService {
|
||||
constructor(private readonly repo: MessagesRepository = messagesRepository) {}
|
||||
|
||||
async getMessages(ticketId: string): Promise<unknown[]> {
|
||||
return this.repo.findByTicketId(ticketId);
|
||||
/** visibleToCustomer is ALWAYS derived from the type map — never accepted as caller input
|
||||
* (FR-008). */
|
||||
async post(
|
||||
ticketId: string,
|
||||
authorRef: string,
|
||||
type: MessageType,
|
||||
body: string,
|
||||
): Promise<TicketMessage> {
|
||||
return this.repo.create({
|
||||
ticketId,
|
||||
authorRef,
|
||||
type,
|
||||
body,
|
||||
visibleToCustomer: isVisibleToCustomer(type),
|
||||
});
|
||||
}
|
||||
|
||||
async listForCustomer(ticketId: string): Promise<TicketMessage[]> {
|
||||
return this.repo.findVisibleToCustomer(ticketId);
|
||||
}
|
||||
|
||||
async listForAgent(ticketId: string): Promise<TicketMessage[]> {
|
||||
return this.repo.findAll(ticketId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export interface MessageDTO {
|
||||
id: string;
|
||||
ticketId: string;
|
||||
content: string;
|
||||
senderId: string;
|
||||
type: string;
|
||||
authorRef: string;
|
||||
body: string;
|
||||
visibleToCustomer: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ticketsService, TicketsService } from '../service';
|
||||
import { updateTicketStatusSchema } from '../schema';
|
||||
|
||||
function actorFrom(request: FastifyRequest): string {
|
||||
return request.reqContext?.actorId ?? 'unknown';
|
||||
}
|
||||
|
||||
export class TicketsController {
|
||||
constructor(private readonly service: TicketsService = ticketsService) {}
|
||||
|
||||
async getTickets(_request: FastifyRequest, reply: FastifyReply) {
|
||||
const tickets = await this.service.listTickets();
|
||||
return reply.status(200).send({
|
||||
success: true,
|
||||
data: tickets,
|
||||
meta: null,
|
||||
});
|
||||
async getById(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const ticket = await this.service.getById(ticketId);
|
||||
return reply.status(200).send({ success: true, data: ticket, meta: null });
|
||||
}
|
||||
|
||||
async updateStatus(request: FastifyRequest, reply: FastifyReply) {
|
||||
const { ticketId } = request.params as { ticketId: string };
|
||||
const { status, expectedVersion } = updateTicketStatusSchema.parse(request.body);
|
||||
const ticket = await this.service.updateStatus(
|
||||
ticketId,
|
||||
status,
|
||||
expectedVersion,
|
||||
actorFrom(request),
|
||||
);
|
||||
return reply.status(200).send({ success: true, data: ticket, meta: null });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { ticketsRoutes } from './routes';
|
||||
export { TicketsService, ticketsService } from './service';
|
||||
export type { CreateTicketInput, TicketFilters } from './types';
|
||||
export type { InboundTicketRequest } from './service';
|
||||
export type { TicketDTO } from './types';
|
||||
export { isValidTicketStatus, isValidTransition, TICKET_STATUSES } from './mapper';
|
||||
export type { TicketStatus } from './mapper';
|
||||
|
||||
@@ -3,3 +3,6 @@ export class TicketMapper {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './ticket-state-machine';
|
||||
export * from './ticket-code';
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* <PRODUCT_CODE>-<YEAR>-<SEQUENCE> per specs/003-ticketing/research.md "Ticket code format",
|
||||
* matching doc 01/04's own running example (e.g. DQB-2026-00567).
|
||||
*/
|
||||
export function deriveProductCode(externalProductId: string): string {
|
||||
const alphabetic = externalProductId.replace(/[^a-zA-Z]/g, '').toUpperCase();
|
||||
return (alphabetic || 'GEN').slice(0, 4);
|
||||
}
|
||||
|
||||
export function generateTicketCode(
|
||||
externalProductId: string,
|
||||
sequence: number,
|
||||
year: number = new Date().getFullYear(),
|
||||
): string {
|
||||
const productCode = deriveProductCode(externalProductId);
|
||||
const paddedSequence = String(sequence).padStart(5, '0');
|
||||
return `${productCode}-${year}-${paddedSequence}`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const TICKET_STATUSES = [
|
||||
'NEW',
|
||||
'AI_ANALYZING',
|
||||
'AI_TROUBLESHOOTING',
|
||||
'AI_VERIFYING',
|
||||
'AI_RESOLVED',
|
||||
'HUMAN_ESCALATION',
|
||||
'IN_PROGRESS',
|
||||
'WAITING_FOR_CUSTOMER',
|
||||
'RESOLUTION_PENDING_CUSTOMER',
|
||||
'RESOLVED',
|
||||
'CLOSED',
|
||||
'REOPENED',
|
||||
] as const;
|
||||
|
||||
export type TicketStatus = (typeof TICKET_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Explicit adjacency table for the ticket lifecycle — see
|
||||
* specs/003-ticketing/research.md "Ticket lifecycle state machine" for the rationale behind
|
||||
* each edge (read from docs/04's described flows). A status with no listed edges is terminal
|
||||
* unless a specific status can be reached (only REOPENED can be reached from CLOSED).
|
||||
*/
|
||||
const TRANSITIONS: Record<TicketStatus, readonly TicketStatus[]> = {
|
||||
NEW: ['AI_ANALYZING', 'HUMAN_ESCALATION'],
|
||||
AI_ANALYZING: ['AI_TROUBLESHOOTING', 'HUMAN_ESCALATION'],
|
||||
AI_TROUBLESHOOTING: ['AI_VERIFYING', 'HUMAN_ESCALATION'],
|
||||
AI_VERIFYING: ['AI_RESOLVED', 'HUMAN_ESCALATION'],
|
||||
AI_RESOLVED: ['RESOLUTION_PENDING_CUSTOMER', 'RESOLVED', 'HUMAN_ESCALATION'],
|
||||
HUMAN_ESCALATION: ['IN_PROGRESS'],
|
||||
IN_PROGRESS: ['WAITING_FOR_CUSTOMER', 'RESOLUTION_PENDING_CUSTOMER', 'HUMAN_ESCALATION'],
|
||||
WAITING_FOR_CUSTOMER: ['IN_PROGRESS'],
|
||||
RESOLUTION_PENDING_CUSTOMER: ['RESOLVED', 'IN_PROGRESS'],
|
||||
RESOLVED: ['CLOSED', 'REOPENED'],
|
||||
CLOSED: ['REOPENED'],
|
||||
REOPENED: ['IN_PROGRESS', 'AI_ANALYZING'],
|
||||
};
|
||||
|
||||
export function isValidTicketStatus(value: string): value is TicketStatus {
|
||||
return (TICKET_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isValidTransition(from: TicketStatus, to: TicketStatus): boolean {
|
||||
return TRANSITIONS[from].includes(to);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './tickets.repository';
|
||||
export * from './problems.repository';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { Problem } from '@prisma/client';
|
||||
|
||||
export interface CreateProblemInput {
|
||||
statement: string;
|
||||
symptoms: string;
|
||||
impact?: string;
|
||||
productId: string;
|
||||
categoryId?: string;
|
||||
severity: string;
|
||||
customerImpact?: string;
|
||||
businessImpact?: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
export class ProblemsRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async create(data: CreateProblemInput): Promise<Problem> {
|
||||
return this.prisma.problem.create({ data });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Problem | null> {
|
||||
return this.prisma.problem.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
/** specs/003-ticketing/research.md "Recurring-problem linking" — an explicit caller-supplied
|
||||
* reference (a prior ticket code or problem id) is the only recognized way to link to an
|
||||
* existing Problem; no fuzzy/semantic matching is attempted here. */
|
||||
async findByReference(referenceIds: string[]): Promise<Problem | null> {
|
||||
if (referenceIds.length === 0) return null;
|
||||
|
||||
const byProblemId = await this.prisma.problem.findFirst({
|
||||
where: { id: { in: referenceIds } },
|
||||
});
|
||||
if (byProblemId) return byProblemId;
|
||||
|
||||
const byTicketCode = await this.prisma.ticket.findFirst({
|
||||
where: { code: { in: referenceIds } },
|
||||
include: { problem: true },
|
||||
});
|
||||
return byTicketCode?.problem ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export const problemsRepository = new ProblemsRepository();
|
||||
@@ -1,10 +1,103 @@
|
||||
import { Prisma, Ticket } from '@prisma/client';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
|
||||
export interface CreateTicketData {
|
||||
code: string;
|
||||
productId: string;
|
||||
problemId: string;
|
||||
customerId: string;
|
||||
externalUserId: string;
|
||||
externalTenantId: string;
|
||||
priority: string;
|
||||
severity: string;
|
||||
categoryId?: string;
|
||||
}
|
||||
|
||||
function isIdempotencyKeyConflict(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002' &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
(error.meta?.target as string[]).includes('idempotencyKey')
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a create failed on the `code` unique constraint — a rare race between two
|
||||
* concurrent creates computing the same sequence number. The service layer retries with a
|
||||
* bumped sequence rather than this repository owning retry policy. */
|
||||
export function isTicketCodeConflict(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002' &&
|
||||
Array.isArray(error.meta?.target) &&
|
||||
(error.meta?.target as string[]).includes('code')
|
||||
);
|
||||
}
|
||||
|
||||
export class TicketsRepository {
|
||||
constructor(private readonly prisma = prismaClient) {}
|
||||
|
||||
async findAll(): Promise<unknown[]> {
|
||||
return [];
|
||||
async findById(id: string): Promise<Ticket | null> {
|
||||
return this.prisma.ticket.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Ticket | null> {
|
||||
return this.prisma.ticket.findUnique({ where: { code } });
|
||||
}
|
||||
|
||||
/** Count of existing tickets sharing this exact code prefix (e.g. "DQB-2026-") — used to
|
||||
* derive the next sequence number. Scoped by the CODE prefix, not by productId: the `code`
|
||||
* column has a single global unique constraint, and multiple different products can derive
|
||||
* the same 4-character prefix (ticket-code.ts's deriveProductCode truncates), so counting by
|
||||
* productId alone under-counts and causes collisions across products sharing a prefix. Not
|
||||
* itself concurrency-safe (see tickets.service.ts's retry loop, which handles a code
|
||||
* collision the same way idempotency-key collisions are handled). */
|
||||
async countForCodePrefix(codePrefix: string): Promise<number> {
|
||||
return this.prisma.ticket.count({ where: { code: { startsWith: codePrefix } } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ticket. When `idempotencyKey` is provided and a ticket already exists for
|
||||
* (productId, idempotencyKey), returns the EXISTING ticket instead of creating a new one —
|
||||
* race-safe under concurrent retries, since it's the database's own unique constraint (not a
|
||||
* read-then-write check) that decides who wins (specs/003-ticketing/research.md "Idempotency-
|
||||
* key enforcement"). When no key is provided, always creates a new ticket — there is nothing
|
||||
* to deduplicate against.
|
||||
*/
|
||||
async createOrFindByIdempotencyKey(
|
||||
data: CreateTicketData,
|
||||
idempotencyKey?: string,
|
||||
): Promise<{ ticket: Ticket; wasExisting: boolean }> {
|
||||
try {
|
||||
const ticket = await this.prisma.ticket.create({
|
||||
data: { ...data, idempotencyKey: idempotencyKey ?? null },
|
||||
});
|
||||
return { ticket, wasExisting: false };
|
||||
} catch (error) {
|
||||
if (idempotencyKey && isIdempotencyKeyConflict(error)) {
|
||||
const existing = await this.prisma.ticket.findUnique({
|
||||
where: { productId_idempotencyKey: { productId: data.productId, idempotencyKey } },
|
||||
});
|
||||
if (existing) return { ticket: existing, wasExisting: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic concurrency: succeeds only if `expectedVersion` still matches the row's current
|
||||
* version (specs/003-ticketing/research.md). Returns null on a stale-version mismatch or a
|
||||
* missing ticket — the caller distinguishes those cases itself. */
|
||||
async updateStatus(
|
||||
id: string,
|
||||
newStatus: string,
|
||||
expectedVersion: number,
|
||||
): Promise<Ticket | null> {
|
||||
const result = await this.prisma.ticket.updateMany({
|
||||
where: { id, version: expectedVersion },
|
||||
data: { status: newStatus, version: { increment: 1 } },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
return this.prisma.ticket.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,11 @@ import { FastifyInstance } from 'fastify';
|
||||
import { ticketsController } from '../controller';
|
||||
|
||||
export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||
fastify.get('/tickets', (req, reply) => ticketsController.getTickets(req, reply));
|
||||
fastify.get('/tickets/:ticketId', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
ticketsController.getById(req, reply),
|
||||
);
|
||||
|
||||
fastify.patch('/tickets/:ticketId/status', { preHandler: fastify.authenticate }, (req, reply) =>
|
||||
ticketsController.updateStatus(req, reply),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { TICKET_STATUSES } from '../mapper/ticket-state-machine';
|
||||
|
||||
export const createTicketSchema = z.object({
|
||||
title: z.string().min(3),
|
||||
description: z.string().min(5),
|
||||
productId: z.string().uuid(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
});
|
||||
export const updateTicketStatusSchema = z
|
||||
.object({
|
||||
status: z.enum(TICKET_STATUSES),
|
||||
expectedVersion: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type UpdateTicketStatusBody = z.infer<typeof updateTicketStatusSchema>;
|
||||
|
||||
@@ -1,10 +1,147 @@
|
||||
import { ticketsRepository, TicketsRepository } from '../repository';
|
||||
import { Ticket } from '@prisma/client';
|
||||
import { AppError } from '@/common/errors';
|
||||
import {
|
||||
ticketsRepository,
|
||||
TicketsRepository,
|
||||
problemsRepository,
|
||||
ProblemsRepository,
|
||||
isTicketCodeConflict,
|
||||
} from '../repository';
|
||||
import { generateTicketCode, deriveProductCode } from '../mapper/ticket-code';
|
||||
import {
|
||||
isValidTicketStatus,
|
||||
isValidTransition,
|
||||
TicketStatus,
|
||||
} from '../mapper/ticket-state-machine';
|
||||
import { messagesService, MessagesService } from '@/modules/ticketing/messages';
|
||||
|
||||
export interface InboundTicketRequest {
|
||||
productId: string; // internal Product.id (already resolved by the caller)
|
||||
externalProductId: string;
|
||||
customerId: string; // CustomerReference.id
|
||||
externalUserId: string;
|
||||
externalTenantId: string;
|
||||
problem: string;
|
||||
referenceIds?: string[] | undefined;
|
||||
idempotencyKey?: string | undefined;
|
||||
}
|
||||
|
||||
const MAX_CODE_RETRIES = 5;
|
||||
|
||||
export class TicketsService {
|
||||
constructor(private readonly repo: TicketsRepository = ticketsRepository) {}
|
||||
constructor(
|
||||
private readonly ticketsRepo: TicketsRepository = ticketsRepository,
|
||||
private readonly problemsRepo: ProblemsRepository = problemsRepository,
|
||||
private readonly messages: MessagesService = messagesService,
|
||||
) {}
|
||||
|
||||
async listTickets(): Promise<unknown[]> {
|
||||
return this.repo.findAll();
|
||||
/**
|
||||
* FR-001/FR-002/FR-003/FR-004: creates (or, on idempotency-key reuse, fetches) a Ticket and
|
||||
* its Problem, synchronously within the caller's request — see
|
||||
* specs/003-ticketing/data-model.md "Inbound Request -> Ticket Creation".
|
||||
*/
|
||||
async createFromInboundRequest(
|
||||
input: InboundTicketRequest,
|
||||
): Promise<{ ticket: Ticket; wasExisting: boolean }> {
|
||||
const problem =
|
||||
(input.referenceIds?.length
|
||||
? await this.problemsRepo.findByReference(input.referenceIds)
|
||||
: null) ??
|
||||
(await this.problemsRepo.create({
|
||||
statement: input.problem,
|
||||
symptoms: input.problem,
|
||||
productId: input.productId,
|
||||
severity: 'medium',
|
||||
}));
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const codePrefix = `${deriveProductCode(input.externalProductId)}-${year}-`;
|
||||
let attempt = 0;
|
||||
let sequence = (await this.ticketsRepo.countForCodePrefix(codePrefix)) + 1;
|
||||
|
||||
for (;;) {
|
||||
const code = generateTicketCode(input.externalProductId, sequence, year);
|
||||
try {
|
||||
const { ticket, wasExisting } = await this.ticketsRepo.createOrFindByIdempotencyKey(
|
||||
{
|
||||
code,
|
||||
productId: input.productId,
|
||||
problemId: problem.id,
|
||||
customerId: input.customerId,
|
||||
externalUserId: input.externalUserId,
|
||||
externalTenantId: input.externalTenantId,
|
||||
priority: 'medium',
|
||||
severity: 'medium',
|
||||
},
|
||||
input.idempotencyKey,
|
||||
);
|
||||
|
||||
if (!wasExisting) {
|
||||
await this.messages.post(
|
||||
ticket.id,
|
||||
'system',
|
||||
'SYSTEM_EVENT',
|
||||
`Ticket created (status: ${ticket.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ticket, wasExisting };
|
||||
} catch (error) {
|
||||
if (isTicketCodeConflict(error) && attempt < MAX_CODE_RETRIES) {
|
||||
attempt += 1;
|
||||
sequence += 1;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getById(ticketId: string): Promise<Ticket> {
|
||||
const ticket = await this.ticketsRepo.findById(ticketId);
|
||||
if (!ticket) throw new AppError('Ticket not found.', 'NOT_FOUND', 404);
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/** FR-006/FR-007: validates the transition against the state machine and applies it only if
|
||||
* `expectedVersion` still matches (optimistic concurrency) — see
|
||||
* specs/003-ticketing/research.md. */
|
||||
async updateStatus(
|
||||
ticketId: string,
|
||||
newStatus: string,
|
||||
expectedVersion: number,
|
||||
actor: string,
|
||||
): Promise<Ticket> {
|
||||
if (!isValidTicketStatus(newStatus)) {
|
||||
throw new AppError(`Unknown status: ${newStatus}.`, 'INVALID_STATUS', 400);
|
||||
}
|
||||
|
||||
const current = await this.getById(ticketId);
|
||||
if (!isValidTransition(current.status as TicketStatus, newStatus)) {
|
||||
throw new AppError(
|
||||
`Cannot transition from ${current.status} to ${newStatus}.`,
|
||||
'INVALID_TRANSITION',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.ticketsRepo.updateStatus(ticketId, newStatus, expectedVersion);
|
||||
if (!updated) {
|
||||
throw new AppError(
|
||||
'Ticket was modified by another request — refresh and retry.',
|
||||
'CONFLICT',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
await this.messages.post(
|
||||
ticketId,
|
||||
actor,
|
||||
'SYSTEM_EVENT',
|
||||
`Status changed from ${current.status} to ${newStatus}.`,
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export interface CreateTicketInput {
|
||||
title: string;
|
||||
description: string;
|
||||
export interface TicketDTO {
|
||||
id: string;
|
||||
code: string;
|
||||
productId: string;
|
||||
categoryId?: string;
|
||||
}
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
problemId: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
severity: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ describe('Inbound rate limiting', () => {
|
||||
const externalProductId = `TEST_RATELIMIT_PROD_${Date.now()}`;
|
||||
|
||||
afterAll(async () => {
|
||||
// A successful request now also creates a Ticket/Problem (specs/003-ticketing) — those
|
||||
// must be cleaned up before the Product they reference, or the FK RESTRICT blocks it.
|
||||
await prismaClient.ticketMessage.deleteMany({
|
||||
where: { ticket: { product: { externalProductId } } },
|
||||
});
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.problem.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId } },
|
||||
});
|
||||
@@ -111,6 +118,13 @@ describe('Inbound rate limiting', () => {
|
||||
expect(sixth.statusCode).toBe(429);
|
||||
expect(sixth.json().error.code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
|
||||
await prismaClient.ticketMessage.deleteMany({
|
||||
where: { ticket: { product: { externalProductId: productId } } },
|
||||
});
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId: productId } } });
|
||||
await prismaClient.problem.deleteMany({
|
||||
where: { product: { externalProductId: productId } },
|
||||
});
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId: productId } },
|
||||
});
|
||||
|
||||
@@ -40,6 +40,13 @@ describe('Product Integration Auth (full preHandler)', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// A successful request now also creates a Ticket/Problem (specs/003-ticketing) — those
|
||||
// must be cleaned up before the Product they reference, or the FK RESTRICT blocks it.
|
||||
await prismaClient.ticketMessage.deleteMany({
|
||||
where: { ticket: { product: { externalProductId } } },
|
||||
});
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.problem.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId } },
|
||||
});
|
||||
|
||||
@@ -15,6 +15,13 @@ describe('Product Integration Admin Lifecycle', () => {
|
||||
const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`;
|
||||
|
||||
afterAll(async () => {
|
||||
// A successful request now also creates a Ticket/Problem (specs/003-ticketing) — those
|
||||
// must be cleaned up before the Product they reference, or the FK RESTRICT blocks it.
|
||||
await prismaClient.ticketMessage.deleteMany({
|
||||
where: { ticket: { product: { externalProductId } } },
|
||||
});
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.problem.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId } },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { malwareScanner } from '@/modules/ticketing/attachments';
|
||||
|
||||
/** Covers specs/003-ticketing/quickstart.md Scenario 5 against a real Postgres/Redis/MinIO. */
|
||||
describe('Ticket attachments — upload, confirm, scan-gated download', () => {
|
||||
let app: FastifyInstance;
|
||||
let ticketId: string;
|
||||
const externalProductId = `TEST_ATT_PROD_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Attachments Test Product', status: 'active' },
|
||||
});
|
||||
const secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: 'attachment pipeline check',
|
||||
},
|
||||
});
|
||||
ticketId = created.json().data.ticketId;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.ticketAttachment.deleteMany({ where: { ticketId } });
|
||||
await prismaClient.ticketMessage.deleteMany({ where: { ticketId } });
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.problem.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId } },
|
||||
});
|
||||
await prismaClient.product.deleteMany({ where: { externalProductId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects an oversized upload before it ever reaches object storage', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/attachments/upload-url`,
|
||||
payload: { fileName: 'huge.pdf', mimeType: 'application/pdf', sizeBytes: 999_999_999 },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects a disallowed mime type', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/attachments/upload-url`,
|
||||
payload: { fileName: 'evil.exe', mimeType: 'application/x-msdownload', sizeBytes: 100 },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('an uploaded attachment is refused for download while pending, and stays refused after the placeholder scanner marks it infected', async () => {
|
||||
const uploadUrlResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/attachments/upload-url`,
|
||||
payload: { fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 },
|
||||
});
|
||||
expect(uploadUrlResponse.statusCode).toBe(200);
|
||||
const { uploadUrl, storageKey } = uploadUrlResponse.json().data;
|
||||
|
||||
const putResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
body: Buffer.from('fake-png-bytes'),
|
||||
});
|
||||
expect(putResponse.ok).toBe(true);
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/attachments/confirm`,
|
||||
payload: { storageKey, fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 },
|
||||
});
|
||||
expect(confirmResponse.statusCode).toBe(201);
|
||||
const attachmentId = confirmResponse.json().data.id;
|
||||
expect(confirmResponse.json().data.scanStatus).toBe('pending');
|
||||
|
||||
const downloadWhilePending = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
|
||||
});
|
||||
expect(downloadWhilePending.statusCode).toBe(409);
|
||||
|
||||
// Run the scan job inline (no worker process in this test) — same call the queue worker
|
||||
// makes, per src/jobs/attachments/index.ts.
|
||||
const result = await malwareScanner.scan(storageKey);
|
||||
await prismaClient.ticketAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { scanStatus: result },
|
||||
});
|
||||
|
||||
const downloadAfterScan = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
|
||||
});
|
||||
// The placeholder scanner fails closed (always 'infected'), so this remains refused —
|
||||
// proving the pipeline actually gates on a real scan result rather than defaulting open.
|
||||
expect(downloadAfterScan.statusCode).toBe(409);
|
||||
|
||||
const attachment = await prismaClient.ticketAttachment.findUniqueOrThrow({
|
||||
where: { id: attachmentId },
|
||||
});
|
||||
expect(attachment.scanStatus).toBe('infected');
|
||||
});
|
||||
|
||||
it('a clean attachment produces a working, time-limited download URL', async () => {
|
||||
const uploadUrlResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/attachments/upload-url`,
|
||||
payload: { fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
|
||||
});
|
||||
const { uploadUrl, storageKey } = uploadUrlResponse.json().data;
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/pdf' },
|
||||
body: Buffer.from('fake-pdf-bytes'),
|
||||
});
|
||||
|
||||
const confirmResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/attachments/confirm`,
|
||||
payload: { storageKey, fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
|
||||
});
|
||||
const attachmentId = confirmResponse.json().data.id;
|
||||
|
||||
// Simulate a real scanner reporting clean, bypassing the fail-closed placeholder directly
|
||||
// at the repository layer — this test's job is to prove the DOWNLOAD gate respects
|
||||
// scanStatus, not to re-test the placeholder scanner itself (covered by
|
||||
// tests/unit/ticketing/malware-scanner.test.ts).
|
||||
await prismaClient.ticketAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { scanStatus: 'clean' },
|
||||
});
|
||||
|
||||
const downloadResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
|
||||
});
|
||||
expect(downloadResponse.statusCode).toBe(200);
|
||||
const { downloadUrl } = downloadResponse.json().data;
|
||||
expect(downloadUrl).toContain(storageKey);
|
||||
|
||||
const fetched = await fetch(downloadUrl);
|
||||
expect(fetched.ok).toBe(true);
|
||||
const body = await fetched.text();
|
||||
expect(body).toBe('fake-pdf-bytes');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
|
||||
/**
|
||||
* Covers specs/003-ticketing/quickstart.md Scenarios 1, 2, 3, 6 end-to-end against a real
|
||||
* Postgres/Redis.
|
||||
*/
|
||||
describe('Ticket creation via the inbound trust boundary', () => {
|
||||
let app: FastifyInstance;
|
||||
let secret: string;
|
||||
const externalProductId = `TEST_TICKET_PROD_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Ticket Test Product', status: 'active' },
|
||||
});
|
||||
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.ticketMessage.deleteMany({
|
||||
where: { ticket: { product: { externalProductId } } },
|
||||
});
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.problem.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId } },
|
||||
});
|
||||
await prismaClient.product.deleteMany({ where: { externalProductId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function send(payload: Record<string, unknown>, userId = 'user-1') {
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId,
|
||||
});
|
||||
return app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId,
|
||||
source: 'test',
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('Scenario 1: creates a ticket and problem immediately', async () => {
|
||||
const response = await send({ problem: 'PDF to HTML conversion failed' });
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json().data;
|
||||
expect(body.status).toBe('NEW');
|
||||
expect(body.ticketId).toBeDefined();
|
||||
expect(body.code).toMatch(/^[A-Z]+-\d{4}-\d{5}$/);
|
||||
|
||||
const ticket = await prismaClient.ticket.findUnique({ where: { id: body.ticketId } });
|
||||
expect(ticket).not.toBeNull();
|
||||
const problem = await prismaClient.problem.findUnique({ where: { id: body.problemId } });
|
||||
expect(problem).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Scenario 2: a retried idempotency key returns the same ticket, never a second one', async () => {
|
||||
const idempotencyKey = `idem-${Date.now()}`;
|
||||
const first = await send({ problem: 'duplicate check', idempotencyKey });
|
||||
const second = await send({ problem: 'duplicate check', idempotencyKey });
|
||||
|
||||
expect(first.statusCode).toBe(202);
|
||||
expect(second.statusCode).toBe(202);
|
||||
expect(first.json().data.ticketId).toBe(second.json().data.ticketId);
|
||||
|
||||
const count = await prismaClient.ticket.count({
|
||||
where: { product: { externalProductId }, idempotencyKey },
|
||||
});
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('Scenario 3: an explicit reference links to the existing Problem instead of creating a new one', async () => {
|
||||
const first = await send({ problem: 'recurring problem' });
|
||||
const firstBody = first.json().data;
|
||||
|
||||
const second = await send({
|
||||
problem: 'recurring problem, again',
|
||||
referenceIds: [firstBody.problemId],
|
||||
});
|
||||
const secondBody = second.json().data;
|
||||
|
||||
expect(secondBody.problemId).toBe(firstBody.problemId);
|
||||
expect(secondBody.ticketId).not.toBe(firstBody.ticketId);
|
||||
});
|
||||
|
||||
it('Scenario 6: a stale-version status update is rejected, not silently overwritten', async () => {
|
||||
const created = await send({ problem: 'concurrency check' });
|
||||
const { ticketId } = created.json().data;
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
payload: { status: 'AI_ANALYZING', expectedVersion: ticket.version },
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
|
||||
});
|
||||
|
||||
const results = [first.statusCode, second.statusCode].sort();
|
||||
expect(results).toEqual([200, 409]);
|
||||
|
||||
const finalTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
expect(finalTicket.version).toBe(ticket.version + 1);
|
||||
});
|
||||
|
||||
it('rejects an invalid status transition', async () => {
|
||||
const created = await send({ problem: 'invalid transition check' });
|
||||
const { ticketId } = created.json().data;
|
||||
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/tickets/${ticketId}/status`,
|
||||
payload: { status: 'RESOLVED', expectedVersion: ticket.version },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.code).toBe('INVALID_TRANSITION');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildApp } from '@/app';
|
||||
import { prismaClient } from '@/infrastructure/database';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
encryptCredential,
|
||||
generateCredentialSecret,
|
||||
issueIntegrationToken,
|
||||
} from '@/modules/catalog/products';
|
||||
import { MESSAGE_TYPES } from '@/modules/ticketing/messages/mapper/message-visibility';
|
||||
|
||||
/** Covers specs/003-ticketing/quickstart.md Scenario 4 against a real Postgres. */
|
||||
describe('Ticket messages — type-scoped visibility', () => {
|
||||
let app: FastifyInstance;
|
||||
let ticketId: string;
|
||||
const externalProductId = `TEST_MSG_PROD_${Date.now()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Messages Test Product', status: 'active' },
|
||||
});
|
||||
const secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const token = issueIntegrationToken(secret, {
|
||||
externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/support/requests',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
productId: externalProductId,
|
||||
tenantId: 'tenant-1',
|
||||
userId: 'user-1',
|
||||
source: 'test',
|
||||
problem: 'message visibility check',
|
||||
},
|
||||
});
|
||||
ticketId = created.json().data.ticketId;
|
||||
|
||||
for (const type of MESSAGE_TYPES) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/messages`,
|
||||
payload: { type, body: `Message of type ${type}` },
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.ticketMessage.deleteMany({ where: { ticketId } });
|
||||
await prismaClient.ticket.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.problem.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.productIntegration.deleteMany({
|
||||
where: { product: { externalProductId } },
|
||||
});
|
||||
await prismaClient.product.deleteMany({ where: { externalProductId } });
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('a customer-scoped read excludes internal-only types entirely', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/messages` });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const types = response.json().data.map((m: { type: string }) => m.type);
|
||||
|
||||
expect(types).toContain('CUSTOMER_MESSAGE');
|
||||
expect(types).toContain('AI_MESSAGE');
|
||||
expect(types).toContain('AGENT_MESSAGE');
|
||||
expect(types).toContain('SYSTEM_EVENT');
|
||||
expect(types).not.toContain('INTERNAL_NOTE');
|
||||
expect(types).not.toContain('INVESTIGATION_NOTE');
|
||||
expect(types).not.toContain('SOLUTION_NOTE');
|
||||
});
|
||||
|
||||
it('an agent-scoped read includes every message type, including internal notes', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/agent/tickets/${ticketId}/messages`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const types = response.json().data.map((m: { type: string }) => m.type);
|
||||
|
||||
for (const type of MESSAGE_TYPES) {
|
||||
expect(types).toContain(type);
|
||||
}
|
||||
// +1 for the SYSTEM_EVENT ticket-creation message written by TicketsService itself.
|
||||
expect(types.length).toBe(MESSAGE_TYPES.length + 1);
|
||||
});
|
||||
|
||||
it('rejects a message with an undefined type', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/tickets/${ticketId}/messages`,
|
||||
payload: { type: 'NOT_A_REAL_TYPE', body: 'x' },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UnimplementedPlaceholderScanner } from '@/modules/ticketing/attachments/mapper/malware-scanner';
|
||||
|
||||
describe('UnimplementedPlaceholderScanner', () => {
|
||||
it('always resolves "infected", never "clean" (fails closed)', async () => {
|
||||
const scanner = new UnimplementedPlaceholderScanner();
|
||||
const result = await scanner.scan('some/object-key.pdf');
|
||||
expect(result).toBe('infected');
|
||||
});
|
||||
|
||||
it('does not throw', async () => {
|
||||
const scanner = new UnimplementedPlaceholderScanner();
|
||||
await expect(scanner.scan('another-key')).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
isVisibleToCustomer,
|
||||
isValidMessageType,
|
||||
} from '@/modules/ticketing/messages/mapper/message-visibility';
|
||||
|
||||
describe('Message type visibility mapping', () => {
|
||||
it('maps every defined type to a fixed, non-overridable visibility', () => {
|
||||
expect(isVisibleToCustomer('CUSTOMER_MESSAGE')).toBe(true);
|
||||
expect(isVisibleToCustomer('AI_MESSAGE')).toBe(true);
|
||||
expect(isVisibleToCustomer('AGENT_MESSAGE')).toBe(true);
|
||||
expect(isVisibleToCustomer('SYSTEM_EVENT')).toBe(true);
|
||||
expect(isVisibleToCustomer('INTERNAL_NOTE')).toBe(false);
|
||||
expect(isVisibleToCustomer('INVESTIGATION_NOTE')).toBe(false);
|
||||
expect(isVisibleToCustomer('SOLUTION_NOTE')).toBe(false);
|
||||
});
|
||||
|
||||
it('has a visibility entry for every defined message type', () => {
|
||||
for (const type of MESSAGE_TYPES) {
|
||||
expect(typeof isVisibleToCustomer(type)).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an undefined message type', () => {
|
||||
expect(isValidMessageType('NOT_A_REAL_TYPE')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateTicketCode,
|
||||
deriveProductCode,
|
||||
} from '@/modules/ticketing/tickets/mapper/ticket-code';
|
||||
|
||||
describe('Ticket code generation', () => {
|
||||
it('produces the documented <PRODUCT_CODE>-<YEAR>-<SEQUENCE> shape', () => {
|
||||
expect(generateTicketCode('PROD_DQ_001', 567, 2026)).toBe('PROD-2026-00567');
|
||||
});
|
||||
|
||||
it('pads the sequence to 5 digits', () => {
|
||||
expect(generateTicketCode('DQB', 1, 2026)).toBe('DQB-2026-00001');
|
||||
});
|
||||
|
||||
it('derives an uppercase alphabetic product code, capped at 4 chars', () => {
|
||||
expect(deriveProductCode('docuqube-123')).toBe('DOCU');
|
||||
expect(deriveProductCode('DQB')).toBe('DQB');
|
||||
});
|
||||
|
||||
it('falls back to a generic code when the external id has no alphabetic characters', () => {
|
||||
expect(deriveProductCode('12345')).toBe('GEN');
|
||||
});
|
||||
|
||||
it('defaults to the current year when none is given', () => {
|
||||
const code = generateTicketCode('DQB', 1);
|
||||
expect(code).toMatch(/^DQB-\d{4}-00001$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TICKET_STATUSES,
|
||||
isValidTransition,
|
||||
isValidTicketStatus,
|
||||
} from '@/modules/ticketing/tickets/mapper/ticket-state-machine';
|
||||
|
||||
describe('Ticket state machine', () => {
|
||||
it('accepts every documented valid edge', () => {
|
||||
const validEdges: Array<[string, string]> = [
|
||||
['NEW', 'AI_ANALYZING'],
|
||||
['NEW', 'HUMAN_ESCALATION'],
|
||||
['AI_ANALYZING', 'AI_TROUBLESHOOTING'],
|
||||
['AI_TROUBLESHOOTING', 'AI_VERIFYING'],
|
||||
['AI_VERIFYING', 'AI_RESOLVED'],
|
||||
['AI_RESOLVED', 'RESOLUTION_PENDING_CUSTOMER'],
|
||||
['AI_RESOLVED', 'RESOLVED'],
|
||||
['HUMAN_ESCALATION', 'IN_PROGRESS'],
|
||||
['IN_PROGRESS', 'WAITING_FOR_CUSTOMER'],
|
||||
['IN_PROGRESS', 'RESOLUTION_PENDING_CUSTOMER'],
|
||||
['WAITING_FOR_CUSTOMER', 'IN_PROGRESS'],
|
||||
['RESOLUTION_PENDING_CUSTOMER', 'RESOLVED'],
|
||||
['RESOLVED', 'CLOSED'],
|
||||
['RESOLVED', 'REOPENED'],
|
||||
['CLOSED', 'REOPENED'],
|
||||
['REOPENED', 'IN_PROGRESS'],
|
||||
['REOPENED', 'AI_ANALYZING'],
|
||||
];
|
||||
|
||||
for (const [from, to] of validEdges) {
|
||||
expect(
|
||||
isValidTransition(from as never, to as never),
|
||||
`${from} -> ${to} should be valid`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a representative sample of invalid edges', () => {
|
||||
const invalidEdges: Array<[string, string]> = [
|
||||
['NEW', 'RESOLVED'],
|
||||
['NEW', 'CLOSED'],
|
||||
['CLOSED', 'RESOLVED'],
|
||||
['CLOSED', 'IN_PROGRESS'],
|
||||
['AI_ANALYZING', 'RESOLVED'],
|
||||
['WAITING_FOR_CUSTOMER', 'CLOSED'],
|
||||
['RESOLVED', 'AI_ANALYZING'],
|
||||
];
|
||||
|
||||
for (const [from, to] of invalidEdges) {
|
||||
expect(
|
||||
isValidTransition(from as never, to as never),
|
||||
`${from} -> ${to} should be invalid`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes every defined status as valid, and rejects unknown strings', () => {
|
||||
for (const status of TICKET_STATUSES) {
|
||||
expect(isValidTicketStatus(status)).toBe(true);
|
||||
}
|
||||
expect(isValidTicketStatus('NOT_A_REAL_STATUS')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user