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>
227 lines
9.2 KiB
Groovy
227 lines
9.2 KiB
Groovy
// CI/CD pipeline for supporthub-api.
|
|
//
|
|
// Stage order and guarantees are defined by:
|
|
// - .specify/memory/constitution.md -> "Testing, Observability & CI/CD Gates"
|
|
// - specs/001-ci-pipeline/contracts/pipeline-stage-contract.md
|
|
//
|
|
// Prerequisites (configured on the Jenkins side, never in this repo):
|
|
// - Agents with Docker and Node.js 20+ available (label: 'docker && node20')
|
|
// - Credentials (Secret text unless noted) per target environment <env> in [test, prod]:
|
|
// <env>-postgres-password, <env>-redis-password, <env>-jwt-secret
|
|
// <env>-aws-access-key-id, <env>-aws-secret-access-key
|
|
// <env>-integration-credential-encryption-key (64 hex chars / 32 bytes — see
|
|
// specs/002-saas-integration/research.md "Credential storage")
|
|
// Plus one Username/Password credential: docker-registry-credentials
|
|
// - A DOCKER_REGISTRY value (e.g. via a "CI_DOCKER_REGISTRY" global Jenkins env var,
|
|
// or override the default below) pointing at the org's actual image registry.
|
|
//
|
|
// This file intentionally contains no secret values, only credential IDs — see
|
|
// specs/001-ci-pipeline/research.md "Environment/secrets handling in the pipeline".
|
|
|
|
pipeline {
|
|
agent { label 'docker && node20' }
|
|
|
|
options {
|
|
timestamps()
|
|
buildDiscarder(logRotator(numToKeepStr: '30'))
|
|
// Each build gets its own workspace by default; Compose project names below are
|
|
// additionally scoped by BUILD_NUMBER so concurrent runs never share containers,
|
|
// networks, or volumes (FR-009 / SC-005).
|
|
ansiColor('xterm')
|
|
}
|
|
|
|
environment {
|
|
DOCKER_REGISTRY = "${env.CI_DOCKER_REGISTRY ?: 'registry.example.com/supporthub'}"
|
|
IMAGE_NAME = 'supporthub-api'
|
|
COMPOSE_PROJECT = "supporthub-ci-${env.BUILD_NUMBER}"
|
|
}
|
|
|
|
stages {
|
|
|
|
stage('Checkout') {
|
|
steps {
|
|
checkout scm
|
|
}
|
|
}
|
|
|
|
stage('Install') {
|
|
steps {
|
|
sh 'npm ci'
|
|
}
|
|
}
|
|
|
|
stage('Environment validation') {
|
|
steps {
|
|
script {
|
|
resolveDeployTarget()
|
|
writeTargetEnvFile(env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV)
|
|
}
|
|
// Reuses the existing Zod schema in src/config/env.ts as-is: it throws a
|
|
// specific, descriptive error on safeParse failure, so this fails fast with
|
|
// no new validation logic (FR-002).
|
|
sh "npx tsx --env-file=.env.${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV} -e \"import('./src/config/env.ts').then(() => console.log('Environment OK'))\""
|
|
}
|
|
}
|
|
|
|
stage('Generate Prisma client') {
|
|
steps {
|
|
sh 'npm run prisma:generate'
|
|
}
|
|
}
|
|
|
|
stage('Typecheck') {
|
|
steps {
|
|
sh 'npm run typecheck'
|
|
}
|
|
}
|
|
|
|
stage('Lint') {
|
|
steps {
|
|
sh 'npm run lint'
|
|
// Enforces Constitution Principle III (module boundaries) server-side —
|
|
// mirrors .husky/pre-commit so a bypassed/missing local hook can't merge
|
|
// a boundary violation.
|
|
sh 'npx tsx scripts/check-architecture.ts'
|
|
}
|
|
}
|
|
|
|
stage('Format check') {
|
|
steps {
|
|
sh 'npm run format:check'
|
|
}
|
|
}
|
|
|
|
stage('Unit test') {
|
|
steps {
|
|
sh 'npm run test:unit'
|
|
}
|
|
}
|
|
|
|
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 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"
|
|
}
|
|
}
|
|
|
|
stage('E2E test') {
|
|
steps {
|
|
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml run --rm app npm run test:e2e"
|
|
}
|
|
}
|
|
|
|
stage('Build') {
|
|
steps {
|
|
script {
|
|
def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV
|
|
sh "npm run build:${buildTarget}"
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Docker build') {
|
|
steps {
|
|
script {
|
|
def buildTarget = env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV
|
|
env.IMAGE_TAG = "${env.DOCKER_REGISTRY}/${env.IMAGE_NAME}:${buildTarget}-${env.BUILD_NUMBER}"
|
|
}
|
|
sh "docker build --build-arg BUILD_COMMAND=\"npm run build:${env.TARGET_ENV == 'none' ? 'test' : env.TARGET_ENV}\" -t ${env.IMAGE_TAG} ."
|
|
}
|
|
}
|
|
|
|
stage('Publish') {
|
|
when { expression { env.TARGET_ENV != 'none' } }
|
|
steps {
|
|
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials',
|
|
usernameVariable: 'REGISTRY_USER',
|
|
passwordVariable: 'REGISTRY_PASSWORD')]) {
|
|
sh '''
|
|
echo "$REGISTRY_PASSWORD" | docker login "$DOCKER_REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
|
docker push "$IMAGE_TAG"
|
|
docker logout "$DOCKER_REGISTRY"
|
|
'''
|
|
}
|
|
}
|
|
}
|
|
|
|
stage('Deploy') {
|
|
when { expression { env.TARGET_ENV != 'none' } }
|
|
steps {
|
|
script {
|
|
writeTargetEnvFile(env.TARGET_ENV)
|
|
}
|
|
sh "docker compose --env-file .env.${env.TARGET_ENV} -f docker-compose.${env.TARGET_ENV == 'prod' ? 'prod' : 'test'}.yml up -d"
|
|
}
|
|
}
|
|
}
|
|
|
|
post {
|
|
always {
|
|
// Tear down the ephemeral integration/E2E stack regardless of outcome, and never
|
|
// leave a generated .env.* file behind in the workspace. --env-file must be passed
|
|
// to `down` too, or Compose can fail to resolve service config and leave containers
|
|
// running (confirmed while validating this pipeline locally).
|
|
sh "docker compose -p ${env.COMPOSE_PROJECT} --env-file .env.test -f docker-compose.test.yml down -v --remove-orphans || true"
|
|
sh 'rm -f .env.test .env.prod .env.development'
|
|
cleanWs()
|
|
}
|
|
failure {
|
|
echo "Pipeline failed at stage: ${currentBuild.result}. See the failing stage's log above for the exact command and output — no local reproduction should be necessary (FR-004)."
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolves env.TARGET_ENV from the branch being built:
|
|
// main -> prod
|
|
// develop/test -> test
|
|
// anything else -> none (validate-only; Publish/Deploy stages are skipped, not failed)
|
|
void resolveDeployTarget() {
|
|
def branch = env.BRANCH_NAME ?: ''
|
|
if (branch == 'main') {
|
|
env.TARGET_ENV = 'prod'
|
|
} else if (branch == 'develop' || branch == 'test') {
|
|
env.TARGET_ENV = 'test'
|
|
} else {
|
|
env.TARGET_ENV = 'none'
|
|
}
|
|
echo "Resolved deploy target for branch '${branch}': ${env.TARGET_ENV}"
|
|
}
|
|
|
|
// Writes .env.<target> into the workspace from Jenkins credentials — never read from a
|
|
// file committed to the repository (constitution governance: "secrets never committed").
|
|
void writeTargetEnvFile(String target) {
|
|
withCredentials([
|
|
string(credentialsId: "${target}-postgres-password", variable: 'POSTGRES_PASSWORD'),
|
|
string(credentialsId: "${target}-redis-password", variable: 'REDIS_PASSWORD'),
|
|
string(credentialsId: "${target}-jwt-secret", variable: 'JWT_SECRET'),
|
|
string(credentialsId: "${target}-aws-access-key-id", variable: 'AWS_ACCESS_KEY_ID'),
|
|
string(credentialsId: "${target}-aws-secret-access-key", variable: 'AWS_SECRET_ACCESS_KEY'),
|
|
string(credentialsId: "${target}-integration-credential-encryption-key", variable: 'INTEGRATION_CREDENTIAL_ENCRYPTION_KEY'),
|
|
]) {
|
|
def port = target == 'prod' ? '4503' : (target == 'test' ? '4502' : '4501')
|
|
def dbName = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_dev')
|
|
def dbUser = target == 'prod' ? 'myapp_prod' : (target == 'test' ? 'myapp_test' : 'support_user')
|
|
writeFile file: ".env.${target}", text: """
|
|
NODE_ENV=${target == 'prod' ? 'production' : target}
|
|
PORT=${port}
|
|
BUILD_COMMAND=npm run build:${target}
|
|
POSTGRES_HOST=postgres
|
|
POSTGRES_DB=${dbName}
|
|
POSTGRES_USER=${dbUser}
|
|
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
|
DATABASE_URL=postgresql://${dbUser}:${POSTGRES_PASSWORD}@postgres:5432/${dbName}
|
|
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()
|
|
}
|
|
}
|