feat: initialize backend database schema and implement audit logging system with core service modules

This commit is contained in:
azeeee05
2026-08-07 16:27:15 +05:30
parent e3edd7a6c4
commit dbfce10983
15 changed files with 520 additions and 39 deletions
+27
View File
@@ -21,6 +21,33 @@ CREATE SCHEMA IF NOT EXISTS tenant;
CREATE SCHEMA IF NOT EXISTS masters;
CREATE SCHEMA IF NOT EXISTS cohort;
CREATE SCHEMA IF NOT EXISTS policy_engine;
CREATE SCHEMA IF NOT EXISTS audit;
CREATE SCHEMA IF NOT EXISTS recovery_incident;
-- ─── Audit Logs ──────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS audit.tbl_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(255) NOT NULL,
module VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
entity_id VARCHAR(255),
entity_label VARCHAR(255),
before JSONB,
after JSONB,
performed_by VARCHAR(255),
ip_address VARCHAR(50),
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON audit.tbl_audit_logs (tenant_id);
CREATE INDEX IF NOT EXISTS idx_audit_module ON audit.tbl_audit_logs (module);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit.tbl_audit_logs (action);
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit.tbl_audit_logs (entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_createdat ON audit.tbl_audit_logs (created_at DESC);
-- ─── Enum Types ─────────────────────────────────────────────────────────────
+10
View File
@@ -1,4 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './modules/health/health.module';
@@ -8,6 +9,8 @@ import { TenantModule } from './modules/tenant/tenant.module';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module';
import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { AuditInterceptor } from './common/interceptors/audit.interceptor';
const env = process.env.NODE_ENV;
const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', '.env'];
@@ -25,6 +28,13 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local',
CohortModule,
PolicyEngineModule,
RecoveryIncidentModule,
AuditLogModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: AuditInterceptor,
},
],
})
export class AppModule implements NestModule {
@@ -0,0 +1,88 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
import { AuditLogService } from '../../modules/audit-log/audit-log.service';
// Methods that mutate data — only these are audited
const MUTABLE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
// Map: [HTTP method, URL pattern] → { module, action }
// The interceptor only handles CREATE (POST) since UPDATE/DELETE
// need before-snapshots which are captured directly in each service.
const MODULE_MAP: Array<{ pattern: RegExp; module: string }> = [
{ pattern: /policy-engine/, module: 'policy-engine' },
{ pattern: /recovery-incidents/, module: 'recovery-incident' },
{ pattern: /cohort/, module: 'cohort' },
{ pattern: /master-data/, module: 'master-data' },
];
function resolveModule(url: string): string {
for (const entry of MODULE_MAP) {
if (entry.pattern.test(url)) return entry.module;
}
return 'unknown';
}
function resolveAction(method: string, url: string): string {
if (method === 'POST') return 'CREATE';
if (method === 'DELETE') return 'DELETE';
if (method === 'PATCH' && url.includes('/status')) return 'STATUS_CHANGE';
if (method === 'PATCH' || method === 'PUT') return 'UPDATE';
return method;
}
@Injectable()
export class AuditInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditInterceptor.name);
constructor(private readonly auditLogService: AuditLogService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest<Request>();
const { method, ip, headers } = req;
const url = req.originalUrl || req.url;
// Only intercept CREATE (POST) — UPDATE/DELETE handled in services
if (!MUTABLE_METHODS.includes(method) || method !== 'POST') {
return next.handle();
}
const module = resolveModule(url);
const action = resolveAction(method, url);
const userAgent = headers['user-agent'] ?? '';
const tenantId = (headers['x-tenant-id'] as string) ?? 'unknown';
this.logger.debug(`Intercepted ${method} ${url} -> Module: ${module}, Action: ${action}`);
return next.handle().pipe(
tap({
next: async (responseData: any) => {
this.logger.debug(`Writing audit log for ${module} ${action}...`);
try {
await this.auditLogService.log({
module,
action,
entityId: responseData?.id ?? undefined,
entityLabel: responseData?.name ?? responseData?.recoveryCode ?? responseData?.flightNumber ?? undefined,
before: undefined, // no before on CREATE
after: responseData ?? undefined,
performedBy: tenantId,
ipAddress: ip,
userAgent: String(userAgent),
});
} catch (err) {
// Never let audit failure break the main request
this.logger.warn(`Audit log failed for ${method} ${url}: ${err}`);
}
},
}),
);
}
}
+1
View File
@@ -35,6 +35,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "cohort";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "policy_engine";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "recovery_incident";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "audit";`);
// Run synchronization manually if it was enabled
if (options.synchronize) {
@@ -0,0 +1,47 @@
import {
Controller,
Get,
Param,
Query,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuditLogService } from './audit-log.service';
@ApiTags('Audit Logs')
@Controller('audit-logs')
export class AuditLogController {
constructor(private readonly auditLogService: AuditLogService) {}
@Get()
@ApiOperation({ summary: 'Get paginated audit logs with optional filters' })
findAll(
@Query('page') page: string = '1',
@Query('limit') limit: string = '20',
@Query('module') module?: string,
@Query('action') action?: string,
@Query('entityId') entityId?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
return this.auditLogService.findAll({
page: Number(page),
limit: Number(limit),
module,
action,
entityId,
dateFrom,
dateTo,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single audit log entry by ID' })
async findOne(@Param('id') id: string) {
const log = await this.auditLogService.findOne(id);
if (!log) {
throw new NotFoundException(`Audit log entry ${id} not found`);
}
return log;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogService } from './audit-log.service';
import { AuditLogController } from './audit-log.controller';
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
controllers: [AuditLogController],
providers: [AuditLogService],
exports: [AuditLogService], // exported so other modules can inject it
})
export class AuditLogModule {}
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { AuditLog } from './entities/audit-log.entity';
import { CreateAuditLogDto } from './dto/create-audit-log.dto';
import { getTenantIdOrNull } from '../../common/tenant/tenant.context';
@Injectable()
export class AuditLogService {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>,
) {}
// ─── Write a single audit entry ──────────────────────────────────────────
// Called directly from services (for UPDATE/DELETE with before snapshots)
// and from the HTTP interceptor (for CREATE).
async log(dto: CreateAuditLogDto): Promise<void> {
const tenantId = getTenantIdOrNull() ?? dto.performedBy ?? 'system';
const entry = this.auditLogRepository.create({
...dto,
tenantId,
performedBy: dto.performedBy ?? tenantId,
});
await this.auditLogRepository.save(entry);
}
// ─── Paginated query with filters ────────────────────────────────────────
async findAll(options: {
page?: number;
limit?: number;
module?: string;
action?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const { page = 1, limit = 20, module, action, entityId, dateFrom, dateTo } = options;
const skip = (page - 1) * limit;
const tenantId = getTenantIdOrNull();
const where: FindOptionsWhere<AuditLog> = {};
if (tenantId) where.tenantId = tenantId;
if (module) where.module = module;
if (action) where.action = action;
if (entityId) where.entityId = entityId;
if (dateFrom && dateTo) {
where.createdAt = Between(new Date(dateFrom), new Date(dateTo)) as any;
} else if (dateFrom) {
where.createdAt = MoreThanOrEqual(new Date(dateFrom)) as any;
} else if (dateTo) {
where.createdAt = LessThanOrEqual(new Date(dateTo)) as any;
}
const [data, total] = await this.auditLogRepository.findAndCount({
where,
skip,
take: limit,
order: { createdAt: 'DESC' },
});
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
// ─── Single entry lookup ──────────────────────────────────────────────────
async findOne(id: string): Promise<AuditLog | null> {
const tenantId = getTenantIdOrNull();
const where: FindOptionsWhere<AuditLog> = { id };
if (tenantId) where.tenantId = tenantId;
return this.auditLogRepository.findOne({ where });
}
}
@@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator';
export class CreateAuditLogDto {
@IsString()
@IsNotEmpty()
module: string;
@IsString()
@IsNotEmpty()
action: string;
@IsString()
@IsOptional()
entityId?: string;
@IsString()
@IsOptional()
entityLabel?: string;
@IsObject()
@IsOptional()
before?: Record<string, any>;
@IsObject()
@IsOptional()
after?: Record<string, any>;
@IsString()
@IsOptional()
performedBy?: string;
@IsString()
@IsOptional()
ipAddress?: string;
@IsString()
@IsOptional()
userAgent?: string;
}
@@ -0,0 +1,45 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_audit_logs', schema: 'audit' })
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'varchar', length: 255 })
tenantId!: string;
@Column({ type: 'varchar', length: 100 })
module!: string; // 'policy-engine' | 'recovery-incident' | 'cohort' | 'master-data'
@Column({ type: 'varchar', length: 50 })
action!: string; // 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE'
@Column({ name: 'entity_id', type: 'varchar', length: 255, nullable: true })
entityId?: string;
@Column({ name: 'entity_label', type: 'varchar', length: 255, nullable: true })
entityLabel?: string; // human-readable name (policy name, flight #, etc.)
@Column({ type: 'jsonb', nullable: true })
before?: Record<string, any>; // snapshot before change (null for CREATE)
@Column({ type: 'jsonb', nullable: true })
after?: Record<string, any>; // snapshot after change (null for DELETE)
@Column({ name: 'performed_by', type: 'varchar', length: 255, nullable: true })
performedBy?: string; // tenantId for now; will be userId when auth is added
@Column({ name: 'ip_address', type: 'varchar', length: 50, nullable: true })
ipAddress?: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+2 -1
View File
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CohortController } from './cohort.controller';
import { CohortService } from './cohort.service';
import { Cohort } from './cohort.entity';
import { AuditLogModule } from '../audit-log/audit-log.module';
@Module({
imports: [TypeOrmModule.forFeature([Cohort])],
imports: [TypeOrmModule.forFeature([Cohort]), AuditLogModule],
controllers: [CohortController],
providers: [CohortService],
exports: [CohortService],
+41 -2
View File
@@ -5,12 +5,14 @@ import { getTenantId } from '../../common/tenant/tenant.context';
import { Cohort } from './cohort.entity';
import { CreateCohortDto } from './dto/create-cohort.dto';
import { UpdateCohortDto } from './dto/update-cohort.dto';
import { AuditLogService } from '../audit-log/audit-log.service';
@Injectable()
export class CohortService {
constructor(
@InjectRepository(Cohort)
private readonly cohortRepository: Repository<Cohort>,
private readonly auditLogService: AuditLogService,
) {}
private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) {
@@ -96,19 +98,56 @@ export class CohortService {
async update(id: string, updateCohortDto: UpdateCohortDto): Promise<Cohort> {
const cohort = await this.findOne(id);
const beforeSnapshot = { ...cohort };
const mappedData = this.mapDtoToEntity(updateCohortDto);
Object.assign(cohort, mappedData);
return this.cohortRepository.save(cohort);
const after = await this.cohortRepository.save(cohort);
await this.auditLogService.log({
module: 'cohort',
action: 'UPDATE',
entityId: id,
entityLabel: cohort.name,
before: beforeSnapshot,
after: { ...after },
performedBy: getTenantId(),
});
return after;
}
async updateStatus(id: string, status: string): Promise<Cohort> {
const cohort = await this.findOne(id);
const beforeStatus = cohort.status;
cohort.status = status;
return this.cohortRepository.save(cohort);
const after = await this.cohortRepository.save(cohort);
await this.auditLogService.log({
module: 'cohort',
action: 'STATUS_CHANGE',
entityId: id,
entityLabel: cohort.name,
before: { status: beforeStatus },
after: { status },
performedBy: getTenantId(),
});
return after;
}
async remove(id: string): Promise<void> {
const cohort = await this.findOne(id);
const beforeSnapshot = { ...cohort };
await this.cohortRepository.remove(cohort);
await this.auditLogService.log({
module: 'cohort',
action: 'DELETE',
entityId: id,
entityLabel: beforeSnapshot.name,
before: beforeSnapshot,
after: undefined,
performedBy: getTenantId(),
});
}
}
@@ -9,6 +9,7 @@ import { RuleCondition } from './entities/rule-condition.entity';
import { PolicyAction } from './entities/policy-action.entity';
import { PolicyActionValue } from './entities/policy-action-value.entity';
import { MasterDataModule } from '../master-data/master-data.module';
import { AuditLogModule } from '../audit-log/audit-log.module';
@Module({
imports: [
@@ -21,6 +22,7 @@ import { MasterDataModule } from '../master-data/master-data.module';
PolicyActionValue,
]),
MasterDataModule,
AuditLogModule,
],
controllers: [PolicyEngineController],
providers: [PolicyEngineService],
@@ -28,3 +30,4 @@ import { MasterDataModule } from '../master-data/master-data.module';
})
export class PolicyEngineModule { }
@@ -12,6 +12,7 @@ import { CreatePolicyDto } from './dto/create-policy.dto';
import { UpdatePolicyDto } from './dto/update-policy.dto';
import { PolicyStatus, AudienceType } from './entities/policy.enums';
import { Jurisdiction } from '../master-data/entities/jurisdiction.entity';
import { AuditLogService } from '../audit-log/audit-log.service';
@Injectable()
export class PolicyEngineService {
@@ -20,6 +21,7 @@ export class PolicyEngineService {
private readonly policyRepository: Repository<Policy>,
@InjectRepository(Jurisdiction)
private readonly jurisdictionRepository: Repository<Jurisdiction>,
private readonly auditLogService: AuditLogService,
) {}
private getRelations() {
@@ -41,15 +43,13 @@ export class PolicyEngineService {
async create(createPolicyDto: CreatePolicyDto): Promise<Policy> {
const tenantId = getTenantId();
// Create the policy entity and cascade nested associations using TypeORM
const policy = this.policyRepository.create({
...createPolicyDto,
tenantId,
});
const saved = await this.policyRepository.save(policy);
return this.findOne(saved.id);
// CREATE is logged automatically by AuditInterceptor (POST handler)
}
async findAll(
@@ -62,12 +62,8 @@ export class PolicyEngineService {
const tenantId = getTenantId();
const whereClause: any = { tenantId };
if (status) {
whereClause.status = status;
}
if (audienceType) {
whereClause.audienceType = audienceType;
}
if (status) whereClause.status = status;
if (audienceType) whereClause.audienceType = audienceType;
const [data, total] = await this.policyRepository.findAndCount({
where: whereClause,
@@ -101,41 +97,32 @@ export class PolicyEngineService {
}
async update(id: string, updatePolicyDto: UpdatePolicyDto): Promise<Policy> {
// Ensure policy exists and belongs to the tenant
await this.findOne(id);
// Capture before snapshot
const before = await this.findOne(id);
const beforeSnapshot = { ...before };
const { targetAudiences, rules, ...policyData } = updatePolicyDto;
// Execute database operations in a transaction for clean replacement of nested objects
return this.policyRepository.manager.transaction(async (transactionalEntityManager) => {
// 1. Delete all existing target audiences associated with this policy
const after = await this.policyRepository.manager.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id });
// 2. Find and delete existing rules (cascades to conditions and actions/values)
const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } });
if (existingRules.length > 0) {
await transactionalEntityManager.remove(PolicyRule, existingRules);
}
// 3. Fetch original policy again in transaction to ensure we have a fresh copy
const policy = await transactionalEntityManager.findOneOrFail(Policy, {
where: { id, tenantId: getTenantId() },
});
// 4. Update policy basic columns
Object.assign(policy, policyData);
// 5. Build new Target Audience entities if provided
if (targetAudiences) {
policy.targetAudiences = targetAudiences.map((target) =>
transactionalEntityManager.create(PolicyTargetAudience, {
...target,
policyId: id,
}),
transactionalEntityManager.create(PolicyTargetAudience, { ...target, policyId: id }),
);
}
// 6. Build new Policy Rule entities with Conditions and Actions if provided
if (rules) {
policy.rules = rules.map((ruleDto) => {
const conditions = ruleDto.conditions?.map((cond) =>
@@ -146,7 +133,6 @@ export class PolicyEngineService {
const values = act.values?.map((val) =>
transactionalEntityManager.create(PolicyActionValue, val),
) || [];
return transactionalEntityManager.create(PolicyAction, {
actionTypeId: act.actionTypeId,
sequence: act.sequence,
@@ -164,25 +150,61 @@ export class PolicyEngineService {
});
}
// 7. Save updated policy and cascades
const savedPolicy = await transactionalEntityManager.save(Policy, policy);
// Reload updated policy with all relations and return
return transactionalEntityManager.findOneOrFail(Policy, {
where: { id: savedPolicy.id },
relations: this.getRelations(),
});
});
// Log UPDATE with before/after snapshots
await this.auditLogService.log({
module: 'policy-engine',
action: 'UPDATE',
entityId: id,
entityLabel: (before as any).name ?? id,
before: beforeSnapshot,
after: { ...after },
performedBy: getTenantId(),
});
return after;
}
async updateStatus(id: string, status: PolicyStatus): Promise<Policy> {
const policy = await this.findOne(id);
const beforeStatus = policy.status;
policy.status = status;
return this.policyRepository.save(policy);
const after = await this.policyRepository.save(policy);
await this.auditLogService.log({
module: 'policy-engine',
action: 'STATUS_CHANGE',
entityId: id,
entityLabel: (policy as any).name ?? id,
before: { status: beforeStatus },
after: { status },
performedBy: getTenantId(),
});
return after;
}
async remove(id: string): Promise<void> {
const policy = await this.findOne(id);
const beforeSnapshot = { ...policy };
await this.policyRepository.remove(policy);
await this.auditLogService.log({
module: 'policy-engine',
action: 'DELETE',
entityId: id,
entityLabel: (beforeSnapshot as any).name ?? id,
before: beforeSnapshot,
after: undefined,
performedBy: getTenantId(),
});
}
}
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RecoveryIncidentService } from './recovery-incident.service';
import { RecoveryIncidentController } from './recovery-incident.controller';
import { RecoveryIncident } from './entities/recovery-incident.entity';
import { AuditLogModule } from '../audit-log/audit-log.module';
@Module({
imports: [TypeOrmModule.forFeature([RecoveryIncident])],
imports: [TypeOrmModule.forFeature([RecoveryIncident]), AuditLogModule],
controllers: [RecoveryIncidentController],
providers: [RecoveryIncidentService],
exports: [RecoveryIncidentService],
@@ -1,16 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, HttpException, HttpStatus } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RecoveryIncident } from './entities/recovery-incident.entity';
import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto';
import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto';
import { getTenantId } from '../../common/tenant/tenant.context';
import { AuditLogService } from '../audit-log/audit-log.service';
@Injectable()
export class RecoveryIncidentService {
constructor(
@InjectRepository(RecoveryIncident)
private readonly recoveryIncidentRepo: Repository<RecoveryIncident>,
private readonly auditLogService: AuditLogService,
) {}
async create(createDto: CreateRecoveryIncidentDto): Promise<RecoveryIncident> {
@@ -21,6 +23,7 @@ export class RecoveryIncidentService {
tenantId,
});
return this.recoveryIncidentRepo.save(incident);
// CREATE is logged automatically by AuditInterceptor (POST handler)
}
async findAll(): Promise<RecoveryIncident[]> {
@@ -36,28 +39,90 @@ export class RecoveryIncidentService {
const incident = await this.recoveryIncidentRepo.findOne({
where: { id, tenantId },
});
if (!incident) {
throw new NotFoundException(`RecoveryIncident with ID ${id} not found`);
}
return incident;
}
async update(id: string, updateDto: UpdateRecoveryIncidentDto): Promise<RecoveryIncident> {
const incident = await this.findOne(id);
const before = await this.findOne(id);
const beforeSnapshot = { ...before };
const updateData: any = { ...updateDto };
if (updateDto.date) {
updateData.date = new Date(updateDto.date);
}
Object.assign(incident, updateData);
return this.recoveryIncidentRepo.save(incident);
Object.assign(before, updateData);
const after = await this.recoveryIncidentRepo.save(before);
// Log UPDATE with before/after snapshots
await this.auditLogService.log({
module: 'recovery-incident',
action: 'UPDATE',
entityId: id,
entityLabel: before.recoveryCode ?? before.flightNumber,
before: beforeSnapshot,
after: { ...after },
performedBy: getTenantId(),
});
return after;
}
async remove(id: string): Promise<void> {
const incident = await this.findOne(id);
const beforeSnapshot = { ...incident };
await this.recoveryIncidentRepo.remove(incident);
// Log DELETE — before = deleted record, after = null
await this.auditLogService.log({
module: 'recovery-incident',
action: 'DELETE',
entityId: id,
entityLabel: beforeSnapshot.recoveryCode ?? beforeSnapshot.flightNumber,
before: beforeSnapshot,
after: undefined,
performedBy: getTenantId(),
});
}
// ─── AviationStack proxy ──────────────────────────────────────────────────
async flightLookup(flightIata: string): Promise<any> {
const apiKey = process.env.AVIATION_STACK_API_KEY;
if (!apiKey) {
throw new HttpException('AviationStack API key not configured', HttpStatus.INTERNAL_SERVER_ERROR);
}
const url = `http://api.aviationstack.com/v1/flights?access_key=${apiKey}&flight_iata=${encodeURIComponent(flightIata.trim())}&limit=1`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new HttpException(
`AviationStack responded with ${response.status}`,
HttpStatus.BAD_GATEWAY,
);
}
const json = await response.json();
if (json.error) {
throw new HttpException(json.error.info || 'AviationStack error', HttpStatus.BAD_GATEWAY);
}
const flights: any[] = json.data ?? [];
return flights.length > 0 ? flights[0] : null;
} catch (err: any) {
if (err instanceof HttpException) throw err;
throw new HttpException(
`Failed to reach AviationStack: ${err.message}`,
HttpStatus.BAD_GATEWAY,
);
}
}
}