feat: initialize backend database schema and implement audit logging system with core service modules
This commit is contained in:
@@ -21,6 +21,33 @@ CREATE SCHEMA IF NOT EXISTS tenant;
|
|||||||
CREATE SCHEMA IF NOT EXISTS masters;
|
CREATE SCHEMA IF NOT EXISTS masters;
|
||||||
CREATE SCHEMA IF NOT EXISTS cohort;
|
CREATE SCHEMA IF NOT EXISTS cohort;
|
||||||
CREATE SCHEMA IF NOT EXISTS policy_engine;
|
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 ─────────────────────────────────────────────────────────────
|
-- ─── Enum Types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { HealthModule } from './modules/health/health.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 { TenantMiddleware } from './common/tenant/tenant.middleware';
|
||||||
import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module';
|
import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module';
|
||||||
import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.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 env = process.env.NODE_ENV;
|
||||||
const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', '.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,
|
CohortModule,
|
||||||
PolicyEngineModule,
|
PolicyEngineModule,
|
||||||
RecoveryIncidentModule,
|
RecoveryIncidentModule,
|
||||||
|
AuditLogModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: AuditInterceptor,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
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}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "cohort";`);
|
||||||
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "policy_engine";`);
|
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 "recovery_incident";`);
|
||||||
|
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "audit";`);
|
||||||
|
|
||||||
// Run synchronization manually if it was enabled
|
// Run synchronization manually if it was enabled
|
||||||
if (options.synchronize) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { CohortController } from './cohort.controller';
|
import { CohortController } from './cohort.controller';
|
||||||
import { CohortService } from './cohort.service';
|
import { CohortService } from './cohort.service';
|
||||||
import { Cohort } from './cohort.entity';
|
import { Cohort } from './cohort.entity';
|
||||||
|
import { AuditLogModule } from '../audit-log/audit-log.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Cohort])],
|
imports: [TypeOrmModule.forFeature([Cohort]), AuditLogModule],
|
||||||
controllers: [CohortController],
|
controllers: [CohortController],
|
||||||
providers: [CohortService],
|
providers: [CohortService],
|
||||||
exports: [CohortService],
|
exports: [CohortService],
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { getTenantId } from '../../common/tenant/tenant.context';
|
|||||||
import { Cohort } from './cohort.entity';
|
import { Cohort } from './cohort.entity';
|
||||||
import { CreateCohortDto } from './dto/create-cohort.dto';
|
import { CreateCohortDto } from './dto/create-cohort.dto';
|
||||||
import { UpdateCohortDto } from './dto/update-cohort.dto';
|
import { UpdateCohortDto } from './dto/update-cohort.dto';
|
||||||
|
import { AuditLogService } from '../audit-log/audit-log.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CohortService {
|
export class CohortService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Cohort)
|
@InjectRepository(Cohort)
|
||||||
private readonly cohortRepository: Repository<Cohort>,
|
private readonly cohortRepository: Repository<Cohort>,
|
||||||
|
private readonly auditLogService: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) {
|
private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) {
|
||||||
@@ -96,19 +98,56 @@ export class CohortService {
|
|||||||
|
|
||||||
async update(id: string, updateCohortDto: UpdateCohortDto): Promise<Cohort> {
|
async update(id: string, updateCohortDto: UpdateCohortDto): Promise<Cohort> {
|
||||||
const cohort = await this.findOne(id);
|
const cohort = await this.findOne(id);
|
||||||
|
const beforeSnapshot = { ...cohort };
|
||||||
const mappedData = this.mapDtoToEntity(updateCohortDto);
|
const mappedData = this.mapDtoToEntity(updateCohortDto);
|
||||||
Object.assign(cohort, mappedData);
|
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> {
|
async updateStatus(id: string, status: string): Promise<Cohort> {
|
||||||
const cohort = await this.findOne(id);
|
const cohort = await this.findOne(id);
|
||||||
|
const beforeStatus = cohort.status;
|
||||||
cohort.status = 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> {
|
async remove(id: string): Promise<void> {
|
||||||
const cohort = await this.findOne(id);
|
const cohort = await this.findOne(id);
|
||||||
|
const beforeSnapshot = { ...cohort };
|
||||||
await this.cohortRepository.remove(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 { PolicyAction } from './entities/policy-action.entity';
|
||||||
import { PolicyActionValue } from './entities/policy-action-value.entity';
|
import { PolicyActionValue } from './entities/policy-action-value.entity';
|
||||||
import { MasterDataModule } from '../master-data/master-data.module';
|
import { MasterDataModule } from '../master-data/master-data.module';
|
||||||
|
import { AuditLogModule } from '../audit-log/audit-log.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { MasterDataModule } from '../master-data/master-data.module';
|
|||||||
PolicyActionValue,
|
PolicyActionValue,
|
||||||
]),
|
]),
|
||||||
MasterDataModule,
|
MasterDataModule,
|
||||||
|
AuditLogModule,
|
||||||
],
|
],
|
||||||
controllers: [PolicyEngineController],
|
controllers: [PolicyEngineController],
|
||||||
providers: [PolicyEngineService],
|
providers: [PolicyEngineService],
|
||||||
@@ -28,3 +30,4 @@ import { MasterDataModule } from '../master-data/master-data.module';
|
|||||||
})
|
})
|
||||||
export class PolicyEngineModule { }
|
export class PolicyEngineModule { }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CreatePolicyDto } from './dto/create-policy.dto';
|
|||||||
import { UpdatePolicyDto } from './dto/update-policy.dto';
|
import { UpdatePolicyDto } from './dto/update-policy.dto';
|
||||||
import { PolicyStatus, AudienceType } from './entities/policy.enums';
|
import { PolicyStatus, AudienceType } from './entities/policy.enums';
|
||||||
import { Jurisdiction } from '../master-data/entities/jurisdiction.entity';
|
import { Jurisdiction } from '../master-data/entities/jurisdiction.entity';
|
||||||
|
import { AuditLogService } from '../audit-log/audit-log.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PolicyEngineService {
|
export class PolicyEngineService {
|
||||||
@@ -20,6 +21,7 @@ export class PolicyEngineService {
|
|||||||
private readonly policyRepository: Repository<Policy>,
|
private readonly policyRepository: Repository<Policy>,
|
||||||
@InjectRepository(Jurisdiction)
|
@InjectRepository(Jurisdiction)
|
||||||
private readonly jurisdictionRepository: Repository<Jurisdiction>,
|
private readonly jurisdictionRepository: Repository<Jurisdiction>,
|
||||||
|
private readonly auditLogService: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private getRelations() {
|
private getRelations() {
|
||||||
@@ -41,15 +43,13 @@ export class PolicyEngineService {
|
|||||||
|
|
||||||
async create(createPolicyDto: CreatePolicyDto): Promise<Policy> {
|
async create(createPolicyDto: CreatePolicyDto): Promise<Policy> {
|
||||||
const tenantId = getTenantId();
|
const tenantId = getTenantId();
|
||||||
|
|
||||||
// Create the policy entity and cascade nested associations using TypeORM
|
|
||||||
const policy = this.policyRepository.create({
|
const policy = this.policyRepository.create({
|
||||||
...createPolicyDto,
|
...createPolicyDto,
|
||||||
tenantId,
|
tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await this.policyRepository.save(policy);
|
const saved = await this.policyRepository.save(policy);
|
||||||
return this.findOne(saved.id);
|
return this.findOne(saved.id);
|
||||||
|
// CREATE is logged automatically by AuditInterceptor (POST handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(
|
async findAll(
|
||||||
@@ -62,12 +62,8 @@ export class PolicyEngineService {
|
|||||||
const tenantId = getTenantId();
|
const tenantId = getTenantId();
|
||||||
|
|
||||||
const whereClause: any = { tenantId };
|
const whereClause: any = { tenantId };
|
||||||
if (status) {
|
if (status) whereClause.status = status;
|
||||||
whereClause.status = status;
|
if (audienceType) whereClause.audienceType = audienceType;
|
||||||
}
|
|
||||||
if (audienceType) {
|
|
||||||
whereClause.audienceType = audienceType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [data, total] = await this.policyRepository.findAndCount({
|
const [data, total] = await this.policyRepository.findAndCount({
|
||||||
where: whereClause,
|
where: whereClause,
|
||||||
@@ -101,41 +97,32 @@ export class PolicyEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, updatePolicyDto: UpdatePolicyDto): Promise<Policy> {
|
async update(id: string, updatePolicyDto: UpdatePolicyDto): Promise<Policy> {
|
||||||
// Ensure policy exists and belongs to the tenant
|
// Capture before snapshot
|
||||||
await this.findOne(id);
|
const before = await this.findOne(id);
|
||||||
|
const beforeSnapshot = { ...before };
|
||||||
|
|
||||||
const { targetAudiences, rules, ...policyData } = updatePolicyDto;
|
const { targetAudiences, rules, ...policyData } = updatePolicyDto;
|
||||||
|
|
||||||
// Execute database operations in a transaction for clean replacement of nested objects
|
const after = await this.policyRepository.manager.transaction(async (transactionalEntityManager) => {
|
||||||
return this.policyRepository.manager.transaction(async (transactionalEntityManager) => {
|
|
||||||
// 1. Delete all existing target audiences associated with this policy
|
|
||||||
await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id });
|
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 } });
|
const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } });
|
||||||
if (existingRules.length > 0) {
|
if (existingRules.length > 0) {
|
||||||
await transactionalEntityManager.remove(PolicyRule, existingRules);
|
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, {
|
const policy = await transactionalEntityManager.findOneOrFail(Policy, {
|
||||||
where: { id, tenantId: getTenantId() },
|
where: { id, tenantId: getTenantId() },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Update policy basic columns
|
|
||||||
Object.assign(policy, policyData);
|
Object.assign(policy, policyData);
|
||||||
|
|
||||||
// 5. Build new Target Audience entities if provided
|
|
||||||
if (targetAudiences) {
|
if (targetAudiences) {
|
||||||
policy.targetAudiences = targetAudiences.map((target) =>
|
policy.targetAudiences = targetAudiences.map((target) =>
|
||||||
transactionalEntityManager.create(PolicyTargetAudience, {
|
transactionalEntityManager.create(PolicyTargetAudience, { ...target, policyId: id }),
|
||||||
...target,
|
|
||||||
policyId: id,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Build new Policy Rule entities with Conditions and Actions if provided
|
|
||||||
if (rules) {
|
if (rules) {
|
||||||
policy.rules = rules.map((ruleDto) => {
|
policy.rules = rules.map((ruleDto) => {
|
||||||
const conditions = ruleDto.conditions?.map((cond) =>
|
const conditions = ruleDto.conditions?.map((cond) =>
|
||||||
@@ -146,7 +133,6 @@ export class PolicyEngineService {
|
|||||||
const values = act.values?.map((val) =>
|
const values = act.values?.map((val) =>
|
||||||
transactionalEntityManager.create(PolicyActionValue, val),
|
transactionalEntityManager.create(PolicyActionValue, val),
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
return transactionalEntityManager.create(PolicyAction, {
|
return transactionalEntityManager.create(PolicyAction, {
|
||||||
actionTypeId: act.actionTypeId,
|
actionTypeId: act.actionTypeId,
|
||||||
sequence: act.sequence,
|
sequence: act.sequence,
|
||||||
@@ -164,25 +150,61 @@ export class PolicyEngineService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Save updated policy and cascades
|
|
||||||
const savedPolicy = await transactionalEntityManager.save(Policy, policy);
|
const savedPolicy = await transactionalEntityManager.save(Policy, policy);
|
||||||
|
|
||||||
// Reload updated policy with all relations and return
|
|
||||||
return transactionalEntityManager.findOneOrFail(Policy, {
|
return transactionalEntityManager.findOneOrFail(Policy, {
|
||||||
where: { id: savedPolicy.id },
|
where: { id: savedPolicy.id },
|
||||||
relations: this.getRelations(),
|
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> {
|
async updateStatus(id: string, status: PolicyStatus): Promise<Policy> {
|
||||||
const policy = await this.findOne(id);
|
const policy = await this.findOne(id);
|
||||||
|
const beforeStatus = policy.status;
|
||||||
|
|
||||||
policy.status = 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> {
|
async remove(id: string): Promise<void> {
|
||||||
const policy = await this.findOne(id);
|
const policy = await this.findOne(id);
|
||||||
|
const beforeSnapshot = { ...policy };
|
||||||
|
|
||||||
await this.policyRepository.remove(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 { RecoveryIncidentService } from './recovery-incident.service';
|
||||||
import { RecoveryIncidentController } from './recovery-incident.controller';
|
import { RecoveryIncidentController } from './recovery-incident.controller';
|
||||||
import { RecoveryIncident } from './entities/recovery-incident.entity';
|
import { RecoveryIncident } from './entities/recovery-incident.entity';
|
||||||
|
import { AuditLogModule } from '../audit-log/audit-log.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([RecoveryIncident])],
|
imports: [TypeOrmModule.forFeature([RecoveryIncident]), AuditLogModule],
|
||||||
controllers: [RecoveryIncidentController],
|
controllers: [RecoveryIncidentController],
|
||||||
providers: [RecoveryIncidentService],
|
providers: [RecoveryIncidentService],
|
||||||
exports: [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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { RecoveryIncident } from './entities/recovery-incident.entity';
|
import { RecoveryIncident } from './entities/recovery-incident.entity';
|
||||||
import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto';
|
import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto';
|
||||||
import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto';
|
import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto';
|
||||||
import { getTenantId } from '../../common/tenant/tenant.context';
|
import { getTenantId } from '../../common/tenant/tenant.context';
|
||||||
|
import { AuditLogService } from '../audit-log/audit-log.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RecoveryIncidentService {
|
export class RecoveryIncidentService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(RecoveryIncident)
|
@InjectRepository(RecoveryIncident)
|
||||||
private readonly recoveryIncidentRepo: Repository<RecoveryIncident>,
|
private readonly recoveryIncidentRepo: Repository<RecoveryIncident>,
|
||||||
|
private readonly auditLogService: AuditLogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(createDto: CreateRecoveryIncidentDto): Promise<RecoveryIncident> {
|
async create(createDto: CreateRecoveryIncidentDto): Promise<RecoveryIncident> {
|
||||||
@@ -21,6 +23,7 @@ export class RecoveryIncidentService {
|
|||||||
tenantId,
|
tenantId,
|
||||||
});
|
});
|
||||||
return this.recoveryIncidentRepo.save(incident);
|
return this.recoveryIncidentRepo.save(incident);
|
||||||
|
// CREATE is logged automatically by AuditInterceptor (POST handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(): Promise<RecoveryIncident[]> {
|
async findAll(): Promise<RecoveryIncident[]> {
|
||||||
@@ -45,19 +48,81 @@ export class RecoveryIncidentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, updateDto: UpdateRecoveryIncidentDto): Promise<RecoveryIncident> {
|
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 };
|
const updateData: any = { ...updateDto };
|
||||||
if (updateDto.date) {
|
if (updateDto.date) {
|
||||||
updateData.date = new Date(updateDto.date);
|
updateData.date = new Date(updateDto.date);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(incident, updateData);
|
Object.assign(before, updateData);
|
||||||
return this.recoveryIncidentRepo.save(incident);
|
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> {
|
async remove(id: string): Promise<void> {
|
||||||
const incident = await this.findOne(id);
|
const incident = await this.findOne(id);
|
||||||
|
const beforeSnapshot = { ...incident };
|
||||||
|
|
||||||
await this.recoveryIncidentRepo.remove(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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user