diff --git a/database/schema.sql b/database/schema.sql index 4e0ab83..ab236ae 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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 ───────────────────────────────────────────────────────────── diff --git a/database/seed-master-data.sql b/database/seed-master-data.sql index 8bc9cd1..360c1e1 100644 --- a/database/seed-master-data.sql +++ b/database/seed-master-data.sql @@ -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); diff --git a/src/app.module.ts b/src/app.module.ts index a8e13cc..00d7825 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { DatabaseModule } from './database/database.module'; import { HealthModule } from './modules/health/health.module'; @@ -8,6 +9,8 @@ import { TenantModule } from './modules/tenant/tenant.module'; import { TenantMiddleware } from './common/tenant/tenant.middleware'; import { PolicyEngineModule } from './modules/policy-engine/policy-engine.module'; import { RecoveryIncidentModule } from './modules/recovery-incident/recovery-incident.module'; +import { AuditLogModule } from './modules/audit-log/audit-log.module'; +import { AuditInterceptor } from './common/interceptors/audit.interceptor'; const env = process.env.NODE_ENV; const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', '.env']; @@ -25,6 +28,13 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', CohortModule, PolicyEngineModule, RecoveryIncidentModule, + AuditLogModule, + ], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/src/common/interceptors/audit.interceptor.ts b/src/common/interceptors/audit.interceptor.ts new file mode 100644 index 0000000..cb95d4e --- /dev/null +++ b/src/common/interceptors/audit.interceptor.ts @@ -0,0 +1,88 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + Logger, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { Request } from 'express'; +import { AuditLogService } from '../../modules/audit-log/audit-log.service'; + +// Methods that mutate data — only these are audited +const MUTABLE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; + +// Map: [HTTP method, URL pattern] → { module, action } +// The interceptor only handles CREATE (POST) since UPDATE/DELETE +// need before-snapshots which are captured directly in each service. +const MODULE_MAP: Array<{ pattern: RegExp; module: string }> = [ + { pattern: /policy-engine/, module: 'policy-engine' }, + { pattern: /recovery-incidents/, module: 'recovery-incident' }, + { pattern: /cohort/, module: 'cohort' }, + { pattern: /master-data/, module: 'master-data' }, +]; + +function resolveModule(url: string): string { + for (const entry of MODULE_MAP) { + if (entry.pattern.test(url)) return entry.module; + } + return 'unknown'; +} + +function resolveAction(method: string, url: string): string { + if (method === 'POST') return 'CREATE'; + if (method === 'DELETE') return 'DELETE'; + if (method === 'PATCH' && url.includes('/status')) return 'STATUS_CHANGE'; + if (method === 'PATCH' || method === 'PUT') return 'UPDATE'; + return method; +} + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + private readonly logger = new Logger(AuditInterceptor.name); + + constructor(private readonly auditLogService: AuditLogService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const { method, ip, headers } = req; + const url = req.originalUrl || req.url; + + // Only intercept CREATE (POST) — UPDATE/DELETE handled in services + if (!MUTABLE_METHODS.includes(method) || method !== 'POST') { + return next.handle(); + } + + const module = resolveModule(url); + const action = resolveAction(method, url); + const userAgent = headers['user-agent'] ?? ''; + const tenantId = (headers['x-tenant-id'] as string) ?? 'unknown'; + + this.logger.debug(`Intercepted ${method} ${url} -> Module: ${module}, Action: ${action}`); + + return next.handle().pipe( + tap({ + next: async (responseData: any) => { + this.logger.debug(`Writing audit log for ${module} ${action}...`); + try { + await this.auditLogService.log({ + module, + action, + entityId: responseData?.id ?? undefined, + entityLabel: responseData?.name ?? responseData?.recoveryCode ?? responseData?.flightNumber ?? undefined, + before: undefined, // no before on CREATE + after: responseData ?? undefined, + performedBy: tenantId, + ipAddress: ip, + userAgent: String(userAgent), + }); + } catch (err) { + // Never let audit failure break the main request + this.logger.warn(`Audit log failed for ${method} ${url}: ${err}`); + } + }, + }), + ); + } +} diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 056dfd6..5b6bf23 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -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) { diff --git a/src/modules/audit-log/audit-log.controller.ts b/src/modules/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..1d87c08 --- /dev/null +++ b/src/modules/audit-log/audit-log.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + Param, + Query, + NotFoundException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { AuditLogService } from './audit-log.service'; + +@ApiTags('Audit Logs') +@Controller('audit-logs') +export class AuditLogController { + constructor(private readonly auditLogService: AuditLogService) {} + + @Get() + @ApiOperation({ summary: 'Get paginated audit logs with optional filters' }) + findAll( + @Query('page') page: string = '1', + @Query('limit') limit: string = '20', + @Query('module') module?: string, + @Query('action') action?: string, + @Query('entityId') entityId?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + return this.auditLogService.findAll({ + page: Number(page), + limit: Number(limit), + module, + action, + entityId, + dateFrom, + dateTo, + }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single audit log entry by ID' }) + async findOne(@Param('id') id: string) { + const log = await this.auditLogService.findOne(id); + if (!log) { + throw new NotFoundException(`Audit log entry ${id} not found`); + } + return log; + } +} diff --git a/src/modules/audit-log/audit-log.module.ts b/src/modules/audit-log/audit-log.module.ts new file mode 100644 index 0000000..1ad7a2b --- /dev/null +++ b/src/modules/audit-log/audit-log.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogService } from './audit-log.service'; +import { AuditLogController } from './audit-log.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditLogController], + providers: [AuditLogService], + exports: [AuditLogService], // exported so other modules can inject it +}) +export class AuditLogModule {} diff --git a/src/modules/audit-log/audit-log.service.ts b/src/modules/audit-log/audit-log.service.ts new file mode 100644 index 0000000..5a1ebc5 --- /dev/null +++ b/src/modules/audit-log/audit-log.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { AuditLog } from './entities/audit-log.entity'; +import { CreateAuditLogDto } from './dto/create-audit-log.dto'; +import { getTenantIdOrNull } from '../../common/tenant/tenant.context'; + +@Injectable() +export class AuditLogService { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + ) {} + + // ─── Write a single audit entry ────────────────────────────────────────── + // Called directly from services (for UPDATE/DELETE with before snapshots) + // and from the HTTP interceptor (for CREATE). + async log(dto: CreateAuditLogDto): Promise { + const tenantId = getTenantIdOrNull() ?? dto.performedBy ?? 'system'; + const entry = this.auditLogRepository.create({ + ...dto, + tenantId, + performedBy: dto.performedBy ?? tenantId, + }); + await this.auditLogRepository.save(entry); + } + + // ─── Paginated query with filters ──────────────────────────────────────── + async findAll(options: { + page?: number; + limit?: number; + module?: string; + action?: string; + entityId?: string; + dateFrom?: string; + dateTo?: string; + }) { + const { page = 1, limit = 20, module, action, entityId, dateFrom, dateTo } = options; + const skip = (page - 1) * limit; + const tenantId = getTenantIdOrNull(); + + const where: FindOptionsWhere = {}; + + if (tenantId) where.tenantId = tenantId; + if (module) where.module = module; + if (action) where.action = action; + if (entityId) where.entityId = entityId; + + if (dateFrom && dateTo) { + where.createdAt = Between(new Date(dateFrom), new Date(dateTo)) as any; + } else if (dateFrom) { + where.createdAt = MoreThanOrEqual(new Date(dateFrom)) as any; + } else if (dateTo) { + where.createdAt = LessThanOrEqual(new Date(dateTo)) as any; + } + + const [data, total] = await this.auditLogRepository.findAndCount({ + where, + skip, + take: limit, + order: { createdAt: 'DESC' }, + }); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + // ─── Single entry lookup ────────────────────────────────────────────────── + async findOne(id: string): Promise { + const tenantId = getTenantIdOrNull(); + const where: FindOptionsWhere = { id }; + if (tenantId) where.tenantId = tenantId; + return this.auditLogRepository.findOne({ where }); + } +} diff --git a/src/modules/audit-log/dto/create-audit-log.dto.ts b/src/modules/audit-log/dto/create-audit-log.dto.ts new file mode 100644 index 0000000..d23fa49 --- /dev/null +++ b/src/modules/audit-log/dto/create-audit-log.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator'; + +export class CreateAuditLogDto { + @IsString() + @IsNotEmpty() + module: string; + + @IsString() + @IsNotEmpty() + action: string; + + @IsString() + @IsOptional() + entityId?: string; + + @IsString() + @IsOptional() + entityLabel?: string; + + @IsObject() + @IsOptional() + before?: Record; + + @IsObject() + @IsOptional() + after?: Record; + + @IsString() + @IsOptional() + performedBy?: string; + + @IsString() + @IsOptional() + ipAddress?: string; + + @IsString() + @IsOptional() + userAgent?: string; +} diff --git a/src/modules/audit-log/entities/audit-log.entity.ts b/src/modules/audit-log/entities/audit-log.entity.ts new file mode 100644 index 0000000..f19d475 --- /dev/null +++ b/src/modules/audit-log/entities/audit-log.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'tbl_audit_logs', schema: 'audit' }) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'varchar', length: 255 }) + tenantId!: string; + + @Column({ type: 'varchar', length: 100 }) + module!: string; // 'policy-engine' | 'recovery-incident' | 'cohort' | 'master-data' + + @Column({ type: 'varchar', length: 50 }) + action!: string; // 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE' + + @Column({ name: 'entity_id', type: 'varchar', length: 255, nullable: true }) + entityId?: string; + + @Column({ name: 'entity_label', type: 'varchar', length: 255, nullable: true }) + entityLabel?: string; // human-readable name (policy name, flight #, etc.) + + @Column({ type: 'jsonb', nullable: true }) + before?: Record; // snapshot before change (null for CREATE) + + @Column({ type: 'jsonb', nullable: true }) + after?: Record; // snapshot after change (null for DELETE) + + @Column({ name: 'performed_by', type: 'varchar', length: 255, nullable: true }) + performedBy?: string; // tenantId for now; will be userId when auth is added + + @Column({ name: 'ip_address', type: 'varchar', length: 50, nullable: true }) + ipAddress?: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/src/modules/cohort/cohort.module.ts b/src/modules/cohort/cohort.module.ts index 46b6533..db72de7 100644 --- a/src/modules/cohort/cohort.module.ts +++ b/src/modules/cohort/cohort.module.ts @@ -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], diff --git a/src/modules/cohort/cohort.service.ts b/src/modules/cohort/cohort.service.ts index 8ede91a..e192ef0 100644 --- a/src/modules/cohort/cohort.service.ts +++ b/src/modules/cohort/cohort.service.ts @@ -5,12 +5,14 @@ import { getTenantId } from '../../common/tenant/tenant.context'; import { Cohort } from './cohort.entity'; import { CreateCohortDto } from './dto/create-cohort.dto'; import { UpdateCohortDto } from './dto/update-cohort.dto'; +import { AuditLogService } from '../audit-log/audit-log.service'; @Injectable() export class CohortService { constructor( @InjectRepository(Cohort) private readonly cohortRepository: Repository, + private readonly auditLogService: AuditLogService, ) {} private mapDtoToEntity(dto: CreateCohortDto | UpdateCohortDto) { @@ -96,19 +98,56 @@ export class CohortService { async update(id: string, updateCohortDto: UpdateCohortDto): Promise { const cohort = await this.findOne(id); + const beforeSnapshot = { ...cohort }; const mappedData = this.mapDtoToEntity(updateCohortDto); Object.assign(cohort, mappedData); - return this.cohortRepository.save(cohort); + const after = await this.cohortRepository.save(cohort); + + await this.auditLogService.log({ + module: 'cohort', + action: 'UPDATE', + entityId: id, + entityLabel: cohort.name, + before: beforeSnapshot, + after: { ...after }, + performedBy: getTenantId(), + }); + + return after; } async updateStatus(id: string, status: string): Promise { const cohort = await this.findOne(id); + const beforeStatus = cohort.status; cohort.status = status; - return this.cohortRepository.save(cohort); + const after = await this.cohortRepository.save(cohort); + + await this.auditLogService.log({ + module: 'cohort', + action: 'STATUS_CHANGE', + entityId: id, + entityLabel: cohort.name, + before: { status: beforeStatus }, + after: { status }, + performedBy: getTenantId(), + }); + + return after; } async remove(id: string): Promise { const cohort = await this.findOne(id); + const beforeSnapshot = { ...cohort }; await this.cohortRepository.remove(cohort); + + await this.auditLogService.log({ + module: 'cohort', + action: 'DELETE', + entityId: id, + entityLabel: beforeSnapshot.name, + before: beforeSnapshot, + after: undefined, + performedBy: getTenantId(), + }); } } diff --git a/src/modules/master-data/entities/airline-preference.entity.ts b/src/modules/master-data/entities/airline-preference.entity.ts new file mode 100644 index 0000000..adf5e11 --- /dev/null +++ b/src/modules/master-data/entities/airline-preference.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/airport-restriction.entity.ts b/src/modules/master-data/entities/airport-restriction.entity.ts new file mode 100644 index 0000000..0f2ee32 --- /dev/null +++ b/src/modules/master-data/entities/airport-restriction.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/applicable-product.entity.ts b/src/modules/master-data/entities/applicable-product.entity.ts new file mode 100644 index 0000000..09738e8 --- /dev/null +++ b/src/modules/master-data/entities/applicable-product.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/approval-level.entity.ts b/src/modules/master-data/entities/approval-level.entity.ts new file mode 100644 index 0000000..2c297e9 --- /dev/null +++ b/src/modules/master-data/entities/approval-level.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/baggage-type.entity.ts b/src/modules/master-data/entities/baggage-type.entity.ts new file mode 100644 index 0000000..5400396 --- /dev/null +++ b/src/modules/master-data/entities/baggage-type.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/cabin-preference.entity.ts b/src/modules/master-data/entities/cabin-preference.entity.ts new file mode 100644 index 0000000..ff0087c --- /dev/null +++ b/src/modules/master-data/entities/cabin-preference.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/compensation-basis.entity.ts b/src/modules/master-data/entities/compensation-basis.entity.ts new file mode 100644 index 0000000..a228595 --- /dev/null +++ b/src/modules/master-data/entities/compensation-basis.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/compensation-rule.entity.ts b/src/modules/master-data/entities/compensation-rule.entity.ts new file mode 100644 index 0000000..53f6b8c --- /dev/null +++ b/src/modules/master-data/entities/compensation-rule.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/destination-type.entity.ts b/src/modules/master-data/entities/destination-type.entity.ts new file mode 100644 index 0000000..d8596e5 --- /dev/null +++ b/src/modules/master-data/entities/destination-type.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/eligible-fare-type.entity.ts b/src/modules/master-data/entities/eligible-fare-type.entity.ts new file mode 100644 index 0000000..ec37f49 --- /dev/null +++ b/src/modules/master-data/entities/eligible-fare-type.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/meal-category.entity.ts b/src/modules/master-data/entities/meal-category.entity.ts new file mode 100644 index 0000000..fa8969b --- /dev/null +++ b/src/modules/master-data/entities/meal-category.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/meal-type.entity.ts b/src/modules/master-data/entities/meal-type.entity.ts new file mode 100644 index 0000000..5d61928 --- /dev/null +++ b/src/modules/master-data/entities/meal-type.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/room-type.entity.ts b/src/modules/master-data/entities/room-type.entity.ts new file mode 100644 index 0000000..3d5cbf2 --- /dev/null +++ b/src/modules/master-data/entities/room-type.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/seat-category.entity.ts b/src/modules/master-data/entities/seat-category.entity.ts new file mode 100644 index 0000000..4a484a0 --- /dev/null +++ b/src/modules/master-data/entities/seat-category.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/tax-type.entity.ts b/src/modules/master-data/entities/tax-type.entity.ts new file mode 100644 index 0000000..cd0a4bd --- /dev/null +++ b/src/modules/master-data/entities/tax-type.entity.ts @@ -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; +} diff --git a/src/modules/master-data/entities/vehicle-category.entity.ts b/src/modules/master-data/entities/vehicle-category.entity.ts new file mode 100644 index 0000000..decaccb --- /dev/null +++ b/src/modules/master-data/entities/vehicle-category.entity.ts @@ -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; +} diff --git a/src/modules/master-data/master-data.module.ts b/src/modules/master-data/master-data.module.ts index 7872e55..84b271e 100644 --- a/src/modules/master-data/master-data.module.ts +++ b/src/modules/master-data/master-data.module.ts @@ -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, diff --git a/src/modules/master-data/master-data.service.ts b/src/modules/master-data/master-data.service.ts index 3daae44..77d3ae4 100644 --- a/src/modules/master-data/master-data.service.ts +++ b/src/modules/master-data/master-data.service.ts @@ -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, @InjectRepository(RefundMethod) private refundMethodRepo: Repository, @InjectRepository(AmountType) private amountTypeRepo: Repository, + @InjectRepository(BaggageType) private baggageTypeRepo: Repository, + @InjectRepository(CompensationRule) private compensationRuleRepo: Repository, + @InjectRepository(ApprovalLevel) private approvalLevelRepo: Repository, + @InjectRepository(EligibleFareType) private eligibleFareTypeRepo: Repository, + @InjectRepository(ApplicableProduct) private applicableProductRepo: Repository, + @InjectRepository(MealType) private mealTypeRepo: Repository, + @InjectRepository(AirportRestriction) private airportRestrictionRepo: Repository, + @InjectRepository(DestinationType) private destinationTypeRepo: Repository, + @InjectRepository(VehicleCategory) private vehicleCategoryRepo: Repository, + @InjectRepository(CabinPreference) private cabinPreferenceRepo: Repository, + @InjectRepository(AirlinePreference) private airlinePreferenceRepo: Repository, + @InjectRepository(MealCategory) private mealCategoryRepo: Repository, + @InjectRepository(SeatCategory) private seatCategoryRepo: Repository, + @InjectRepository(CompensationBasis) private compensationBasisRepo: Repository, + @InjectRepository(TaxType) private taxTypeRepo: Repository, + @InjectRepository(RoomType) private roomTypeRepo: Repository, @InjectRepository(ConditionGroup) private conditionGroupRepo: Repository, @InjectRepository(ConditionField) private conditionFieldRepo: Repository, @InjectRepository(RuleCategoryGroup) private ruleCategoryGroupRepo: Repository, @@ -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' }, ]; } diff --git a/src/modules/policy-engine/policy-engine.module.ts b/src/modules/policy-engine/policy-engine.module.ts index e097376..6322699 100644 --- a/src/modules/policy-engine/policy-engine.module.ts +++ b/src/modules/policy-engine/policy-engine.module.ts @@ -9,6 +9,7 @@ import { RuleCondition } from './entities/rule-condition.entity'; import { PolicyAction } from './entities/policy-action.entity'; import { PolicyActionValue } from './entities/policy-action-value.entity'; import { MasterDataModule } from '../master-data/master-data.module'; +import { AuditLogModule } from '../audit-log/audit-log.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { MasterDataModule } from '../master-data/master-data.module'; PolicyActionValue, ]), MasterDataModule, + AuditLogModule, ], controllers: [PolicyEngineController], providers: [PolicyEngineService], @@ -28,3 +30,4 @@ import { MasterDataModule } from '../master-data/master-data.module'; }) export class PolicyEngineModule { } + diff --git a/src/modules/policy-engine/policy-engine.service.ts b/src/modules/policy-engine/policy-engine.service.ts index 848355b..ef3d3e3 100644 --- a/src/modules/policy-engine/policy-engine.service.ts +++ b/src/modules/policy-engine/policy-engine.service.ts @@ -12,6 +12,7 @@ import { CreatePolicyDto } from './dto/create-policy.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; import { PolicyStatus, AudienceType } from './entities/policy.enums'; import { Jurisdiction } from '../master-data/entities/jurisdiction.entity'; +import { AuditLogService } from '../audit-log/audit-log.service'; @Injectable() export class PolicyEngineService { @@ -20,6 +21,7 @@ export class PolicyEngineService { private readonly policyRepository: Repository, @InjectRepository(Jurisdiction) private readonly jurisdictionRepository: Repository, + private readonly auditLogService: AuditLogService, ) {} private getRelations() { @@ -41,15 +43,13 @@ export class PolicyEngineService { async create(createPolicyDto: CreatePolicyDto): Promise { const tenantId = getTenantId(); - - // Create the policy entity and cascade nested associations using TypeORM const policy = this.policyRepository.create({ ...createPolicyDto, tenantId, }); - const saved = await this.policyRepository.save(policy); return this.findOne(saved.id); + // CREATE is logged automatically by AuditInterceptor (POST handler) } async findAll( @@ -62,12 +62,8 @@ export class PolicyEngineService { const tenantId = getTenantId(); const whereClause: any = { tenantId }; - if (status) { - whereClause.status = status; - } - if (audienceType) { - whereClause.audienceType = audienceType; - } + if (status) whereClause.status = status; + if (audienceType) whereClause.audienceType = audienceType; const [data, total] = await this.policyRepository.findAndCount({ where: whereClause, @@ -101,41 +97,32 @@ export class PolicyEngineService { } async update(id: string, updatePolicyDto: UpdatePolicyDto): Promise { - // Ensure policy exists and belongs to the tenant - await this.findOne(id); + // Capture before snapshot + const before = await this.findOne(id); + const beforeSnapshot = { ...before }; const { targetAudiences, rules, ...policyData } = updatePolicyDto; - // Execute database operations in a transaction for clean replacement of nested objects - return this.policyRepository.manager.transaction(async (transactionalEntityManager) => { - // 1. Delete all existing target audiences associated with this policy + const after = await this.policyRepository.manager.transaction(async (transactionalEntityManager) => { await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id }); - // 2. Find and delete existing rules (cascades to conditions and actions/values) const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } }); if (existingRules.length > 0) { await transactionalEntityManager.remove(PolicyRule, existingRules); } - // 3. Fetch original policy again in transaction to ensure we have a fresh copy const policy = await transactionalEntityManager.findOneOrFail(Policy, { where: { id, tenantId: getTenantId() }, }); - // 4. Update policy basic columns Object.assign(policy, policyData); - // 5. Build new Target Audience entities if provided if (targetAudiences) { policy.targetAudiences = targetAudiences.map((target) => - transactionalEntityManager.create(PolicyTargetAudience, { - ...target, - policyId: id, - }), + transactionalEntityManager.create(PolicyTargetAudience, { ...target, policyId: id }), ); } - // 6. Build new Policy Rule entities with Conditions and Actions if provided if (rules) { policy.rules = rules.map((ruleDto) => { const conditions = ruleDto.conditions?.map((cond) => @@ -146,7 +133,6 @@ export class PolicyEngineService { const values = act.values?.map((val) => transactionalEntityManager.create(PolicyActionValue, val), ) || []; - return transactionalEntityManager.create(PolicyAction, { actionTypeId: act.actionTypeId, sequence: act.sequence, @@ -164,25 +150,61 @@ export class PolicyEngineService { }); } - // 7. Save updated policy and cascades const savedPolicy = await transactionalEntityManager.save(Policy, policy); - - // Reload updated policy with all relations and return return transactionalEntityManager.findOneOrFail(Policy, { where: { id: savedPolicy.id }, relations: this.getRelations(), }); }); + + // Log UPDATE with before/after snapshots + await this.auditLogService.log({ + module: 'policy-engine', + action: 'UPDATE', + entityId: id, + entityLabel: (before as any).name ?? id, + before: beforeSnapshot, + after: { ...after }, + performedBy: getTenantId(), + }); + + return after; } async updateStatus(id: string, status: PolicyStatus): Promise { const policy = await this.findOne(id); + const beforeStatus = policy.status; + policy.status = status; - return this.policyRepository.save(policy); + const after = await this.policyRepository.save(policy); + + await this.auditLogService.log({ + module: 'policy-engine', + action: 'STATUS_CHANGE', + entityId: id, + entityLabel: (policy as any).name ?? id, + before: { status: beforeStatus }, + after: { status }, + performedBy: getTenantId(), + }); + + return after; } async remove(id: string): Promise { const policy = await this.findOne(id); + const beforeSnapshot = { ...policy }; + await this.policyRepository.remove(policy); + + await this.auditLogService.log({ + module: 'policy-engine', + action: 'DELETE', + entityId: id, + entityLabel: (beforeSnapshot as any).name ?? id, + before: beforeSnapshot, + after: undefined, + performedBy: getTenantId(), + }); } } diff --git a/src/modules/recovery-incident/recovery-incident.module.ts b/src/modules/recovery-incident/recovery-incident.module.ts index e612e06..d2c81a7 100644 --- a/src/modules/recovery-incident/recovery-incident.module.ts +++ b/src/modules/recovery-incident/recovery-incident.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RecoveryIncidentService } from './recovery-incident.service'; import { RecoveryIncidentController } from './recovery-incident.controller'; import { RecoveryIncident } from './entities/recovery-incident.entity'; +import { AuditLogModule } from '../audit-log/audit-log.module'; @Module({ - imports: [TypeOrmModule.forFeature([RecoveryIncident])], + imports: [TypeOrmModule.forFeature([RecoveryIncident]), AuditLogModule], controllers: [RecoveryIncidentController], providers: [RecoveryIncidentService], exports: [RecoveryIncidentService], diff --git a/src/modules/recovery-incident/recovery-incident.service.ts b/src/modules/recovery-incident/recovery-incident.service.ts index 607eaa4..3c1137f 100644 --- a/src/modules/recovery-incident/recovery-incident.service.ts +++ b/src/modules/recovery-incident/recovery-incident.service.ts @@ -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, + private readonly auditLogService: AuditLogService, ) {} async create(createDto: CreateRecoveryIncidentDto): Promise { @@ -31,6 +33,7 @@ export class RecoveryIncidentService { tenantId, }); return this.recoveryIncidentRepo.save(incident); + // CREATE is logged automatically by AuditInterceptor (POST handler) } async findAll(): Promise { @@ -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 { - 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 { @@ -249,6 +266,54 @@ export class RecoveryIncidentService { async remove(id: string): Promise { const incident = await this.findOne(id); + const beforeSnapshot = { ...incident }; + await this.recoveryIncidentRepo.remove(incident); + + // Log DELETE — before = deleted record, after = null + await this.auditLogService.log({ + module: 'recovery-incident', + action: 'DELETE', + entityId: id, + entityLabel: beforeSnapshot.recoveryCode ?? beforeSnapshot.flightNumber, + before: beforeSnapshot, + after: undefined, + performedBy: getTenantId(), + }); + } + + // ─── AviationStack proxy ────────────────────────────────────────────────── + async flightLookup(flightIata: string): Promise { + const apiKey = process.env.AVIATION_STACK_API_KEY; + if (!apiKey) { + throw new HttpException('AviationStack API key not configured', HttpStatus.INTERNAL_SERVER_ERROR); + } + + const url = `http://api.aviationstack.com/v1/flights?access_key=${apiKey}&flight_iata=${encodeURIComponent(flightIata.trim())}&limit=1`; + + try { + const response = await fetch(url); + if (!response.ok) { + throw new HttpException( + `AviationStack responded with ${response.status}`, + HttpStatus.BAD_GATEWAY, + ); + } + + const json = await response.json(); + + if (json.error) { + throw new HttpException(json.error.info || 'AviationStack error', HttpStatus.BAD_GATEWAY); + } + + const flights: any[] = json.data ?? []; + return flights.length > 0 ? flights[0] : null; + } catch (err: any) { + if (err instanceof HttpException) throw err; + throw new HttpException( + `Failed to reach AviationStack: ${err.message}`, + HttpStatus.BAD_GATEWAY, + ); + } } }