create the specf document
This commit is contained in:
@@ -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 <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`
|
||||
|
||||
@@ -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<EscalationEvent> {
|
||||
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<CreateEscalationEventResult> {
|
||||
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<EscalationEvent[]> {
|
||||
|
||||
@@ -76,7 +76,7 @@ export class EscalationService {
|
||||
actor: string,
|
||||
reason: string,
|
||||
): Promise<EscalationEvent> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user