diff --git a/README.md b/README.md index 50e68ad..4e56f2d 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,31 @@ -# SupportHub API +### Development +- docker compose --env-file .env.development -f docker-compose.development.yml up -d --build -### Development (Docker) -- Start all services: `docker compose --env-file .env.development -f docker-compose.development.yml up -d --build` -- Start only database & cache (for local app development): `docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis` +### Test +- docker compose --env-file .env.test -f docker-compose.test.yml up --build -### Test (Docker) -- `docker compose --env-file .env.test -f docker-compose.test.yml up --build` +### Production +- docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d -### Production (Docker) -- `docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d` +### Stop +- docker compose -f docker-compose.prod.yml down -### Stop / Down -- Stop production: `docker compose -f docker-compose.prod.yml down` -- Stop development: `docker compose -f docker-compose.development.yml down` -- Stop development & wipe volumes: `docker compose --env-file .env.development -f docker-compose.development.yml down -v` +### List Containers +- docker compose --env-file .env.development -f docker-compose.development.yml ps -### List Containers & Logs -- List containers: `docker compose --env-file .env.development -f docker-compose.development.yml ps` -- Follow logs: `docker compose --env-file .env.development -f docker-compose.development.yml logs -f` +### Logs +- docker compose --env-file .env.development -f docker-compose.development.yml logs -f ---- - -### Local Development (Host) -1. Start database & cache in Docker: - ```bash - docker compose --env-file .env.development -f docker-compose.development.yml up -d postgres redis - ``` -2. Start API server in watch mode: - ```bash - npm run dev - ``` - ---- - -### Database Migrations & Prisma - -- **Generate Prisma Client**: - ```bash - npm run prisma:generate - ``` - -- **Run / Apply Dev Migrations**: - ```bash - npx dotenv-cli -e .env.development -- npm run prisma:migrate - ``` - -- **Deploy Migrations (Production/CI)**: - ```bash - npx dotenv-cli -e .env.development -- npm run prisma:deploy - ``` - -- **Push Schema directly (Sync schema without migration files)**: - ```bash - npx dotenv-cli -e .env.development -- npx prisma db push - ``` - ---- +### Database Migrations +- **Local (using .env.development):** + - Create/apply new migration: `npx prisma migrate dev --name ` + - Push schema directly (prototype/sync): `npx prisma db push` + - Deploy pending migrations: `npm run prisma:deploy` +- **Inside Docker Container:** + - `docker exec -it support-api-development npx prisma migrate deploy` ### Database Seeding - -- **Seed Database (Roles, Products, Categories, Hierarchy & Demo data)**: - ```bash - npx dotenv-cli -e .env.development -- npm run prisma:seed - ``` - +- **Local:** + - `npm run prisma:seed` (or `npx tsx --env-file=.env.development prisma/seed/index.ts`) +- **Inside Docker Container:** + - `docker exec -it support-api-development npm run prisma:seed` diff --git a/src/modules/orchestration/escalation/repository/escalation-event.repository.ts b/src/modules/orchestration/escalation/repository/escalation-event.repository.ts index 120c8f7..6d5ee39 100644 --- a/src/modules/orchestration/escalation/repository/escalation-event.repository.ts +++ b/src/modules/orchestration/escalation/repository/escalation-event.repository.ts @@ -10,13 +10,51 @@ export interface CreateEscalationEventData { triggeredBy: string; } +export interface CreateEscalationEventResult { + event: EscalationEvent; + /** False when `create` returned a pre-existing event instead of inserting a new one — the + * caller (EscalationService.fire) uses this to skip re-running side effects (reassignment, + * the audit event-bus publish) for a duplicate trigger. */ + wasNewlyCreated: boolean; +} + +function isDuplicateRuleEscalationConflict(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' && + (error.meta?.target as string[] | undefined)?.includes('ruleId') === true + ); +} + export class EscalationEventRepository { constructor(private readonly prisma = prismaClient) {} - async create(data: CreateEscalationEventData): Promise { - return this.prisma.escalationEvent.create({ - data: data as Prisma.EscalationEventUncheckedCreateInput, - }); + /** + * 016-load-concurrency-testing research.md §3: a rule-triggered escalation + * (`data.ruleId` set) can only ever legitimately fire once per ticket over that ticket's + * lifetime (SLARun.ticketId is @unique — no reopen-cycle support). The + * `escalation_events_ticket_rule_unique` partial unique index (migration 20260909120000) + * enforces this at the database level; a duplicate trigger (e.g. two overlapping breach-sweep + * passes, or a re-delivered job) fails with `P2002` here, and this method returns the + * pre-existing event instead of throwing — the duplicate is silently absorbed, never + * surfaced as an error to a caller that did nothing wrong. Manual escalations (`ruleId: null`) + * are unaffected and always insert a new row. + */ + async create(data: CreateEscalationEventData): Promise { + try { + const event = await this.prisma.escalationEvent.create({ + data: data as Prisma.EscalationEventUncheckedCreateInput, + }); + return { event, wasNewlyCreated: true }; + } catch (error) { + if (!isDuplicateRuleEscalationConflict(error)) throw error; + + const existing = await this.prisma.escalationEvent.findFirst({ + where: { ticketId: data.ticketId, ruleId: data.ruleId }, + }); + if (!existing) throw error; // conflict raced with a delete — surface the original error. + return { event: existing, wasNewlyCreated: false }; + } } async findAllForTicket(ticketId: string): Promise { diff --git a/src/modules/orchestration/escalation/service/escalation.service.ts b/src/modules/orchestration/escalation/service/escalation.service.ts index 667c32b..be79de5 100644 --- a/src/modules/orchestration/escalation/service/escalation.service.ts +++ b/src/modules/orchestration/escalation/service/escalation.service.ts @@ -76,7 +76,7 @@ export class EscalationService { actor: string, reason: string, ): Promise { - const event = await this.events.create({ + const { event, wasNewlyCreated } = await this.events.create({ ticketId, ruleId, // No existing model persists "which hierarchy node is this ticket currently in" — Assignment @@ -88,6 +88,12 @@ export class EscalationService { triggeredBy: actor, }); + // 016-load-concurrency-testing research.md §3/FR-003: a duplicate rule-triggered trigger + // (the repository already detected and absorbed it) must not also reassign or re-publish — + // both already happened for the winning attempt; doing them again would be the exact + // duplicate-side-effect bug this feature exists to close. + if (!wasNewlyCreated) return event; + await this.assignments.assignToSpecificNode(ticketId, targetNodeId, actor, reason); // research.md "SLA_BREACHED/ESCALATION_TRIGGERED are also published, for audit, not for