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 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 { 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],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user