From dbfce109832627b1dccb54d3986816e7abe0710b Mon Sep 17 00:00:00 2001 From: azeeee05 Date: Fri, 7 Aug 2026 16:27:15 +0530 Subject: [PATCH] feat: initialize backend database schema and implement audit logging system with core service modules --- database/schema.sql | 27 ++++++ src/app.module.ts | 10 +++ src/common/interceptors/audit.interceptor.ts | 88 +++++++++++++++++++ src/database/database.module.ts | 1 + src/modules/audit-log/audit-log.controller.ts | 47 ++++++++++ src/modules/audit-log/audit-log.module.ts | 13 +++ src/modules/audit-log/audit-log.service.ts | 80 +++++++++++++++++ .../audit-log/dto/create-audit-log.dto.ts | 39 ++++++++ .../audit-log/entities/audit-log.entity.ts | 45 ++++++++++ src/modules/cohort/cohort.module.ts | 3 +- src/modules/cohort/cohort.service.ts | 43 ++++++++- .../policy-engine/policy-engine.module.ts | 3 + .../policy-engine/policy-engine.service.ts | 78 ++++++++++------ .../recovery-incident.module.ts | 3 +- .../recovery-incident.service.ts | 79 +++++++++++++++-- 15 files changed, 520 insertions(+), 39 deletions(-) create mode 100644 src/common/interceptors/audit.interceptor.ts create mode 100644 src/modules/audit-log/audit-log.controller.ts create mode 100644 src/modules/audit-log/audit-log.module.ts create mode 100644 src/modules/audit-log/audit-log.service.ts create mode 100644 src/modules/audit-log/dto/create-audit-log.dto.ts create mode 100644 src/modules/audit-log/entities/audit-log.entity.ts diff --git a/database/schema.sql b/database/schema.sql index b8e1463..c3074fa 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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 ───────────────────────────────────────────────────────────── diff --git a/src/app.module.ts b/src/app.module.ts index a8e13cc..00d7825 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 { diff --git a/src/common/interceptors/audit.interceptor.ts b/src/common/interceptors/audit.interceptor.ts new file mode 100644 index 0000000..cb95d4e --- /dev/null +++ b/src/common/interceptors/audit.interceptor.ts @@ -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 { + const req = context.switchToHttp().getRequest(); + 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}`); + } + }, + }), + ); + } +} diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 5ce2bbe..0fca861 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -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) { diff --git a/src/modules/audit-log/audit-log.controller.ts b/src/modules/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..1d87c08 --- /dev/null +++ b/src/modules/audit-log/audit-log.controller.ts @@ -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; + } +} diff --git a/src/modules/audit-log/audit-log.module.ts b/src/modules/audit-log/audit-log.module.ts new file mode 100644 index 0000000..1ad7a2b --- /dev/null +++ b/src/modules/audit-log/audit-log.module.ts @@ -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 {} diff --git a/src/modules/audit-log/audit-log.service.ts b/src/modules/audit-log/audit-log.service.ts new file mode 100644 index 0000000..5a1ebc5 --- /dev/null +++ b/src/modules/audit-log/audit-log.service.ts @@ -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, + ) {} + + // ─── 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 { + 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 = {}; + + 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 { + const tenantId = getTenantIdOrNull(); + const where: FindOptionsWhere = { id }; + if (tenantId) where.tenantId = tenantId; + return this.auditLogRepository.findOne({ where }); + } +} diff --git a/src/modules/audit-log/dto/create-audit-log.dto.ts b/src/modules/audit-log/dto/create-audit-log.dto.ts new file mode 100644 index 0000000..d23fa49 --- /dev/null +++ b/src/modules/audit-log/dto/create-audit-log.dto.ts @@ -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; + + @IsObject() + @IsOptional() + after?: Record; + + @IsString() + @IsOptional() + performedBy?: string; + + @IsString() + @IsOptional() + ipAddress?: string; + + @IsString() + @IsOptional() + userAgent?: string; +} diff --git a/src/modules/audit-log/entities/audit-log.entity.ts b/src/modules/audit-log/entities/audit-log.entity.ts new file mode 100644 index 0000000..f19d475 --- /dev/null +++ b/src/modules/audit-log/entities/audit-log.entity.ts @@ -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; // snapshot before change (null for CREATE) + + @Column({ type: 'jsonb', nullable: true }) + after?: Record; // 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; +} diff --git a/src/modules/cohort/cohort.module.ts b/src/modules/cohort/cohort.module.ts index 46b6533..bee6549 100644 --- a/src/modules/cohort/cohort.module.ts +++ b/src/modules/cohort/cohort.module.ts @@ -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], diff --git a/src/modules/cohort/cohort.service.ts b/src/modules/cohort/cohort.service.ts index 8ede91a..e192ef0 100644 --- a/src/modules/cohort/cohort.service.ts +++ b/src/modules/cohort/cohort.service.ts @@ -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, + private readonly auditLogService: AuditLogService, ) {} private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) { @@ -96,19 +98,56 @@ export class CohortService { async update(id: string, updateCohortDto: UpdateCohortDto): Promise { 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 { 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 { 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(), + }); } } diff --git a/src/modules/policy-engine/policy-engine.module.ts b/src/modules/policy-engine/policy-engine.module.ts index e097376..6322699 100644 --- a/src/modules/policy-engine/policy-engine.module.ts +++ b/src/modules/policy-engine/policy-engine.module.ts @@ -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 { } + diff --git a/src/modules/policy-engine/policy-engine.service.ts b/src/modules/policy-engine/policy-engine.service.ts index 848355b..ef3d3e3 100644 --- a/src/modules/policy-engine/policy-engine.service.ts +++ b/src/modules/policy-engine/policy-engine.service.ts @@ -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, @InjectRepository(Jurisdiction) private readonly jurisdictionRepository: Repository, + private readonly auditLogService: AuditLogService, ) {} private getRelations() { @@ -41,15 +43,13 @@ export class PolicyEngineService { async create(createPolicyDto: CreatePolicyDto): Promise { 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 { - // 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 { 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 { 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(), + }); } } diff --git a/src/modules/recovery-incident/recovery-incident.module.ts b/src/modules/recovery-incident/recovery-incident.module.ts index e612e06..d2c81a7 100644 --- a/src/modules/recovery-incident/recovery-incident.module.ts +++ b/src/modules/recovery-incident/recovery-incident.module.ts @@ -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], diff --git a/src/modules/recovery-incident/recovery-incident.service.ts b/src/modules/recovery-incident/recovery-incident.service.ts index 889342d..d05c6f5 100644 --- a/src/modules/recovery-incident/recovery-incident.service.ts +++ b/src/modules/recovery-incident/recovery-incident.service.ts @@ -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, + private readonly auditLogService: AuditLogService, ) {} async create(createDto: CreateRecoveryIncidentDto): Promise { @@ -21,6 +23,7 @@ export class RecoveryIncidentService { tenantId, }); return this.recoveryIncidentRepo.save(incident); + // CREATE is logged automatically by AuditInterceptor (POST handler) } async findAll(): Promise { @@ -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 { - 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 { 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 { + 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, + ); + } } }