Merge pull request 'azeem' (#23) from azeem into development

Reviewed-on: https://gitea.maskantech.in/gitea_admin/aeroresolve_backend/pulls/23
This commit is contained in:
Syed Waseem khadri Rafai
2026-08-11 09:42:22 +00:00
34 changed files with 1289 additions and 39 deletions
+27
View File
@@ -14,6 +14,33 @@ CREATE SCHEMA IF NOT EXISTS masters_action_builder;
CREATE SCHEMA IF NOT EXISTS masters_lookup;
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 ─────────────────────────────────────────────────────────────
+161
View File
@@ -3,6 +3,10 @@
-- PostgreSQL 14+
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS masters_lookup;
CREATE SCHEMA IF NOT EXISTS masters_rule_engine;
CREATE SCHEMA IF NOT EXISTS masters_action_builder;
-- 1. Action Categories
INSERT INTO masters_action_builder.tbl_action_categories (code, name, "displayOrder", "isActive")
SELECT code, name, displayOrder, true FROM (VALUES
@@ -224,6 +228,7 @@ SELECT label, value, true FROM (VALUES
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_jurisdictions WHERE masters_lookup.tbl_jurisdictions.value = t.value);
-- 12. Rule Categories (Business Rule Categories)
DELETE FROM masters_rule_engine.tbl_rules_categories
WHERE code NOT IN (
@@ -695,5 +700,161 @@ ON CONFLICT (code) DO UPDATE SET
"operatorType" = EXCLUDED."operatorType",
"displayOrder" = EXCLUDED."displayOrder";
-- 38. Baggage Types
INSERT INTO masters_lookup.tbl_baggage_types (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Checked Bag', 'checked-bag'),
('Cabin Bag', 'cabin-bag'),
('Oversized Baggage', 'oversized-baggage'),
('Sports Equipment', 'sports-equipment'),
('Special Baggage', 'special-baggage')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_baggage_types WHERE masters_lookup.tbl_baggage_types.value = t.value);
-- 39. Compensation Rules
INSERT INTO masters_lookup.tbl_compensation_rules (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('EU261', 'eu261'),
('UK261', 'uk261'),
('APPR', 'appr'),
('DGCA', 'dgca'),
('DOT', 'dot'),
('Airline Policy', 'airline-policy')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_compensation_rules WHERE masters_lookup.tbl_compensation_rules.value = t.value);
-- 40. Approval Levels
INSERT INTO masters_lookup.tbl_approval_levels (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Supervisor', 'supervisor'),
('Manager', 'manager'),
('Director', 'director')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_approval_levels WHERE masters_lookup.tbl_approval_levels.value = t.value);
-- 41. Eligible Fare Types
INSERT INTO masters_lookup.tbl_eligible_fare_types (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Published Fares', 'published-fares'),
('Promo Fares', 'promo-fares')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_eligible_fare_types WHERE masters_lookup.tbl_eligible_fare_types.value = t.value);
-- 42. Applicable Products
INSERT INTO masters_lookup.tbl_applicable_products (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Flights', 'flights'),
('Ancillary', 'ancillary'),
('Lounge', 'lounge')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_applicable_products WHERE masters_lookup.tbl_applicable_products.value = t.value);
-- 43. Meal Types
INSERT INTO masters_lookup.tbl_meal_types (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Breakfast', 'breakfast'),
('Lunch', 'lunch'),
('Dinner', 'dinner'),
('Snack', 'snack'),
('Any', 'any')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_meal_types WHERE masters_lookup.tbl_meal_types.value = t.value);
-- 44. Airport Restrictions
INSERT INTO masters_lookup.tbl_airport_restrictions (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Departure', 'departure'),
('Transit', 'transit'),
('Arrival', 'arrival'),
('Any Airport', 'any-airport')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_airport_restrictions WHERE masters_lookup.tbl_airport_restrictions.value = t.value);
-- 45. Destination Types
INSERT INTO masters_lookup.tbl_destination_types (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Home', 'home'),
('Hotel', 'hotel'),
('Airport', 'airport'),
('City Center', 'city-center')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_destination_types WHERE masters_lookup.tbl_destination_types.value = t.value);
-- 46. Vehicle Categories
INSERT INTO masters_lookup.tbl_vehicle_categories (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Executive Sedan', 'executive-sedan'),
('Luxury SUV', 'luxury-suv'),
('Premium Van', 'premium-van')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_vehicle_categories WHERE masters_lookup.tbl_vehicle_categories.value = t.value);
-- 47. Cabin Preferences
INSERT INTO masters_lookup.tbl_cabin_preferences (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Same Cabin', 'same-cabin'),
('Upgrade if Available', 'upgrade-if-available'),
('Lowest Available', 'lowest-available')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_cabin_preferences WHERE masters_lookup.tbl_cabin_preferences.value = t.value);
-- 48. Airline Preferences
INSERT INTO masters_lookup.tbl_airline_preferences (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Same Airline', 'same-airline'),
('Alliance Airline', 'alliance-airline'),
('Any Partner', 'any-partner')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_airline_preferences WHERE masters_lookup.tbl_airline_preferences.value = t.value);
-- 49. Meal Categories
INSERT INTO masters_lookup.tbl_meal_categories (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Vegetarian', 'vegetarian'),
('Non-Vegetarian', 'non-vegetarian'),
('Vegan', 'vegan'),
('Child Meal', 'child-meal')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_meal_categories WHERE masters_lookup.tbl_meal_categories.value = t.value);
-- 50. Seat Categories
INSERT INTO masters_lookup.tbl_seat_categories (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Standard', 'standard'),
('Preferred', 'preferred'),
('Exit Row', 'exit-row'),
('Extra Legroom', 'extra-legroom')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_seat_categories WHERE masters_lookup.tbl_seat_categories.value = t.value);
-- 51. Compensation Bases
INSERT INTO masters_lookup.tbl_compensation_bases (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Regulatory Rule', 'regulatory-rule'),
('Airline Policy', 'airline-policy'),
('Percentage Difference', 'percentage-difference')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_compensation_bases WHERE masters_lookup.tbl_compensation_bases.value = t.value);
-- 52. Tax Types
INSERT INTO masters_lookup.tbl_tax_types (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Airport Tax', 'airport-tax'),
('Fuel Surcharge', 'fuel-surcharge'),
('Government Tax', 'government-tax'),
('Service Tax', 'service-tax'),
('VAT', 'vat'),
('GST', 'gst'),
('Airport Development Fee', 'airport-development-fee')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_tax_types WHERE masters_lookup.tbl_tax_types.value = t.value);
-- 53. Room Types
INSERT INTO masters_lookup.tbl_room_types (label, value, "isActive")
SELECT label, value, true FROM (VALUES
('Single', 'single'),
('Double', 'double'),
('Twin', 'twin'),
('Family', 'family')
) AS t(label, value)
WHERE NOT EXISTS (SELECT 1 FROM masters_lookup.tbl_room_types WHERE masters_lookup.tbl_room_types.value = t.value);
+10
View File
@@ -1,4 +1,5 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './modules/health/health.module';
@@ -8,6 +9,8 @@ import { TenantModule } from './modules/tenant/tenant.module';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module';
import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module';
import { AuditLogModule } from './modules/audit-log/audit-log.module';
import { AuditInterceptor } from './common/interceptors/audit.interceptor';
const env = process.env.NODE_ENV;
const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', '.env'];
@@ -25,6 +28,13 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local',
CohortModule,
PolicyEngineModule,
RecoveryIncidentModule,
AuditLogModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: AuditInterceptor,
},
],
})
export class AppModule implements NestModule {
@@ -0,0 +1,88 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request } from 'express';
import { AuditLogService } from '../../modules/audit-log/audit-log.service';
// Methods that mutate data — only these are audited
const MUTABLE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
// Map: [HTTP method, URL pattern] → { module, action }
// The interceptor only handles CREATE (POST) since UPDATE/DELETE
// need before-snapshots which are captured directly in each service.
const MODULE_MAP: Array<{ pattern: RegExp; module: string }> = [
{ pattern: /policy-engine/, module: 'policy-engine' },
{ pattern: /recovery-incidents/, module: 'recovery-incident' },
{ pattern: /cohort/, module: 'cohort' },
{ pattern: /master-data/, module: 'master-data' },
];
function resolveModule(url: string): string {
for (const entry of MODULE_MAP) {
if (entry.pattern.test(url)) return entry.module;
}
return 'unknown';
}
function resolveAction(method: string, url: string): string {
if (method === 'POST') return 'CREATE';
if (method === 'DELETE') return 'DELETE';
if (method === 'PATCH' && url.includes('/status')) return 'STATUS_CHANGE';
if (method === 'PATCH' || method === 'PUT') return 'UPDATE';
return method;
}
@Injectable()
export class AuditInterceptor implements NestInterceptor {
private readonly logger = new Logger(AuditInterceptor.name);
constructor(private readonly auditLogService: AuditLogService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest<Request>();
const { method, ip, headers } = req;
const url = req.originalUrl || req.url;
// Only intercept CREATE (POST) — UPDATE/DELETE handled in services
if (!MUTABLE_METHODS.includes(method) || method !== 'POST') {
return next.handle();
}
const module = resolveModule(url);
const action = resolveAction(method, url);
const userAgent = headers['user-agent'] ?? '';
const tenantId = (headers['x-tenant-id'] as string) ?? 'unknown';
this.logger.debug(`Intercepted ${method} ${url} -> Module: ${module}, Action: ${action}`);
return next.handle().pipe(
tap({
next: async (responseData: any) => {
this.logger.debug(`Writing audit log for ${module} ${action}...`);
try {
await this.auditLogService.log({
module,
action,
entityId: responseData?.id ?? undefined,
entityLabel: responseData?.name ?? responseData?.recoveryCode ?? responseData?.flightNumber ?? undefined,
before: undefined, // no before on CREATE
after: responseData ?? undefined,
performedBy: tenantId,
ipAddress: ip,
userAgent: String(userAgent),
});
} catch (err) {
// Never let audit failure break the main request
this.logger.warn(`Audit log failed for ${method} ${url}: ${err}`);
}
},
}),
);
}
}
+1
View File
@@ -40,6 +40,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "cohort";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "policy_engine";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "recovery_incident";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "audit";`);
// Run synchronization manually if it was enabled
if (options.synchronize) {
@@ -0,0 +1,47 @@
import {
Controller,
Get,
Param,
Query,
NotFoundException,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuditLogService } from './audit-log.service';
@ApiTags('Audit Logs')
@Controller('audit-logs')
export class AuditLogController {
constructor(private readonly auditLogService: AuditLogService) {}
@Get()
@ApiOperation({ summary: 'Get paginated audit logs with optional filters' })
findAll(
@Query('page') page: string = '1',
@Query('limit') limit: string = '20',
@Query('module') module?: string,
@Query('action') action?: string,
@Query('entityId') entityId?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
return this.auditLogService.findAll({
page: Number(page),
limit: Number(limit),
module,
action,
entityId,
dateFrom,
dateTo,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single audit log entry by ID' })
async findOne(@Param('id') id: string) {
const log = await this.auditLogService.findOne(id);
if (!log) {
throw new NotFoundException(`Audit log entry ${id} not found`);
}
return log;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from './entities/audit-log.entity';
import { AuditLogService } from './audit-log.service';
import { AuditLogController } from './audit-log.controller';
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
controllers: [AuditLogController],
providers: [AuditLogService],
exports: [AuditLogService], // exported so other modules can inject it
})
export class AuditLogModule {}
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, FindOptionsWhere, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { AuditLog } from './entities/audit-log.entity';
import { CreateAuditLogDto } from './dto/create-audit-log.dto';
import { getTenantIdOrNull } from '../../common/tenant/tenant.context';
@Injectable()
export class AuditLogService {
constructor(
@InjectRepository(AuditLog)
private readonly auditLogRepository: Repository<AuditLog>,
) {}
// ─── Write a single audit entry ──────────────────────────────────────────
// Called directly from services (for UPDATE/DELETE with before snapshots)
// and from the HTTP interceptor (for CREATE).
async log(dto: CreateAuditLogDto): Promise<void> {
const tenantId = getTenantIdOrNull() ?? dto.performedBy ?? 'system';
const entry = this.auditLogRepository.create({
...dto,
tenantId,
performedBy: dto.performedBy ?? tenantId,
});
await this.auditLogRepository.save(entry);
}
// ─── Paginated query with filters ────────────────────────────────────────
async findAll(options: {
page?: number;
limit?: number;
module?: string;
action?: string;
entityId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const { page = 1, limit = 20, module, action, entityId, dateFrom, dateTo } = options;
const skip = (page - 1) * limit;
const tenantId = getTenantIdOrNull();
const where: FindOptionsWhere<AuditLog> = {};
if (tenantId) where.tenantId = tenantId;
if (module) where.module = module;
if (action) where.action = action;
if (entityId) where.entityId = entityId;
if (dateFrom && dateTo) {
where.createdAt = Between(new Date(dateFrom), new Date(dateTo)) as any;
} else if (dateFrom) {
where.createdAt = MoreThanOrEqual(new Date(dateFrom)) as any;
} else if (dateTo) {
where.createdAt = LessThanOrEqual(new Date(dateTo)) as any;
}
const [data, total] = await this.auditLogRepository.findAndCount({
where,
skip,
take: limit,
order: { createdAt: 'DESC' },
});
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
// ─── Single entry lookup ──────────────────────────────────────────────────
async findOne(id: string): Promise<AuditLog | null> {
const tenantId = getTenantIdOrNull();
const where: FindOptionsWhere<AuditLog> = { id };
if (tenantId) where.tenantId = tenantId;
return this.auditLogRepository.findOne({ where });
}
}
@@ -0,0 +1,39 @@
import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator';
export class CreateAuditLogDto {
@IsString()
@IsNotEmpty()
module: string;
@IsString()
@IsNotEmpty()
action: string;
@IsString()
@IsOptional()
entityId?: string;
@IsString()
@IsOptional()
entityLabel?: string;
@IsObject()
@IsOptional()
before?: Record<string, any>;
@IsObject()
@IsOptional()
after?: Record<string, any>;
@IsString()
@IsOptional()
performedBy?: string;
@IsString()
@IsOptional()
ipAddress?: string;
@IsString()
@IsOptional()
userAgent?: string;
}
@@ -0,0 +1,45 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_audit_logs', schema: 'audit' })
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'varchar', length: 255 })
tenantId!: string;
@Column({ type: 'varchar', length: 100 })
module!: string; // 'policy-engine' | 'recovery-incident' | 'cohort' | 'master-data'
@Column({ type: 'varchar', length: 50 })
action!: string; // 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE'
@Column({ name: 'entity_id', type: 'varchar', length: 255, nullable: true })
entityId?: string;
@Column({ name: 'entity_label', type: 'varchar', length: 255, nullable: true })
entityLabel?: string; // human-readable name (policy name, flight #, etc.)
@Column({ type: 'jsonb', nullable: true })
before?: Record<string, any>; // snapshot before change (null for CREATE)
@Column({ type: 'jsonb', nullable: true })
after?: Record<string, any>; // snapshot after change (null for DELETE)
@Column({ name: 'performed_by', type: 'varchar', length: 255, nullable: true })
performedBy?: string; // tenantId for now; will be userId when auth is added
@Column({ name: 'ip_address', type: 'varchar', length: 50, nullable: true })
ipAddress?: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+3 -1
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CohortController } from './cohort.controller';
import { CohortService } from './cohort.service';
import { Cohort } from './cohort.entity';
import { AuditLogModule } from '../audit-log/audit-log.module';
@Module({
imports: [TypeOrmModule.forFeature([Cohort])],
imports: [TypeOrmModule.forFeature([Cohort]), AuditLogModule],
controllers: [CohortController],
providers: [CohortService],
exports: [CohortService],
+41 -2
View File
@@ -5,12 +5,14 @@ import { getTenantId } from '../../common/tenant/tenant.context';
import { Cohort } from './cohort.entity';
import { CreateCohortDto } from './dto/create-cohort.dto';
import { UpdateCohortDto } from './dto/update-cohort.dto';
import { AuditLogService } from '../audit-log/audit-log.service';
@Injectable()
export class CohortService {
constructor(
@InjectRepository(Cohort)
private readonly cohortRepository: Repository<Cohort>,
private readonly auditLogService: AuditLogService,
) {}
private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) {
@@ -96,19 +98,56 @@ export class CohortService {
async update(id: string, updateCohortDto: UpdateCohortDto): Promise<Cohort> {
const cohort = await this.findOne(id);
const beforeSnapshot = { ...cohort };
const mappedData = this.mapDtoToEntity(updateCohortDto);
Object.assign(cohort, mappedData);
return this.cohortRepository.save(cohort);
const after = await this.cohortRepository.save(cohort);
await this.auditLogService.log({
module: 'cohort',
action: 'UPDATE',
entityId: id,
entityLabel: cohort.name,
before: beforeSnapshot,
after: { ...after },
performedBy: getTenantId(),
});
return after;
}
async updateStatus(id: string, status: string): Promise<Cohort> {
const cohort = await this.findOne(id);
const beforeStatus = cohort.status;
cohort.status = status;
return this.cohortRepository.save(cohort);
const after = await this.cohortRepository.save(cohort);
await this.auditLogService.log({
module: 'cohort',
action: 'STATUS_CHANGE',
entityId: id,
entityLabel: cohort.name,
before: { status: beforeStatus },
after: { status },
performedBy: getTenantId(),
});
return after;
}
async remove(id: string): Promise<void> {
const cohort = await this.findOne(id);
const beforeSnapshot = { ...cohort };
await this.cohortRepository.remove(cohort);
await this.auditLogService.log({
module: 'cohort',
action: 'DELETE',
entityId: id,
entityLabel: beforeSnapshot.name,
before: beforeSnapshot,
after: undefined,
performedBy: getTenantId(),
});
}
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_airline_preferences', schema: 'masters_lookup' })
export class AirlinePreference {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_airport_restrictions', schema: 'masters_lookup' })
export class AirportRestriction {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_applicable_products', schema: 'masters_lookup' })
export class ApplicableProduct {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_approval_levels', schema: 'masters_lookup' })
export class ApprovalLevel {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_baggage_types', schema: 'masters_lookup' })
export class BaggageType {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_cabin_preferences', schema: 'masters_lookup' })
export class CabinPreference {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_compensation_bases', schema: 'masters_lookup' })
export class CompensationBasis {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_compensation_rules', schema: 'masters_lookup' })
export class CompensationRule {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_destination_types', schema: 'masters_lookup' })
export class DestinationType {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_eligible_fare_types', schema: 'masters_lookup' })
export class EligibleFareType {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_meal_categories', schema: 'masters_lookup' })
export class MealCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_meal_types', schema: 'masters_lookup' })
export class MealType {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_room_types', schema: 'masters_lookup' })
export class RoomType {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_seat_categories', schema: 'masters_lookup' })
export class SeatCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_tax_types', schema: 'masters_lookup' })
export class TaxType {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'tbl_vehicle_categories', schema: 'masters_lookup' })
export class VehicleCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
label!: string;
@Column()
value!: string;
@Column({ default: true })
isActive!: boolean;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
}
@@ -44,6 +44,22 @@ import { RefundBasis } from './entities/refund-basis.entity';
import { Currency } from './entities/currency.entity';
import { RefundMethod } from './entities/refund-method.entity';
import { AmountType } from './entities/amount-type.entity';
import { BaggageType } from './entities/baggage-type.entity';
import { CompensationRule } from './entities/compensation-rule.entity';
import { ApprovalLevel } from './entities/approval-level.entity';
import { EligibleFareType } from './entities/eligible-fare-type.entity';
import { ApplicableProduct } from './entities/applicable-product.entity';
import { MealType } from './entities/meal-type.entity';
import { AirportRestriction } from './entities/airport-restriction.entity';
import { DestinationType } from './entities/destination-type.entity';
import { VehicleCategory } from './entities/vehicle-category.entity';
import { CabinPreference } from './entities/cabin-preference.entity';
import { AirlinePreference } from './entities/airline-preference.entity';
import { MealCategory } from './entities/meal-category.entity';
import { SeatCategory } from './entities/seat-category.entity';
import { CompensationBasis } from './entities/compensation-basis.entity';
import { TaxType } from './entities/tax-type.entity';
import { RoomType } from './entities/room-type.entity';
import { ConditionGroup } from './entities/condition-group.entity';
import { ConditionField } from './entities/condition-field.entity';
@@ -92,6 +108,22 @@ import { RuleCategoryGroup } from './entities/rule-category-group.entity';
Currency,
RefundMethod,
AmountType,
BaggageType,
CompensationRule,
ApprovalLevel,
EligibleFareType,
ApplicableProduct,
MealType,
AirportRestriction,
DestinationType,
VehicleCategory,
CabinPreference,
AirlinePreference,
MealCategory,
SeatCategory,
CompensationBasis,
TaxType,
RoomType,
ConditionGroup,
ConditionField,
RuleCategoryGroup,
@@ -45,6 +45,22 @@ import { RefundBasis } from './entities/refund-basis.entity';
import { Currency } from './entities/currency.entity';
import { RefundMethod } from './entities/refund-method.entity';
import { AmountType } from './entities/amount-type.entity';
import { BaggageType } from './entities/baggage-type.entity';
import { CompensationRule } from './entities/compensation-rule.entity';
import { ApprovalLevel } from './entities/approval-level.entity';
import { EligibleFareType } from './entities/eligible-fare-type.entity';
import { ApplicableProduct } from './entities/applicable-product.entity';
import { MealType } from './entities/meal-type.entity';
import { AirportRestriction } from './entities/airport-restriction.entity';
import { DestinationType } from './entities/destination-type.entity';
import { VehicleCategory } from './entities/vehicle-category.entity';
import { CabinPreference } from './entities/cabin-preference.entity';
import { AirlinePreference } from './entities/airline-preference.entity';
import { MealCategory } from './entities/meal-category.entity';
import { SeatCategory } from './entities/seat-category.entity';
import { CompensationBasis } from './entities/compensation-basis.entity';
import { TaxType } from './entities/tax-type.entity';
import { RoomType } from './entities/room-type.entity';
import { ConditionGroup } from './entities/condition-group.entity';
import { ConditionField } from './entities/condition-field.entity';
import { RuleCategoryGroup } from './entities/rule-category-group.entity';
@@ -104,6 +120,22 @@ export class MasterDataService implements OnModuleInit {
@InjectRepository(Currency) private currencyRepo: Repository<Currency>,
@InjectRepository(RefundMethod) private refundMethodRepo: Repository<RefundMethod>,
@InjectRepository(AmountType) private amountTypeRepo: Repository<AmountType>,
@InjectRepository(BaggageType) private baggageTypeRepo: Repository<BaggageType>,
@InjectRepository(CompensationRule) private compensationRuleRepo: Repository<CompensationRule>,
@InjectRepository(ApprovalLevel) private approvalLevelRepo: Repository<ApprovalLevel>,
@InjectRepository(EligibleFareType) private eligibleFareTypeRepo: Repository<EligibleFareType>,
@InjectRepository(ApplicableProduct) private applicableProductRepo: Repository<ApplicableProduct>,
@InjectRepository(MealType) private mealTypeRepo: Repository<MealType>,
@InjectRepository(AirportRestriction) private airportRestrictionRepo: Repository<AirportRestriction>,
@InjectRepository(DestinationType) private destinationTypeRepo: Repository<DestinationType>,
@InjectRepository(VehicleCategory) private vehicleCategoryRepo: Repository<VehicleCategory>,
@InjectRepository(CabinPreference) private cabinPreferenceRepo: Repository<CabinPreference>,
@InjectRepository(AirlinePreference) private airlinePreferenceRepo: Repository<AirlinePreference>,
@InjectRepository(MealCategory) private mealCategoryRepo: Repository<MealCategory>,
@InjectRepository(SeatCategory) private seatCategoryRepo: Repository<SeatCategory>,
@InjectRepository(CompensationBasis) private compensationBasisRepo: Repository<CompensationBasis>,
@InjectRepository(TaxType) private taxTypeRepo: Repository<TaxType>,
@InjectRepository(RoomType) private roomTypeRepo: Repository<RoomType>,
@InjectRepository(ConditionGroup) private conditionGroupRepo: Repository<ConditionGroup>,
@InjectRepository(ConditionField) private conditionFieldRepo: Repository<ConditionField>,
@InjectRepository(RuleCategoryGroup) private ruleCategoryGroupRepo: Repository<RuleCategoryGroup>,
@@ -258,6 +290,85 @@ export class MasterDataService implements OnModuleInit {
case 'TBL_AMOUNT_TYPES':
return this.amountTypeRepo;
case 'BAGGAGE_TYPE':
case 'BAGGAGE_TYPES':
case 'TBL_BAGGAGE_TYPES':
return this.baggageTypeRepo;
case 'COMPENSATION_RULE':
case 'COMPENSATION_RULES':
case 'TBL_COMPENSATION_RULES':
return this.compensationRuleRepo;
case 'APPROVAL_LEVEL':
case 'APPROVAL_LEVELS':
case 'TBL_APPROVAL_LEVELS':
return this.approvalLevelRepo;
case 'ELIGIBLE_FARE_TYPE':
case 'ELIGIBLE_FARE_TYPES':
case 'TBL_ELIGIBLE_FARE_TYPES':
return this.eligibleFareTypeRepo;
case 'APPLICABLE_PRODUCT':
case 'APPLICABLE_PRODUCTS':
case 'TBL_APPLICABLE_PRODUCTS':
return this.applicableProductRepo;
case 'MEAL_TYPE':
case 'MEAL_TYPES':
case 'TBL_MEAL_TYPES':
return this.mealTypeRepo;
case 'AIRPORT_RESTRICTION':
case 'AIRPORT_RESTRICTIONS':
case 'TBL_AIRPORT_RESTRICTIONS':
return this.airportRestrictionRepo;
case 'DESTINATION_TYPE':
case 'DESTINATION_TYPES':
case 'TBL_DESTINATION_TYPES':
return this.destinationTypeRepo;
case 'VEHICLE_CATEGORY':
case 'VEHICLE_CATEGORIES':
case 'TBL_VEHICLE_CATEGORIES':
return this.vehicleCategoryRepo;
case 'CABIN_PREFERENCE':
case 'CABIN_PREFERENCES':
case 'TBL_CABIN_PREFERENCES':
return this.cabinPreferenceRepo;
case 'AIRLINE_PREFERENCE':
case 'AIRLINE_PREFERENCES':
case 'TBL_AIRLINE_PREFERENCES':
return this.airlinePreferenceRepo;
case 'MEAL_CATEGORY':
case 'MEAL_CATEGORIES':
case 'TBL_MEAL_CATEGORIES':
return this.mealCategoryRepo;
case 'SEAT_CATEGORY':
case 'SEAT_CATEGORIES':
case 'TBL_SEAT_CATEGORIES':
return this.seatCategoryRepo;
case 'COMPENSATION_BASIS':
case 'TBL_COMPENSATION_BASES':
return this.compensationBasisRepo;
case 'TAX_TYPE':
case 'TAX_TYPES':
case 'TBL_TAX_TYPES':
return this.taxTypeRepo;
case 'ROOM_TYPE':
case 'ROOM_TYPES':
case 'TBL_ROOM_TYPES':
return this.roomTypeRepo;
default:
throw new BadRequestException(`Invalid category: ${category}`);
}
@@ -344,6 +455,22 @@ export class MasterDataService implements OnModuleInit {
{ code: 'currency', name: 'Currencies', tableName: 'tbl_currencies', description: 'Supported monetary currencies' },
{ code: 'refund-method', name: 'Refund Methods', tableName: 'tbl_refund_methods', description: 'Payment payout channels for refunds' },
{ code: 'amount-type', name: 'Amount Types', tableName: 'tbl_amount_types', description: 'Amount calculation types (percentage, fixed, formula)' },
{ code: 'baggage-type', name: 'Baggage Types', tableName: 'tbl_baggage_types', description: 'Baggage type classifications' },
{ code: 'compensation-rule', name: 'Compensation Rules', tableName: 'tbl_compensation_rules', description: 'Regulatory compensation rules' },
{ code: 'approval-level', name: 'Approval Levels', tableName: 'tbl_approval_levels', description: 'Action approval hierarchy levels' },
{ code: 'eligible-fare-type', name: 'Eligible Fare Types', tableName: 'tbl_eligible_fare_types', description: 'Fare types eligible for compensation' },
{ code: 'applicable-product', name: 'Applicable Products', tableName: 'tbl_applicable_products', description: 'Products applicable for refunds/compensation' },
{ code: 'meal-type', name: 'Meal Types', tableName: 'tbl_meal_types', description: 'Standard meal types' },
{ code: 'airport-restriction', name: 'Airport Restrictions', tableName: 'tbl_airport_restrictions', description: 'Restrictions for specific airports' },
{ code: 'destination-type', name: 'Destination Types', tableName: 'tbl_destination_types', description: 'Types of travel destinations' },
{ code: 'vehicle-category', name: 'Vehicle Categories', tableName: 'tbl_vehicle_categories', description: 'Ground transportation vehicle categories' },
{ code: 'cabin-preference', name: 'Cabin Preferences', tableName: 'tbl_cabin_preferences', description: 'Passenger cabin preferences' },
{ code: 'airline-preference', name: 'Airline Preferences', tableName: 'tbl_airline_preferences', description: 'Passenger airline rebooking preferences' },
{ code: 'meal-category', name: 'Meal Categories', tableName: 'tbl_meal_categories', description: 'Dietary meal categories' },
{ code: 'seat-category', name: 'Seat Categories', tableName: 'tbl_seat_categories', description: 'Aircraft seat categories' },
{ code: 'compensation-basis', name: 'Compensation Bases', tableName: 'tbl_compensation_bases', description: 'Basis for calculating compensation' },
{ code: 'tax-type', name: 'Tax Types', tableName: 'tbl_tax_types', description: 'Aviation and booking tax types' },
{ code: 'room-type', name: 'Room Types', tableName: 'tbl_room_types', description: 'Hotel accommodation room types' },
];
}
@@ -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,10 +1,11 @@
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';
export interface MetricCardData {
id?: string;
@@ -21,6 +22,7 @@ export class RecoveryIncidentService {
constructor(
@InjectRepository(RecoveryIncident)
private readonly recoveryIncidentRepo: Repository<RecoveryIncident>,
private readonly auditLogService: AuditLogService,
) {}
async create(createDto: CreateRecoveryIncidentDto): Promise<RecoveryIncident> {
@@ -31,6 +33,7 @@ export class RecoveryIncidentService {
tenantId,
});
return this.recoveryIncidentRepo.save(incident);
// CREATE is logged automatically by AuditInterceptor (POST handler)
}
async findAll(): Promise<RecoveryIncident[]> {
@@ -221,24 +224,38 @@ 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 updateStatus(id: string, status: string): Promise<RecoveryIncident> {
@@ -249,6 +266,54 @@ export class RecoveryIncidentService {
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,
);
}
}
}