1
import { Injectable } from '@nestjs/common';
2
import { randomUUID } from 'node:crypto';
3
import { DatabaseService } from '../../../infrastructure/database/database.service';
4
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
5
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
6
import {
7
assertOperationalCaseCreateInput,
8
assertOperationalCaseEvidenceInput,
9
assertOperationalCaseResolveInput,
10
assertOperationalCaseScope,
11
} from './operational-case-input-validation';
12
13
@Injectable()
14
export class OperationalCaseService {
15
constructor(private readonly database:DatabaseService,private readonly outbox:OutboxService) {}
16
17
async create(input:unknown) {
18
assertOperationalCaseCreateInput(input);
19
const id=randomUUID();
20
await this.database.query(`insert into operational_cases(id,organization_id,location_id,case_type,severity,title,summary,affected_entity_type,affected_entity_id,opened_by_user_id) values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,[id,input.organizationId,input.locationId,input.caseType,input.severity,input.title,input.summary,input.affectedEntityType??null,input.affectedEntityId??null,input.openedByUserId??null]);
21
await this.outbox.append({organizationId:input.organizationId,aggregateType:'OperationalCase',aggregateId:id,eventType:'OperationalCaseOpened',payload:{caseId:id,caseType:input.caseType,severity:input.severity,locationId:input.locationId}});
22
return {id,status:'open'};
23
}
24
25
async list(input:unknown) {
26
assertOperationalCaseScope(input);
27
return this.database.query(`select id,case_type,severity,status,title,summary,affected_entity_type,affected_entity_id,outcome,opened_at,resolved_at,version from operational_cases where organization_id=$1 and location_id=$2 order by case status when 'open' then 0 when 'investigating' then 1 when 'resolved' then 2 else 3 end, case severity when 'critical' then 0 when 'high' then 1 when 'medium' then 2 else 3 end, opened_at desc`,[input.organizationId,input.locationId]);
28
}
29
30
async get(input:unknown) {
31
assertOperationalCaseScope(input);
32
if (typeof input.caseId !== 'string' || input.caseId.trim().length === 0 || input.caseId.length > 200) {
33
throw new DomainConflictError('incidents.input_invalid');
34
}
35
const rows=await this.database.query(`select id,case_type,severity,status,title,summary,affected_entity_type,affected_entity_id,evidence,decision,outcome,opened_by_user_id,resolved_by_user_id,opened_at,resolved_at,version from operational_cases where id=$1 and organization_id=$2 and location_id=$3`,[input.caseId,input.organizationId,input.locationId]);
36
if(!rows[0]) throw new DomainConflictError('operational_case.not_found');
37
return rows[0];
38
}
39
40
async addEvidence(input:unknown) {
41
assertOperationalCaseEvidenceInput(input);
42
const rows=await this.database.query<{id:string;evidence:unknown[];version:number}>(`update operational_cases set evidence=evidence || $1::jsonb,version=version+1 where id=$2 and organization_id=$3 and location_id=$4 and status in ('open','investigating') and ($5::bigint is null or version=$5) returning id,evidence,version`,[JSON.stringify([input.evidence]),input.caseId,input.organizationId,input.locationId,input.expectedVersion??null]);
43
if(!rows[0]) throw new DomainConflictError('operational_case.evidence_conflict');
44
return rows[0];
45
}
46
47
async resolve(input:unknown) {
48
assertOperationalCaseResolveInput(input);
49
const rows=await this.database.query<{id:string;status:string;version:number}>(`update operational_cases set status='resolved',outcome=$1,decision=$2::jsonb,resolved_by_user_id=$3,resolved_at=now(),version=version+1 where id=$4 and organization_id=$5 and location_id=$6 and status in ('open','investigating') and ($7::bigint is null or version=$7) returning id,status,version`,[input.outcome,input.decision?JSON.stringify(input.decision):null,input.userId??null,input.caseId,input.organizationId,input.locationId,input.expectedVersion??null]);
50
if(!rows[0]) throw new DomainConflictError('operational_case.resolve_conflict');
51
await this.outbox.append({organizationId:input.organizationId,aggregateType:'OperationalCase',aggregateId:input.caseId,eventType:'OperationalCaseResolved',payload:{caseId:input.caseId,outcome:input.outcome,version:rows[0].version}});
52
return rows[0];
53
}
54
}
55