feat: implement policy engine and recovery incident modules with evaluation logic and database schema

This commit is contained in:
Syed Waseem
2026-08-17 16:38:18 +05:30
parent 1d96290618
commit 5780b5cd47
21 changed files with 2261 additions and 68 deletions
+7 -1
View File
@@ -577,7 +577,6 @@ CREATE TABLE IF NOT EXISTS policy_engine.tbl_policies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE, "tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
policy_name VARCHAR(200) NOT NULL, policy_name VARCHAR(200) NOT NULL,
jurisdiction_id UUID REFERENCES masters_lookup.tbl_jurisdictions(id) ON DELETE SET NULL,
description TEXT, description TEXT,
status VARCHAR NOT NULL DEFAULT 'draft', status VARCHAR NOT NULL DEFAULT 'draft',
version INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 1,
@@ -588,6 +587,13 @@ CREATE TABLE IF NOT EXISTS policy_engine.tbl_policies (
updated_at TIMESTAMP NOT NULL DEFAULT now() updated_at TIMESTAMP NOT NULL DEFAULT now()
); );
CREATE TABLE IF NOT EXISTS policy_engine.tbl_policy_jurisdictions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID NOT NULL REFERENCES policy_engine.tbl_policies(id) ON DELETE CASCADE,
jurisdiction_id UUID NOT NULL REFERENCES masters_lookup.tbl_jurisdictions(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS policy_engine.tbl_policy_target_audiences ( CREATE TABLE IF NOT EXISTS policy_engine.tbl_policy_target_audiences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
policy_id UUID NOT NULL REFERENCES policy_engine.tbl_policies(id) ON DELETE CASCADE, policy_id UUID NOT NULL REFERENCES policy_engine.tbl_policies(id) ON DELETE CASCADE,
+1 -4
View File
@@ -29,10 +29,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
const dataSource = new DataSource({ ...options, synchronize: false }); const dataSource = new DataSource({ ...options, synchronize: false });
await dataSource.initialize(); await dataSource.initialize();
// Create schemas if they don't exist // Ensure schemas exist safely without dropping user created cohorts or policies
await dataSource.query(`DROP SCHEMA IF EXISTS "masters" CASCADE;`);
await dataSource.query(`DROP SCHEMA IF EXISTS "cohort" CASCADE;`);
await dataSource.query(`DROP SCHEMA IF EXISTS "policy_engine" CASCADE;`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "tenant";`); await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "tenant";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_rule_engine";`); await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_rule_engine";`);
await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_action_builder";`); await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_action_builder";`);
@@ -130,6 +130,11 @@ export class CreatePolicyDto {
@IsNotEmpty() @IsNotEmpty()
policyName: string; policyName: string;
@IsArray()
@IsOptional()
@IsUUID('all', { each: true })
jurisdictionIds?: string[];
@IsUUID() @IsUUID()
@IsOptional() @IsOptional()
jurisdictionId?: string; jurisdictionId?: string;
@@ -0,0 +1,23 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import type { Policy } from './policy.entity';
import { Jurisdiction } from '../../master-data/entities/jurisdiction.entity';
@Entity({ name: 'tbl_policy_jurisdictions', schema: 'policy_engine' })
export class PolicyJurisdiction {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'policy_id', type: 'uuid' })
policyId!: string;
@Column({ name: 'jurisdiction_id', type: 'uuid' })
jurisdictionId!: string;
@ManyToOne('Policy', 'jurisdictions', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'policy_id' })
policy!: Policy;
@ManyToOne(() => Jurisdiction, { eager: true, nullable: true })
@JoinColumn({ name: 'jurisdiction_id' })
jurisdiction?: Jurisdiction;
}
@@ -1,9 +1,9 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity'; import { TenantOwnedEntity } from '../../../common/entities/tenant-owned.entity';
import { PolicyStatus, AudienceType } from './policy.enums'; import { PolicyStatus, AudienceType } from './policy.enums';
import { PolicyTargetAudience } from './policy-target-audience.entity'; import { PolicyTargetAudience } from './policy-target-audience.entity';
import { PolicyRule } from './policy-rule.entity'; import { PolicyRule } from './policy-rule.entity';
import { Jurisdiction } from '../../master-data/entities/jurisdiction.entity'; import { PolicyJurisdiction } from './policy-jurisdiction.entity';
@Entity({ name: 'tbl_policies', schema: 'policy_engine' }) @Entity({ name: 'tbl_policies', schema: 'policy_engine' })
export class Policy extends TenantOwnedEntity { export class Policy extends TenantOwnedEntity {
@@ -13,9 +13,6 @@ export class Policy extends TenantOwnedEntity {
@Column({ name: 'policy_name', type: 'varchar', length: 200 }) @Column({ name: 'policy_name', type: 'varchar', length: 200 })
policyName!: string; policyName!: string;
@Column({ name: 'jurisdiction_id', type: 'uuid', nullable: true })
jurisdictionId!: string;
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
description!: string; description!: string;
@@ -50,13 +47,12 @@ export class Policy extends TenantOwnedEntity {
updatedAt!: Date; updatedAt!: Date;
// Relations // Relations
@OneToMany(() => PolicyJurisdiction, (pj) => pj.policy, { cascade: true, onDelete: 'CASCADE' })
jurisdictions!: PolicyJurisdiction[];
@OneToMany(() => PolicyTargetAudience, (target) => target.policy, { cascade: true, onDelete: 'CASCADE' }) @OneToMany(() => PolicyTargetAudience, (target) => target.policy, { cascade: true, onDelete: 'CASCADE' })
targetAudiences!: PolicyTargetAudience[]; targetAudiences!: PolicyTargetAudience[];
@OneToMany(() => PolicyRule, (rule) => rule.policy, { cascade: true, onDelete: 'CASCADE' }) @OneToMany(() => PolicyRule, (rule) => rule.policy, { cascade: true, onDelete: 'CASCADE' })
rules!: PolicyRule[]; rules!: PolicyRule[];
@ManyToOne(() => Jurisdiction, { nullable: true, eager: false, createForeignKeyConstraints: false })
@JoinColumn({ name: 'jurisdiction_id' })
jurisdiction!: Jurisdiction;
} }
@@ -1,13 +1,30 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { PolicyEngineService } from './policy-engine.service'; import { PolicyEngineService } from './policy-engine.service';
import { PolicyEvaluationService } from './policy-evaluation.service';
import { MasterValueResolverService } from './services/master-value-resolver.service';
import { CohortEvaluationService } from './services/cohort-evaluation.service';
import { PolicyApplicabilityService } from './services/policy-applicability.service';
import { RuleEvaluationService } from './services/rule-evaluation.service';
import { ActionResolverService } from './services/action-resolver.service';
import { PolicyEngineController } from './policy-engine.controller'; import { PolicyEngineController } from './policy-engine.controller';
import { Policy } from './entities/policy.entity'; import { Policy } from './entities/policy.entity';
import { PolicyJurisdiction } from './entities/policy-jurisdiction.entity';
import { PolicyTargetAudience } from './entities/policy-target-audience.entity'; import { PolicyTargetAudience } from './entities/policy-target-audience.entity';
import { PolicyRule } from './entities/policy-rule.entity'; import { PolicyRule } from './entities/policy-rule.entity';
import { RuleCondition } from './entities/rule-condition.entity'; import { RuleCondition } from './entities/rule-condition.entity';
import { PolicyAction } from './entities/policy-action.entity'; import { PolicyAction } from './entities/policy-action.entity';
import { PolicyActionValue } from './entities/policy-action-value.entity'; import { PolicyActionValue } from './entities/policy-action-value.entity';
import { Cohort } from '../cohort/cohort.entity';
import { MembershipTier } from '../master-data/entities/membership-tier.entity';
import { PassengerType } from '../master-data/entities/passenger-type.entity';
import { CabinClass } from '../master-data/entities/cabin-class.entity';
import { Jurisdiction } from '../master-data/entities/jurisdiction.entity';
import { ConditionField } from '../master-data/entities/condition-field.entity';
import { Operator } from '../master-data/entities/operator.entity';
import { MasterDataModule } from '../master-data/master-data.module'; import { MasterDataModule } from '../master-data/master-data.module';
import { AuditLogModule } from '../audit-log/audit-log.module'; import { AuditLogModule } from '../audit-log/audit-log.module';
@@ -15,19 +32,41 @@ import { AuditLogModule } from '../audit-log/audit-log.module';
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
Policy, Policy,
PolicyJurisdiction,
PolicyTargetAudience, PolicyTargetAudience,
PolicyRule, PolicyRule,
RuleCondition, RuleCondition,
PolicyAction, PolicyAction,
PolicyActionValue, PolicyActionValue,
Cohort,
MembershipTier,
PassengerType,
CabinClass,
Jurisdiction,
ConditionField,
Operator,
]), ]),
MasterDataModule, MasterDataModule,
AuditLogModule, AuditLogModule,
], ],
controllers: [PolicyEngineController], controllers: [PolicyEngineController],
providers: [PolicyEngineService], providers: [
exports: [PolicyEngineService], PolicyEngineService,
PolicyEvaluationService,
MasterValueResolverService,
CohortEvaluationService,
PolicyApplicabilityService,
RuleEvaluationService,
ActionResolverService,
],
exports: [
PolicyEngineService,
PolicyEvaluationService,
MasterValueResolverService,
CohortEvaluationService,
PolicyApplicabilityService,
RuleEvaluationService,
ActionResolverService,
],
}) })
export class PolicyEngineModule { } export class PolicyEngineModule {}
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { getTenantId } from '../../common/tenant/tenant.context'; import { getTenantId } from '../../common/tenant/tenant.context';
import { Policy } from './entities/policy.entity'; import { Policy } from './entities/policy.entity';
import { PolicyJurisdiction } from './entities/policy-jurisdiction.entity';
import { PolicyTargetAudience } from './entities/policy-target-audience.entity'; import { PolicyTargetAudience } from './entities/policy-target-audience.entity';
import { PolicyRule } from './entities/policy-rule.entity'; import { PolicyRule } from './entities/policy-rule.entity';
import { RuleCondition } from './entities/rule-condition.entity'; import { RuleCondition } from './entities/rule-condition.entity';
@@ -26,7 +27,9 @@ export class PolicyEngineService {
private getRelations() { private getRelations() {
return { return {
jurisdiction: true, jurisdictions: {
jurisdiction: true,
},
targetAudiences: true, targetAudiences: true,
rules: { rules: {
ruleCategory: true, ruleCategory: true,
@@ -43,13 +46,39 @@ export class PolicyEngineService {
async create(createPolicyDto: CreatePolicyDto): Promise<Policy> { async create(createPolicyDto: CreatePolicyDto): Promise<Policy> {
const tenantId = getTenantId(); const tenantId = getTenantId();
const { jurisdictionIds, jurisdictionId, targetAudiences, rules, ...policyData } = createPolicyDto;
const jurIds: string[] = Array.isArray(jurisdictionIds) && jurisdictionIds.length > 0
? jurisdictionIds
: (jurisdictionId ? [jurisdictionId] : []);
const policy = this.policyRepository.create({ const policy = this.policyRepository.create({
...createPolicyDto, ...policyData,
tenantId, tenantId,
jurisdictions: jurIds.map((jId) =>
this.policyRepository.manager.create(PolicyJurisdiction, { jurisdictionId: jId }),
),
targetAudiences: targetAudiences?.map((ta) =>
this.policyRepository.manager.create(PolicyTargetAudience, ta),
),
rules: rules?.map((r) =>
this.policyRepository.manager.create(PolicyRule, {
ruleCategoryId: r.ruleCategoryId,
priority: r.priority,
conditions: r.conditions?.map((c) => this.policyRepository.manager.create(RuleCondition, c)),
actions: r.actions?.map((a) =>
this.policyRepository.manager.create(PolicyAction, {
actionTypeId: a.actionTypeId,
sequence: a.sequence,
values: a.values?.map((v) => this.policyRepository.manager.create(PolicyActionValue, v)),
}),
),
}),
),
}); });
const saved = await this.policyRepository.save(policy); const saved = await this.policyRepository.save(policy);
return this.findOne(saved.id); return this.findOne(saved.id);
// CREATE is logged automatically by AuditInterceptor (POST handler)
} }
async findAll( async findAll(
@@ -101,9 +130,10 @@ export class PolicyEngineService {
const before = await this.findOne(id); const before = await this.findOne(id);
const beforeSnapshot = { ...before }; const beforeSnapshot = { ...before };
const { targetAudiences, rules, ...policyData } = updatePolicyDto; const { jurisdictionIds, jurisdictionId, targetAudiences, rules, ...policyData } = updatePolicyDto;
const after = await this.policyRepository.manager.transaction(async (transactionalEntityManager) => { const after = await this.policyRepository.manager.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.delete(PolicyJurisdiction, { policyId: id });
await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id }); await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id });
const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } }); const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } });
@@ -117,6 +147,16 @@ export class PolicyEngineService {
Object.assign(policy, policyData); Object.assign(policy, policyData);
const jurIds: string[] = Array.isArray(jurisdictionIds)
? jurisdictionIds
: (jurisdictionId ? [jurisdictionId] : []);
if (jurIds.length > 0) {
policy.jurisdictions = jurIds.map((jId) =>
transactionalEntityManager.create(PolicyJurisdiction, { policyId: id, jurisdictionId: jId }),
);
}
if (targetAudiences) { if (targetAudiences) {
policy.targetAudiences = targetAudiences.map((target) => policy.targetAudiences = targetAudiences.map((target) =>
transactionalEntityManager.create(PolicyTargetAudience, { ...target, policyId: id }), transactionalEntityManager.create(PolicyTargetAudience, { ...target, policyId: id }),
@@ -162,7 +202,7 @@ export class PolicyEngineService {
module: 'policy-engine', module: 'policy-engine',
action: 'UPDATE', action: 'UPDATE',
entityId: id, entityId: id,
entityLabel: (before as any).name ?? id, entityLabel: (before as any).policyName ?? id,
before: beforeSnapshot, before: beforeSnapshot,
after: { ...after }, after: { ...after },
performedBy: getTenantId(), performedBy: getTenantId(),
@@ -182,7 +222,7 @@ export class PolicyEngineService {
module: 'policy-engine', module: 'policy-engine',
action: 'STATUS_CHANGE', action: 'STATUS_CHANGE',
entityId: id, entityId: id,
entityLabel: (policy as any).name ?? id, entityLabel: (policy as any).policyName ?? id,
before: { status: beforeStatus }, before: { status: beforeStatus },
after: { status }, after: { status },
performedBy: getTenantId(), performedBy: getTenantId(),
@@ -201,7 +241,7 @@ export class PolicyEngineService {
module: 'policy-engine', module: 'policy-engine',
action: 'DELETE', action: 'DELETE',
entityId: id, entityId: id,
entityLabel: (beforeSnapshot as any).name ?? id, entityLabel: (beforeSnapshot as any).policyName ?? id,
before: beforeSnapshot, before: beforeSnapshot,
after: undefined, after: undefined,
performedBy: getTenantId(), performedBy: getTenantId(),
@@ -0,0 +1,145 @@
import { Injectable, Logger } from '@nestjs/common';
import { MasterValueResolverService, EvaluationInput, IncidentContext } from './services/master-value-resolver.service';
import { CohortEvaluationService, CohortMatchResult } from './services/cohort-evaluation.service';
import { PolicyApplicabilityService } from './services/policy-applicability.service';
import { RuleEvaluationService, RuleEvaluationResult } from './services/rule-evaluation.service';
import { ActionResolverService, ResolvedActionItem } from './services/action-resolver.service';
import { PolicyRule } from './entities/policy-rule.entity';
import { IncidentEvaluation } from '../recovery-incident/entities/incident-evaluation.entity';
import { IncidentEvaluationAction } from '../recovery-incident/entities/incident-evaluation-action.entity';
export interface DetailedEvaluationResult {
incidentId?: string;
decision: 'MATCHED' | 'NO_POLICY_MATCH' | 'NO_MATCH';
matchedCohorts: CohortMatchResult[];
matchedPolicies: Array<{ policyId: string; policyName: string; version: number }>;
matchedRules: RuleEvaluationResult[];
actions: ResolvedActionItem[];
evaluation: Partial<IncidentEvaluation>;
normalizedActions: Partial<IncidentEvaluationAction>[];
}
@Injectable()
export class PolicyEvaluationService {
private readonly logger = new Logger(PolicyEvaluationService.name);
constructor(
private readonly masterValueResolver: MasterValueResolverService,
private readonly cohortEvaluator: CohortEvaluationService,
private readonly policyApplicability: PolicyApplicabilityService,
private readonly ruleEvaluator: RuleEvaluationService,
private readonly actionResolver: ActionResolverService,
) {}
async evaluateIncident(input: EvaluationInput): Promise<DetailedEvaluationResult> {
this.logger.log(
`========== Starting Policy Engine Evaluation for ${input.passengerName || 'Unknown'} (${input.flightNumber || 'No Flight'}) ==========`,
);
// 1. Incident Normalization & Master UUID Resolution
const ctx: IncidentContext = await this.masterValueResolver.resolveIncidentContext(input);
// 2. Cohort Evaluation
const matchedCohorts: CohortMatchResult[] = await this.cohortEvaluator.evaluateCohorts(ctx);
// 3. Policy Applicability
const applicablePolicies = await this.policyApplicability.findApplicablePolicies(
ctx,
matchedCohorts,
);
if (applicablePolicies.length === 0) {
this.logger.warn('==== [POLICY EVALUATION FAILED ❌] No applicable policies matched the incident context/jurisdiction. ====');
return {
incidentId: input.recoveryCode,
decision: 'NO_POLICY_MATCH',
matchedCohorts,
matchedPolicies: [],
matchedRules: [],
actions: [],
evaluation: {
policyName: 'No Policy Matched',
recoveryScore: 50,
matchedCohortName: matchedCohorts.map((c) => c.name).join(', ') || 'None',
status: 'No Match',
aiAssessment: `No active policy configuration in the database matched flight ${input.flightNumber || 'N/A'} under jurisdiction ${input.jurisdiction || 'N/A'}.`,
},
normalizedActions: [],
};
}
// 4. Policy Rule Evaluation & Condition Diagnostics
const matchedPolicySummaries: Array<{ policyId: string; policyName: string; version: number }> = [];
const matchedRuleDiagnostics: RuleEvaluationResult[] = [];
const matchedRuleEntities: PolicyRule[] = [];
for (const policy of applicablePolicies) {
this.logger.log(`Evaluating Rules for Policy: "${policy.policyName}" (ID: ${policy.id})`);
matchedPolicySummaries.push({
policyId: policy.id,
policyName: policy.policyName,
version: policy.version || 1,
});
const rules = (policy.rules || []).sort((a, b) => (a.priority || 0) - (b.priority || 0));
for (const rule of rules) {
const ruleResult = await this.ruleEvaluator.evaluateRule(rule, ctx);
matchedRuleDiagnostics.push(ruleResult);
if (ruleResult.matched) {
matchedRuleEntities.push(rule);
}
}
}
// 5. Action Resolution for Matched Rules
const resolvedActions: ResolvedActionItem[] = await this.actionResolver.resolveActionsForMatchedRules(
matchedRuleEntities,
);
const decision = matchedRuleEntities.length > 0 ? 'MATCHED' : 'NO_MATCH';
const primaryPolicyName = matchedPolicySummaries[0]?.policyName || 'Standard Policy';
const primaryCohortName = matchedCohorts.map((c) => c.name).join(', ') || 'General Audience';
const normalizedActions = resolvedActions.map((ra) => ra.normalizedAction);
const score = decision === 'MATCHED' ? 88 : 60;
const aiAssessment = `Evaluated ${applicablePolicies.length} policy/policies and matched ${
matchedRuleEntities.length
} rule(s) under "${primaryPolicyName}" for ${input.passengerName || 'passenger'}. Generated ${
normalizedActions.length
} action(s) dynamically from database configuration.`;
if (decision === 'MATCHED') {
this.logger.log(
`==== [POLICY ENGINE EVALUATION SUCCESS ✅] ==== Passenger: "${input.passengerName || 'Unknown'}" | Flight: ${input.flightNumber || 'N/A'} | Policy: "${primaryPolicyName}" | Decision: ${decision} | Actions Generated: ${normalizedActions.length}`,
);
resolvedActions.forEach((act, idx) => {
this.logger.log(` -> Action #${idx + 1}: ${act.actionName} (${act.actionTypeCode}) | Category: ${act.category} | Config: ${JSON.stringify(act.configuration)}`);
});
} else {
this.logger.warn(
`==== [POLICY ENGINE EVALUATION NO MATCH ❌] ==== Passenger: "${input.passengerName || 'Unknown'}" | Flight: ${input.flightNumber || 'N/A'} | Policy: "${primaryPolicyName}" | Decision: ${decision} | REASON: Conditions for configured policy rules failed evaluation.`,
);
}
return {
incidentId: input.recoveryCode,
decision,
matchedCohorts,
matchedPolicies: matchedPolicySummaries,
matchedRules: matchedRuleDiagnostics,
actions: resolvedActions,
evaluation: {
policyId: matchedPolicySummaries[0]?.policyId,
policyName: primaryPolicyName,
recoveryScore: score,
matchedCohortName: primaryCohortName,
status: decision === 'MATCHED' ? 'Applied' : 'No Action',
aiAssessment,
},
normalizedActions,
};
}
}
@@ -0,0 +1,374 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { PolicyRule } from '../entities/policy-rule.entity';
import { IncidentEvaluationAction } from '../../recovery-incident/entities/incident-evaluation-action.entity';
import { MasterDataService } from '../../master-data/master-data.service';
export interface ResolvedActionItem {
actionTypeCode: string;
actionName: string;
sequence: number;
category: string;
amount?: number;
currency?: string;
configuration: Record<string, any>;
normalizedAction: Partial<IncidentEvaluationAction>;
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@Injectable()
export class ActionResolverService {
private readonly logger = new Logger(ActionResolverService.name);
constructor(
private readonly dataSource: DataSource,
private readonly masterDataService: MasterDataService,
) {}
async resolveSingleUuid(uuid: string, lookupSource?: string): Promise<string | null> {
if (!uuid || typeof uuid !== 'string' || !UUID_REGEX.test(uuid.trim())) {
return null;
}
const cleanUuid = uuid.trim();
// 1. If lookupSource is defined on fieldDefinition, query MasterDataService first
if (lookupSource) {
try {
const item = await this.masterDataService.findOne(lookupSource, cleanUuid);
if (item) {
const label = item.label || item.name || item.value || item.code;
if (label) return label;
}
} catch {
// Fallback to SQL lookup
}
}
// 2. Query master lookup tables via SQL across known lookup tables
try {
const query = `
SELECT value as label FROM masters_lookup.tbl_currencies WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_currencies WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_refund_components WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_amount_types WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_refund_types WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_compensation_types WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_refund_bases WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_fare_flexibilities WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_applicable_products WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_eligible_fare_types WHERE id = $1
UNION ALL
SELECT label FROM masters_lookup.tbl_refund_methods WHERE id = $1
UNION ALL
SELECT name as label FROM masters_action_builder.tbl_field_definitions WHERE id = $1
UNION ALL
SELECT name as label FROM masters_action_builder.tbl_action_types WHERE id = $1
LIMIT 1;
`;
const result = await this.dataSource.query(query, [cleanUuid]);
if (result && result.length > 0 && result[0].label) {
return result[0].label;
}
} catch {
// Ignore
}
return null;
}
async resolveValue(rawVal: any, lookupSource?: string): Promise<any> {
if (rawVal === undefined || rawVal === null) return rawVal;
if (typeof rawVal !== 'string') return rawVal;
const parts = rawVal.split(',').map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return rawVal;
const resolvedParts: string[] = [];
let hasAnyUuid = false;
for (const part of parts) {
if (UUID_REGEX.test(part)) {
hasAnyUuid = true;
const resolved = await this.resolveSingleUuid(part, lookupSource);
if (resolved) {
resolvedParts.push(resolved);
}
} else {
resolvedParts.push(part);
}
}
if (hasAnyUuid) {
return resolvedParts.length > 0 ? resolvedParts.join(', ') : '';
}
return rawVal;
}
async resolveActionsForMatchedRules(matchedRules: PolicyRule[]): Promise<ResolvedActionItem[]> {
const resolvedItems: ResolvedActionItem[] = [];
for (const rule of matchedRules) {
if (!rule.actions || rule.actions.length === 0) continue;
const sortedActions = [...rule.actions].sort(
(a, b) => (a.sequence || 0) - (b.sequence || 0),
);
for (const act of sortedActions) {
const actionTypeCode = act.actionType?.code || 'policy_action';
const actionName = act.actionType?.name || 'Policy Action';
const configuration: Record<string, any> = {};
let currencyCode = 'USD';
let primaryAmount: number | undefined = undefined;
let percentageVal: number | undefined = undefined;
let otherNumVal: number | undefined = undefined;
if (act.values && act.values.length > 0) {
for (const v of act.values) {
const rawFieldCode = (v.fieldCode || v.fieldDefinition?.fieldCode || '').trim();
const rawFieldName = (v.fieldDefinition?.fieldName || '').trim();
const lookupSource = (v.fieldDefinition?.lookupSource || '').trim();
let displayKey = rawFieldName || rawFieldCode;
if (UUID_REGEX.test(displayKey)) {
const resolvedKey = await this.resolveSingleUuid(displayKey);
if (resolvedKey) displayKey = resolvedKey;
}
if (!displayKey) continue;
let val: any = v.textValue || v.selectedValueId;
if (v.numberValue !== undefined && v.numberValue !== null) {
val = Number(v.numberValue);
} else if (v.booleanValue !== undefined && v.booleanValue !== null) {
val = v.booleanValue;
}
// Resolve values (including multi-select comma separated UUIDs) using lookupSource
val = await this.resolveValue(val, lookupSource);
// Skip empty string resolved values (unresolvable raw UUIDs)
if (val === '' || val === null || val === undefined) {
continue;
}
const readableKey = displayKey
.replace(/_/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase());
configuration[readableKey] = val;
const codeLower = (rawFieldCode || displayKey).toLowerCase();
if (v.numberValue !== undefined && v.numberValue !== null) {
const num = Number(v.numberValue);
if (codeLower.includes('amount') || codeLower.includes('value') || codeLower.includes('payout')) {
if (!codeLower.includes('limit') && !codeLower.includes('percent')) {
primaryAmount = num;
}
} else if (codeLower.includes('percent') || codeLower.includes('rate')) {
percentageVal = num;
} else {
if (otherNumVal === undefined) otherNumVal = num;
}
}
// Resolve Currency Code if currency field or currencyCodeId is present
if (v.currencyCodeId || codeLower.includes('currency')) {
let currVal = String(v.currencyCodeId || val || '');
if (currVal && UUID_REGEX.test(currVal.trim())) {
const resolvedCurr = await this.resolveSingleUuid(currVal.trim(), 'currency');
if (resolvedCurr) {
currVal = resolvedCurr;
}
}
if (currVal && !UUID_REGEX.test(currVal.trim())) {
currencyCode = this.normalizeCurrencyCode(currVal);
}
}
}
}
// Post-process configuration values to append currency and percentage units cleanly
Object.keys(configuration).forEach((key) => {
const val = configuration[key];
const kLower = key.toLowerCase();
if (typeof val === 'number') {
if (kLower.includes('amount') || kLower.includes('limit') || kLower.includes('payout') || kLower.includes('cost') || kLower.includes('fee')) {
configuration[key] = `${currencyCode} ${val.toLocaleString()}`;
} else if (kLower.includes('percent') || kLower.includes('rate') || kLower.includes('pct')) {
configuration[key] = `${val}%`;
}
}
});
const numVal = primaryAmount !== undefined ? primaryAmount : otherNumVal;
const category = this.resolveCategory(
act.actionType?.category?.name,
actionTypeCode,
actionName,
);
const { title, description } = this.generateReadableActionSentence(
actionName,
actionTypeCode,
configuration,
numVal,
currencyCode,
percentageVal,
);
const normalizedAction: Partial<IncidentEvaluationAction> = {
title,
actionTypeCode,
category,
amount: numVal,
currency: currencyCode,
status: numVal && numVal >= 300 ? 'Pending Approval' : 'Automated',
sequence: act.sequence || resolvedItems.length + 1,
description,
};
resolvedItems.push({
actionTypeCode,
actionName,
sequence: act.sequence || resolvedItems.length + 1,
category,
amount: numVal,
currency: currencyCode,
configuration,
normalizedAction,
});
}
}
this.logger.log(`Resolved ${resolvedItems.length} action(s) for matched rules.`);
return resolvedItems;
}
private generateReadableActionSentence(
actionName: string,
actionTypeCode: string,
configuration: Record<string, any>,
amount?: number,
currency?: string,
percentage?: number,
): { title: string; description: string } {
const formattedAmount = amount !== undefined ? `${currency || 'USD'} ${amount.toLocaleString()}` : '';
const formattedPct = percentage !== undefined ? `${percentage}%` : '';
const lowerName = actionName.toLowerCase();
const lowerCode = actionTypeCode.toLowerCase();
let title = actionName;
let description = '';
if (lowerName.includes('meal') || lowerCode.includes('meal')) {
title = formattedAmount ? `Issue ${formattedAmount} Meal Voucher` : `Provide Meal & Refreshment Voucher`;
description = `Grant passenger meal voucher${formattedAmount ? ` valued at ${formattedAmount}` : ''} for duty of care during flight delay.`;
} else if (
lowerName.includes('compensation') ||
lowerName.includes('refund') ||
lowerCode.includes('cash') ||
lowerCode.includes('refund')
) {
if (formattedAmount && formattedPct) {
title = `Issue ${formattedAmount} Financial Compensation (${formattedPct})`;
} else if (formattedAmount) {
title = `Issue ${formattedAmount} Financial Compensation`;
} else if (formattedPct) {
title = `Issue ${formattedPct} Refund Compensation`;
} else {
title = `Process Refund / Compensation`;
}
description = `Authorize financial payout of ${formattedAmount || formattedPct || 'configured amount'} to passenger under policy entitlement.`;
} else if (lowerName.includes('hotel') || lowerCode.includes('hotel')) {
title = `Provide Overnight Hotel Accommodation`;
description = `Arrange hotel stay and ground transportation for passenger.`;
} else if (
lowerName.includes('mile') ||
lowerName.includes('point') ||
lowerCode.includes('mile') ||
lowerCode.includes('point')
) {
const miles = configuration.miles || amount;
title = miles ? `Credit ${Number(miles).toLocaleString()} Loyalty Miles` : `Credit Frequent Flyer Miles`;
description = `Credit loyalty account with ${miles ? Number(miles).toLocaleString() : 'bonus'} frequent flyer miles as goodwill.`;
} else if (lowerName.includes('lounge') || lowerCode.includes('lounge')) {
title = `Grant VIP Airport Lounge Access`;
description = `Issue complimentary airport lounge pass for passenger comfort during wait.`;
} else if (lowerName.includes('upgrade') || lowerCode.includes('upgrade')) {
title = `Provide Cabin Class Upgrade`;
description = `Upgrade passenger seat to next premium cabin class.`;
} else {
title = formattedAmount ? `${actionName} (${formattedAmount})` : actionName;
description = `Execute ${actionName} for passenger recovery.`;
}
return { title, description };
}
private normalizeCurrencyCode(val?: string): string {
if (!val) return 'USD';
const s = val.trim();
if (s.length <= 4) return s.toUpperCase();
const lower = s.toLowerCase();
if (lower.includes('united states') || lower.includes('usd') || lower.includes('dollar')) return 'USD';
if (lower.includes('euro') || lower.includes('eur')) return 'EUR';
if (lower.includes('canadian') || lower.includes('cad')) return 'CAD';
if (lower.includes('pound') || lower.includes('gbp') || lower.includes('sterling')) return 'GBP';
if (lower.includes('dirham') || lower.includes('aed')) return 'AED';
if (lower.includes('riyal') || lower.includes('sar')) return 'SAR';
if (lower.includes('rupee') || lower.includes('inr')) return 'INR';
if (lower.includes('australian') || lower.includes('aud')) return 'AUD';
if (lower.includes('yen') || lower.includes('jpy')) return 'JPY';
if (lower.includes('franc') || lower.includes('chf')) return 'CHF';
return s.substring(0, 10).toUpperCase();
}
private resolveCategory(
catName?: string,
code?: string,
name?: string,
): string {
if (catName && catName.trim().length > 0) {
return catName.trim();
}
const text = `${code || ''} ${name || ''}`.toLowerCase();
if (
text.includes('refund') ||
text.includes('cash') ||
text.includes('compensation') ||
text.includes('settlement')
) {
return 'Financial Refund';
}
if (
text.includes('lounge') ||
text.includes('mile') ||
text.includes('point') ||
text.includes('perk') ||
text.includes('upgrade') ||
text.includes('bonus')
) {
return 'Compensation & Perks';
}
return 'Passenger Care';
}
}
@@ -0,0 +1,153 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cohort } from '../../cohort/cohort.entity';
import { IncidentContext } from './master-value-resolver.service';
import { getTenantIdOrNull } from '../../../common/tenant/tenant.context';
export interface CohortMatchResult {
cohortId: string;
name: string;
match: boolean;
matchedCriteria: string[];
reason?: string;
}
@Injectable()
export class CohortEvaluationService {
private readonly logger = new Logger(CohortEvaluationService.name);
constructor(
@InjectRepository(Cohort)
private readonly cohortRepo: Repository<Cohort>,
) {}
async evaluateCohorts(ctx: IncidentContext): Promise<CohortMatchResult[]> {
const tenantId = getTenantIdOrNull();
this.logger.log(`Evaluating cohorts for tenant: ${tenantId || 'ALL'}`);
const whereCondition = tenantId
? [
{ tenantId, status: 'Active' },
{ tenantId, status: 'active' },
]
: [{ status: 'Active' }, { status: 'active' }];
const activeCohorts = await this.cohortRepo.find({
where: whereCondition,
relations: {
cabinClasses: true,
passengerTypes: true,
loyaltyTiers: true,
regions: true,
tripPurposes: true,
revenueSegments: true,
},
});
const results: CohortMatchResult[] = [];
for (const cohort of activeCohorts) {
const matchedCriteria: string[] = [];
const failedCriteria: string[] = [];
let isMatch = true;
// 1. Loyalty Tier Criterion
if (cohort.loyaltyTiers && cohort.loyaltyTiers.length > 0) {
if (
ctx.loyaltyTierId &&
cohort.loyaltyTiers.some((t) => t.id === ctx.loyaltyTierId)
) {
matchedCriteria.push(`loyaltyTier (${ctx.loyaltyTierName || ctx.loyaltyTierId})`);
} else {
isMatch = false;
failedCriteria.push(`loyaltyTier (Incident tier "${ctx.loyaltyTierName || 'None'}" not in cohort allowed tiers)`);
}
}
// 2. Cabin Class Criterion
if (cohort.cabinClasses && cohort.cabinClasses.length > 0) {
const targetCabinId = ctx.cabinClassId || ctx.originalCabinId;
if (
targetCabinId &&
cohort.cabinClasses.some((c) => c.id === targetCabinId)
) {
matchedCriteria.push(`cabinClass (${ctx.cabinClassName || targetCabinId})`);
} else {
isMatch = false;
failedCriteria.push(`cabinClass (Incident cabin "${ctx.cabinClassName || 'None'}" not in cohort allowed cabins)`);
}
}
// 3. Passenger Type Criterion
if (cohort.passengerTypes && cohort.passengerTypes.length > 0) {
if (
ctx.passengerTypeId &&
cohort.passengerTypes.some((p) => p.id === ctx.passengerTypeId)
) {
matchedCriteria.push(`passengerType (${ctx.passengerTypeName || ctx.passengerTypeId})`);
} else {
isMatch = false;
failedCriteria.push(`passengerType (Incident type "${ctx.passengerTypeName || 'None'}" not in cohort allowed types)`);
}
}
// 4. Origin Airport Criterion
if (cohort.originAirport && cohort.originAirport.length > 0) {
if (ctx.origin && cohort.originAirport.includes(ctx.origin)) {
matchedCriteria.push(`originAirport (${ctx.origin})`);
} else {
isMatch = false;
failedCriteria.push(`originAirport (Incident origin "${ctx.origin || 'None'}" not in cohort origin list)`);
}
}
// 5. Destination Airport Criterion
if (cohort.destinationAirport && cohort.destinationAirport.length > 0) {
if (ctx.destination && cohort.destinationAirport.includes(ctx.destination)) {
matchedCriteria.push(`destinationAirport (${ctx.destination})`);
} else {
isMatch = false;
failedCriteria.push(`destinationAirport (Incident destination "${ctx.destination || 'None'}" not in cohort destination list)`);
}
}
// 6. High Value Passenger Criterion
if (cohort.highValuePassenger && cohort.highValuePassenger !== 'Any') {
const isVip =
ctx.passengerTypeName?.toLowerCase().includes('vip') ||
ctx.loyaltyTierName?.toLowerCase().includes('platinum') ||
ctx.loyaltyTierName?.toLowerCase().includes('gold');
if (cohort.highValuePassenger === 'Yes(VIP/Strategic)' && !isVip) {
isMatch = false;
failedCriteria.push(`highValuePassenger (Cohort requires VIP/Strategic status)`);
} else if (cohort.highValuePassenger === 'No' && isVip) {
isMatch = false;
failedCriteria.push(`highValuePassenger (Cohort excludes VIP/Strategic passengers)`);
} else {
matchedCriteria.push(`highValuePassenger (${cohort.highValuePassenger})`);
}
}
if (isMatch) {
this.logger.log(
`[COHORT MATCHED ✅] Cohort: "${cohort.name}" (ID: ${cohort.id}) | Matched Criteria: [${matchedCriteria.join(', ')}]`,
);
results.push({
cohortId: cohort.id,
name: cohort.name,
match: true,
matchedCriteria,
reason: 'All configured cohort criteria matched incident context.',
});
} else {
this.logger.warn(
`[COHORT REJECTED ❌] Cohort: "${cohort.name}" (ID: ${cohort.id}) | REASON: Failed criteria: [${failedCriteria.join('; ')}]`,
);
}
}
this.logger.log(`Cohort Evaluation complete. Matched ${results.length} cohort(s).`);
return results;
}
}
@@ -0,0 +1,197 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MembershipTier } from '../../master-data/entities/membership-tier.entity';
import { PassengerType } from '../../master-data/entities/passenger-type.entity';
import { CabinClass } from '../../master-data/entities/cabin-class.entity';
import { Jurisdiction } from '../../master-data/entities/jurisdiction.entity';
export interface EvaluationInput {
recoveryCode?: string;
passengerName?: string;
pnr?: string;
loyaltyTier?: string;
passengerType?: string;
nationality?: string;
specialAssistance?: string;
cabinClass?: string;
originalCabin?: string;
actualCabin?: string;
flightNumber?: string;
flightRoute?: string;
origin?: string;
destination?: string;
date?: string;
category?: string;
scenario?: string;
jurisdiction?: string;
delayDuration?: number;
status?: string;
value?: string;
isPerksClaimed?: boolean;
}
export interface IncidentContext {
recoveryCode?: string;
passengerName?: string;
pnr?: string;
passengerTypeId?: string;
passengerTypeName?: string;
loyaltyTierId?: string;
loyaltyTierName?: string;
cabinClassId?: string;
cabinClassName?: string;
originalCabinId?: string;
originalCabinName?: string;
actualCabinId?: string;
actualCabinName?: string;
origin?: string;
destination?: string;
flightNumber?: string;
flightRoute?: string;
scenario?: string;
category?: string;
jurisdictionId?: string;
jurisdictionCode?: string;
delayDuration?: number;
nationality?: string;
specialAssistance?: string;
rawIncident: EvaluationInput;
}
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@Injectable()
export class MasterValueResolverService {
private readonly logger = new Logger(MasterValueResolverService.name);
constructor(
@InjectRepository(MembershipTier)
private readonly membershipTierRepo: Repository<MembershipTier>,
@InjectRepository(PassengerType)
private readonly passengerTypeRepo: Repository<PassengerType>,
@InjectRepository(CabinClass)
private readonly cabinClassRepo: Repository<CabinClass>,
@InjectRepository(Jurisdiction)
private readonly jurisdictionRepo: Repository<Jurisdiction>,
) {}
async resolveIncidentContext(input: EvaluationInput): Promise<IncidentContext> {
this.logger.log(`Resolving Master Data UUIDs for input: ${input.passengerName || 'Unknown'}`);
const [tiers, types, cabins, jurisdictions] = await Promise.all([
this.membershipTierRepo.find(),
this.passengerTypeRepo.find(),
this.cabinClassRepo.find(),
this.jurisdictionRepo.find(),
]);
// Helper functions
const resolveTier = (val?: string) => {
if (!val) return { id: undefined, name: undefined };
if (UUID_REGEX.test(val)) return { id: val, name: val };
const found = tiers.find(
(t) =>
t.id === val ||
t.value?.toLowerCase() === val.toLowerCase() ||
t.label?.toLowerCase().includes(val.toLowerCase()) ||
val.toLowerCase().includes(t.label?.toLowerCase() || ''),
);
return { id: found?.id, name: found?.label || val };
};
const resolvePassengerType = (val?: string) => {
if (!val) return { id: undefined, name: undefined };
if (UUID_REGEX.test(val)) return { id: val, name: val };
const found = types.find(
(p) =>
p.id === val ||
p.value?.toLowerCase() === val.toLowerCase() ||
p.label?.toLowerCase().includes(val.toLowerCase()) ||
val.toLowerCase().includes(p.label?.toLowerCase() || ''),
);
return { id: found?.id, name: found?.label || val };
};
const resolveCabin = (val?: string) => {
if (!val) return { id: undefined, name: undefined };
if (UUID_REGEX.test(val)) return { id: val, name: val };
const found = cabins.find(
(c) =>
c.id === val ||
c.value?.toLowerCase() === val.toLowerCase() ||
c.label?.toLowerCase().includes(val.toLowerCase()) ||
val.toLowerCase().includes(c.label?.toLowerCase() || ''),
);
return { id: found?.id, name: found?.label || val };
};
const resolveJurisdiction = (val?: string) => {
if (!val) return { id: undefined, name: undefined };
if (UUID_REGEX.test(val)) return { id: val, code: val };
const found = jurisdictions.find(
(j) =>
j.id === val ||
j.value?.toLowerCase() === val.toLowerCase() ||
j.label?.toLowerCase().includes(val.toLowerCase()) ||
val.toLowerCase().includes(j.value?.toLowerCase() || ''),
);
return { id: found?.id, code: found?.value || val };
};
const loyaltyRes = resolveTier(input.loyaltyTier);
const passengerTypeRes = resolvePassengerType(input.passengerType);
const cabinRes = resolveCabin(input.cabinClass);
const origCabinRes = resolveCabin(input.originalCabin);
const actCabinRes = resolveCabin(input.actualCabin);
const jurisdictionRes = resolveJurisdiction(input.jurisdiction);
// Extract origin / destination from route if missing
let origin = input.origin;
let destination = input.destination;
if (input.flightRoute && (!origin || !destination)) {
const parts = input.flightRoute.split('→').map((s) => s.trim());
if (parts.length === 2) {
if (!origin) origin = parts[0];
if (!destination) destination = parts[1];
}
}
return {
recoveryCode: input.recoveryCode,
passengerName: input.passengerName,
pnr: input.pnr,
loyaltyTierId: loyaltyRes.id,
loyaltyTierName: loyaltyRes.name,
passengerTypeId: passengerTypeRes.id,
passengerTypeName: passengerTypeRes.name,
cabinClassId: cabinRes.id,
cabinClassName: cabinRes.name,
originalCabinId: origCabinRes.id,
originalCabinName: origCabinRes.name,
actualCabinId: actCabinRes.id,
actualCabinName: actCabinRes.name,
origin,
destination,
flightNumber: input.flightNumber,
flightRoute: input.flightRoute,
scenario: input.scenario,
category: input.category,
jurisdictionId: jurisdictionRes.id,
jurisdictionCode: jurisdictionRes.code,
delayDuration: input.delayDuration !== undefined ? Number(input.delayDuration) : undefined,
nationality: input.nationality,
specialAssistance: input.specialAssistance,
rawIncident: input,
};
}
}
@@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Policy } from '../entities/policy.entity';
import { PolicyStatus, AudienceType } from '../entities/policy.enums';
import { IncidentContext } from './master-value-resolver.service';
import { CohortMatchResult } from './cohort-evaluation.service';
import { getTenantIdOrNull } from '../../../common/tenant/tenant.context';
@Injectable()
export class PolicyApplicabilityService {
private readonly logger = new Logger(PolicyApplicabilityService.name);
constructor(
@InjectRepository(Policy)
private readonly policyRepo: Repository<Policy>,
) {}
async findApplicablePolicies(
ctx: IncidentContext,
matchedCohorts: CohortMatchResult[],
): Promise<Policy[]> {
const tenantId = getTenantIdOrNull();
this.logger.log(`Finding applicable policies for tenant: ${tenantId || 'ALL'}`);
const whereCondition = tenantId
? { tenantId, status: PolicyStatus.ACTIVE }
: { status: PolicyStatus.ACTIVE };
const activePolicies = await this.policyRepo.find({
where: whereCondition,
relations: {
jurisdictions: {
jurisdiction: true,
},
targetAudiences: true,
rules: {
conditions: true,
actions: {
actionType: {
category: true,
},
values: {
fieldDefinition: true,
},
},
},
},
});
const matchedCohortIds = new Set(matchedCohorts.map((c) => c.cohortId));
const applicable = activePolicies.filter((policy) => {
// 1. Jurisdiction Match
const incidentJurisdiction = ctx.jurisdictionId || ctx.jurisdictionCode;
const policyJurisdictions = policy.jurisdictions || [];
if (policyJurisdictions.length > 0) {
if (!incidentJurisdiction) {
this.logger.warn(
`[POLICY REJECTED ❌] Policy "${policy.policyName}" (ID: ${policy.id}) requires a jurisdiction, but incident context has no jurisdiction.`,
);
return false;
}
const incJVal = incidentJurisdiction.trim().toUpperCase();
const hasMatch = policyJurisdictions.some((pj) => {
const pJId = pj.jurisdictionId;
const pJVal = (pj.jurisdiction?.value || pj.jurisdiction?.label || '').trim().toUpperCase();
const matchesId = Boolean(pJId) && pJId === incidentJurisdiction;
const matchesVal =
Boolean(pJVal) &&
(pJVal === incJVal || pJVal.includes(incJVal) || incJVal.includes(pJVal));
return matchesId || matchesVal;
});
if (!hasMatch) {
this.logger.warn(
`[POLICY REJECTED ❌] Policy "${policy.policyName}" (ID: ${policy.id}) jurisdictions DO NOT MATCH incident jurisdiction ("${incJVal}").`,
);
return false;
}
}
// 2. Audience / Cohort Match
if (policy.audienceType === AudienceType.ALL) {
this.logger.log(
`[POLICY APPLICABLE ✅] Policy "${policy.policyName}" (ID: ${policy.id}) applies to ALL audiences.`,
);
return true;
}
if (policy.targetAudiences && policy.targetAudiences.length > 0) {
const hasCohortMatch = policy.targetAudiences.some((ta) =>
matchedCohortIds.has(ta.targetId),
);
if (hasCohortMatch) {
this.logger.log(
`[POLICY APPLICABLE ✅] Policy "${policy.policyName}" (ID: ${policy.id}) matched target cohort audience.`,
);
return true;
}
}
const hasCohortMatch = matchedCohortIds.size > 0 && policy.audienceType === AudienceType.COHORT;
if (hasCohortMatch) {
this.logger.log(
`[POLICY APPLICABLE ✅] Policy "${policy.policyName}" (ID: ${policy.id}) matched cohort audience.`,
);
} else {
this.logger.warn(
`[POLICY REJECTED ❌] Policy "${policy.policyName}" (ID: ${policy.id}) targets specific cohorts, but no matching cohort was found for this incident.`,
);
}
return hasCohortMatch;
});
this.logger.log(`Found ${applicable.length} applicable policy/policies.`);
return applicable;
}
}
@@ -0,0 +1,689 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PolicyRule } from '../entities/policy-rule.entity';
import { RuleCondition } from '../entities/rule-condition.entity';
import { LogicalOperator } from '../entities/policy.enums';
import { ConditionField } from '../../master-data/entities/condition-field.entity';
import { Operator } from '../../master-data/entities/operator.entity';
import { IncidentContext } from './master-value-resolver.service';
export interface ConditionDiagnostic {
field: string;
actual: any;
expected: any;
operator: string;
matched: boolean;
reason: string;
}
export interface RuleEvaluationResult {
ruleId: string;
category?: string;
priority: number;
matched: boolean;
conditions: ConditionDiagnostic[];
reason?: string;
}
@Injectable()
export class RuleEvaluationService {
private readonly logger = new Logger(RuleEvaluationService.name);
constructor(
@InjectRepository(ConditionField)
private readonly fieldRepo: Repository<ConditionField>,
@InjectRepository(Operator)
private readonly operatorRepo: Repository<Operator>,
) {}
async evaluateRule(
rule: PolicyRule,
ctx: IncidentContext,
): Promise<RuleEvaluationResult> {
this.logger.log(`--- Evaluating Rule ID: ${rule.id} (Priority: ${rule.priority}) ---`);
const fields = await this.fieldRepo.find();
const operators = await this.operatorRepo.find();
const sortedConditions = (rule.conditions || []).sort(
(a, b) => (a.sequence || 0) - (b.sequence || 0),
);
if (sortedConditions.length === 0) {
this.logger.log(`>>> [RULE MATCHED ✅] Rule ID: ${rule.id} (Priority: ${rule.priority}) | Reason: No conditions defined (unconditional rule).`);
return {
ruleId: rule.id,
category: rule.ruleCategoryId,
priority: rule.priority || 1,
matched: true,
conditions: [],
reason: 'Unconditional rule automatically matched.',
};
}
const diagnostics: ConditionDiagnostic[] = [];
let isRuleMatched = true;
for (let i = 0; i < sortedConditions.length; i++) {
const cond = sortedConditions[i];
const fieldDef = fields.find((f) => f.id === cond.fieldId);
const opDef = operators.find((o) => o.id === cond.operatorId);
const fieldCode = (fieldDef?.code || 'unknown').toLowerCase();
const opCode = (opDef?.code || opDef?.symbol || 'equals').toLowerCase();
const actualValue = this.extractActualValue(fieldCode, ctx);
const expectedValue = cond.valueText;
const { matched: isCondMatched, reason } = this.evaluateConditionWithReason(
actualValue,
expectedValue,
opCode,
fieldCode,
);
if (isCondMatched) {
this.logger.log(
` [CONDITION MATCHED ✅] Field: "${fieldCode}" | Operator: "${opCode}" | Actual: ${JSON.stringify(actualValue)} | Expected: ${JSON.stringify(expectedValue)} | REASON: ${reason}`,
);
} else {
this.logger.warn(
` [CONDITION FAILED ❌] Field: "${fieldCode}" | Operator: "${opCode}" | Actual: ${JSON.stringify(actualValue)} | Expected: ${JSON.stringify(expectedValue)} | REASON: ${reason}`,
);
}
diagnostics.push({
field: fieldCode,
actual: actualValue,
expected: expectedValue,
operator: opCode,
matched: isCondMatched,
reason,
});
const logicalOp = cond.logicalOperator || LogicalOperator.AND;
if (i === 0) {
isRuleMatched = isCondMatched;
} else {
if (logicalOp === LogicalOperator.OR) {
isRuleMatched = isRuleMatched || isCondMatched;
} else {
isRuleMatched = isRuleMatched && isCondMatched;
}
}
}
if (isRuleMatched) {
this.logger.log(
`>>> [RULE MATCHED ✅] Rule ID: ${rule.id} (Priority: ${rule.priority}) | Actions Configured: ${rule.actions?.length || 0}`,
);
} else {
const failedConds = diagnostics.filter((d) => !d.matched);
this.logger.warn(
`>>> [RULE NOT MATCHED ❌] Rule ID: ${rule.id} (Priority: ${rule.priority}) | REASON: Failed condition(s): [${failedConds
.map((c) => `Field "${c.field}": ${c.reason}`)
.join('; ')}]`,
);
}
return {
ruleId: rule.id,
category: rule.ruleCategoryId,
priority: rule.priority || 1,
matched: isRuleMatched,
conditions: diagnostics,
reason: isRuleMatched
? 'All required conditions matched successfully.'
: `Failed condition(s): ${diagnostics.filter((d) => !d.matched).map((d) => `${d.field} (${d.reason})`).join(', ')}`,
};
}
private normalizeOperatorCode(opCode: string): string {
const raw = (opCode || '').toLowerCase().trim();
if (!raw) return 'eq';
if (
raw === '=' ||
raw === '==' ||
raw === '===' ||
raw === 'eq' ||
raw === 'equal' ||
raw === 'equals' ||
raw === 'is' ||
raw === 'same'
) {
return 'eq';
}
if (
raw === '!=' ||
raw === '!==' ||
raw === '<>' ||
raw === 'ne' ||
raw === 'neq' ||
raw === 'not_equal' ||
raw === 'not_equals' ||
raw === 'not equal' ||
raw === 'not equals' ||
raw === 'is_not' ||
raw === 'is not'
) {
return 'neq';
}
if (
raw === '>' ||
raw === 'gt' ||
raw === 'greater_than' ||
raw === 'greater than' ||
raw === 'more_than' ||
raw === 'more than' ||
raw === 'above'
) {
return 'gt';
}
if (
raw === '>=' ||
raw === 'gte' ||
raw === 'greater_than_or_equal' ||
raw === 'greater than or equal' ||
raw === 'greater_than_or_equals' ||
raw === 'greater than or equals' ||
raw === 'at_least' ||
raw === 'at least' ||
raw === 'min' ||
raw === 'minimum'
) {
return 'gte';
}
if (
raw === '<' ||
raw === 'lt' ||
raw === 'less_than' ||
raw === 'less than' ||
raw === 'under' ||
raw === 'below' ||
raw === 'fewer_than' ||
raw === 'fewer than'
) {
return 'lt';
}
if (
raw === '<=' ||
raw === 'lte' ||
raw === 'less_than_or_equal' ||
raw === 'less than or equal' ||
raw === 'less_than_or_equals' ||
raw === 'less than or equals' ||
raw === 'at_most' ||
raw === 'at most' ||
raw === 'max' ||
raw === 'maximum'
) {
return 'lte';
}
if (
raw === 'contains' ||
raw === 'contain' ||
raw === 'includes' ||
raw === 'include' ||
raw === 'like' ||
raw === 'ilike' ||
raw === 'has' ||
raw === 'have'
) {
return 'contains';
}
if (
raw === 'not_contains' ||
raw === 'not contains' ||
raw === 'does_not_contain' ||
raw === 'does not contain' ||
raw === 'not_includes' ||
raw === 'not includes' ||
raw === 'excludes' ||
raw === 'exclude' ||
raw === 'without' ||
raw === 'not_like' ||
raw === 'not like'
) {
return 'not_contains';
}
if (
raw === 'in' ||
raw === 'inside' ||
raw === 'any_of' ||
raw === 'any of' ||
raw === 'one_of' ||
raw === 'one of'
) {
return 'in';
}
if (
raw === 'not_in' ||
raw === 'not in' ||
raw === 'not_inside' ||
raw === 'not inside' ||
raw === 'none_of' ||
raw === 'none of'
) {
return 'not_in';
}
if (
raw === 'starts_with' ||
raw === 'starts with' ||
raw === 'startswith' ||
raw === 'beginning_with'
) {
return 'starts_with';
}
if (
raw === 'ends_with' ||
raw === 'ends with' ||
raw === 'endswith' ||
raw === 'ending_with'
) {
return 'ends_with';
}
if (
raw === 'is_empty' ||
raw === 'is empty' ||
raw === 'empty' ||
raw === 'is_null' ||
raw === 'is null' ||
raw === 'null'
) {
return 'is_empty';
}
if (
raw === 'is_not_empty' ||
raw === 'is not empty' ||
raw === 'not_empty' ||
raw === 'not empty' ||
raw === 'is_not_null' ||
raw === 'is not null' ||
raw === 'not_null' ||
raw === 'not null'
) {
return 'is_not_empty';
}
return raw;
}
private evaluateConditionWithReason(
actual: any,
expected: any,
opCode: string,
fieldCode: string,
): { matched: boolean; reason: string } {
const normalizedOp = this.normalizeOperatorCode(opCode);
if (normalizedOp === 'is_empty') {
const isEmpty = actual === undefined || actual === null || String(actual).trim() === '';
return {
matched: isEmpty,
reason: isEmpty
? `Field "${fieldCode}" is empty or null.`
: `Field "${fieldCode}" has value "${actual}" and is NOT empty.`,
};
}
if (normalizedOp === 'is_not_empty') {
const isNotEmpty = actual !== undefined && actual !== null && String(actual).trim() !== '';
return {
matched: isNotEmpty,
reason: isNotEmpty
? `Field "${fieldCode}" has value "${actual}" and is not empty.`
: `Field "${fieldCode}" is empty or null.`,
};
}
if (actual === undefined || actual === null) {
return {
matched: false,
reason: `Incident context property "${fieldCode}" is missing, null, or undefined in the disruption payload.`,
};
}
const matched = this.compareValues(actual, expected, opCode);
if (matched) {
return {
matched: true,
reason: `Actual value "${actual}" satisfies operator "${opCode}" against expected value "${expected}".`,
};
} else {
const actStr = String(actual);
const expStr = String(expected || '');
const actNum = Number(actual);
const expNum = Number(expected);
const isNumeric = !isNaN(actNum) && !isNaN(expNum) && expStr !== '';
if (normalizedOp === 'neq') {
return {
matched: false,
reason: `Actual value "${actStr}" IS EQUAL to expected value "${expStr}", failing "not equal" operator "${opCode}".`,
};
} else if (isNumeric) {
return {
matched: false,
reason: `Actual numeric value (${actNum}) DOES NOT satisfy operator "${opCode}" against expected required numeric value (${expNum}).`,
};
} else {
return {
matched: false,
reason: `Actual text value "${actStr}" DOES NOT match expected value "${expStr}" under operator "${opCode}".`,
};
}
}
}
private extractActualValue(fieldCode: string, ctx: IncidentContext): any {
const fc = (fieldCode || '').toLowerCase().trim();
const raw = ctx.rawIncident || {};
switch (fc) {
// FLIGHT
case 'flight_type':
case 'flighttype':
return (
(raw as any).flightType ||
(ctx.origin && ctx.destination
? ctx.origin === ctx.destination
? 'domestic'
: 'international'
: undefined)
);
case 'origin':
case 'origin_airport':
case 'originairport':
return ctx.origin || (raw as any).origin;
case 'destination':
case 'destination_airport':
case 'destinationairport':
case 'airport':
return ctx.destination || (raw as any).destination;
case 'operating_carrier':
case 'operatingcarrier':
return (raw as any).operatingCarrier || (raw as any).carrier;
case 'marketing_carrier':
case 'marketingcarrier':
return (raw as any).marketingCarrier || (raw as any).carrier;
case 'flight_distance':
case 'flightdistance':
return (raw as any).flightDistance !== undefined
? Number((raw as any).flightDistance)
: undefined;
case 'flight_number':
case 'flightnumber':
return ctx.flightNumber || (raw as any).flightNumber;
// DISRUPTION
case 'delay_duration':
case 'delayduration':
case 'delay':
return ctx.delayDuration !== undefined ? ctx.delayDuration : (raw as any).delayDuration;
case 'delay_reason':
case 'delayreason':
return (raw as any).delayReason || (raw as any).reason;
case 'cancellation_reason':
case 'cancellationreason':
return (raw as any).cancellationReason || (raw as any).reason;
case 'diversion_reason':
case 'diversionreason':
return (raw as any).diversionReason || (raw as any).reason;
case 'weather_condition':
case 'weathercondition':
return (raw as any).weatherCondition;
case 'atc_restriction':
case 'atcrestriction':
return (raw as any).atcRestriction;
case 'technical_fault':
case 'technicalfault':
return (raw as any).technicalFault;
case 'extraordinary_circumstance':
case 'extraordinarycircumstance':
return (raw as any).extraordinaryCircumstance;
case 'airline_responsibility':
case 'airlineresponsibility':
return (raw as any).airlineResponsibility;
// PASSENGER
case 'passenger_type':
case 'passengertype':
return ctx.passengerTypeId || ctx.passengerTypeName || (raw as any).passengerType;
case 'cabin_class':
case 'cabinclass':
return ctx.cabinClassId || ctx.cabinClassName || (raw as any).cabinClass;
case 'membership_tier':
case 'membershiptier':
case 'loyalty_tier':
case 'loyaltytier':
return ctx.loyaltyTierId || ctx.loyaltyTierName || (raw as any).loyaltyTier;
case 'customer_value':
case 'customervalue':
return (raw as any).customerValue;
case 'corporate_customer':
case 'corporatecustomer':
return (raw as any).corporateCustomer;
case 'group_booking':
case 'groupbooking':
return (raw as any).groupBooking;
case 'special_assistance':
case 'specialassistance':
return ctx.specialAssistance || (raw as any).specialAssistance;
// JOURNEY
case 'journey_type':
case 'journeytype':
return (raw as any).journeyType;
case 'protected_connection':
case 'protectedconnection':
return (raw as any).protectedConnection;
case 'self_transfer':
case 'selftransfer':
return (raw as any).selfTransfer;
case 'number_of_segments':
case 'numberofsegments':
return (raw as any).numberOfSegments !== undefined
? Number((raw as any).numberOfSegments)
: undefined;
case 'final_destination_delay':
case 'finaldestinationdelay':
return (raw as any).finalDestinationDelay !== undefined
? Number((raw as any).finalDestinationDelay)
: ctx.delayDuration;
// BOOKING
case 'booking_channel':
case 'bookingchannel':
return (raw as any).bookingChannel;
case 'refundable_ticket':
case 'refundableticket':
return (raw as any).refundableTicket;
case 'fare_flexibility':
case 'fareflexibility':
return (raw as any).fareFlexibility;
case 'ticket_value':
case 'ticketvalue':
return (raw as any).ticketValue !== undefined
? Number((raw as any).ticketValue)
: (raw as any).value;
case 'ancillary_purchased':
case 'ancillarypurchased':
return (raw as any).ancillaryPurchased;
// BAGGAGE
case 'bag_status':
case 'bagstatus':
return (raw as any).bagStatus;
case 'bag_type':
case 'bagtype':
return (raw as any).bagType;
case 'bag_delay':
case 'bagdelay':
return (raw as any).bagDelay !== undefined ? Number((raw as any).bagDelay) : undefined;
case 'bag_value':
case 'bagvalue':
return (raw as any).bagValue !== undefined ? Number((raw as any).bagValue) : undefined;
case 'pir_created':
case 'pircreated':
return (raw as any).pirCreated;
// CABIN
case 'original_cabin':
case 'originalcabin':
return ctx.originalCabinId || ctx.originalCabinName || (raw as any).originalCabin;
case 'assigned_cabin':
case 'assignedcabin':
case 'actual_cabin':
case 'actualcabin':
return ctx.actualCabinId || ctx.actualCabinName || (raw as any).actualCabin;
case 'downgrade_level':
case 'downgradelevel':
return (raw as any).downgradeLevel !== undefined
? Number((raw as any).downgradeLevel)
: undefined;
case 'seat_type':
case 'seattype':
return (raw as any).seatType;
// ANCILLARY
case 'ancillary_type':
case 'ancillarytype':
return (raw as any).ancillaryType;
case 'ancillary_delivered':
case 'ancillarydelivered':
return (raw as any).ancillaryDelivered;
case 'service_value':
case 'servicevalue':
return (raw as any).serviceValue !== undefined
? Number((raw as any).serviceValue)
: undefined;
// GENERAL / COMMON
case 'scenario':
return ctx.scenario || (raw as any).scenario;
case 'category':
return ctx.category || (raw as any).category;
case 'jurisdiction':
return ctx.jurisdictionId || ctx.jurisdictionCode || (raw as any).jurisdiction;
case 'nationality':
return ctx.nationality || (raw as any).nationality;
default:
const camelCase = fc.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
return (
(ctx as any)[fc] ??
(ctx as any)[camelCase] ??
(raw as any)[fc] ??
(raw as any)[camelCase]
);
}
}
private compareValues(actual: any, expected: any, rawOpCode: string): boolean {
const op = this.normalizeOperatorCode(rawOpCode);
if (op === 'is_empty') {
return actual === undefined || actual === null || String(actual).trim() === '';
}
if (op === 'is_not_empty') {
return actual !== undefined && actual !== null && String(actual).trim() !== '';
}
if (actual === undefined || actual === null) return false;
const actStr = String(actual).toLowerCase().trim();
const expStr = String(expected || '').toLowerCase().trim();
// Check if numeric comparison
const actNum = Number(actual);
const expNum = Number(expected);
const isNumeric = !isNaN(actNum) && !isNaN(expNum) && expStr !== '';
if (isNumeric) {
switch (op) {
case 'eq':
return actNum === expNum;
case 'neq':
return actNum !== expNum;
case 'gt':
return actNum > expNum;
case 'gte':
return actNum >= expNum;
case 'lt':
return actNum < expNum;
case 'lte':
return actNum <= expNum;
case 'in':
const numInList = expStr.split(',').map((s) => Number(s.trim())).filter((n) => !isNaN(n));
return numInList.includes(actNum);
case 'not_in':
const numNotInList = expStr.split(',').map((s) => Number(s.trim())).filter((n) => !isNaN(n));
return !numNotInList.includes(actNum);
default:
return actNum === expNum;
}
}
// Smart String Matcher for Airport Codes & Master Data Dropdown Labels (e.g. "JFK" vs "JFK - New York JFK")
const isStringMatch = (a: string, e: string): boolean => {
if (!a && !e) return true;
if (!a || !e) return false;
if (a === e) return true;
if (a.includes(e) || e.includes(a)) return true;
// Extract IATA / token prefix before dash or space (e.g. "JFK - New York JFK" -> "jfk")
const eTokens = e.split(/[\s\-_]+/);
const aTokens = a.split(/[\s\-_]+/);
if (eTokens.some((t) => t && t.length >= 2 && a.includes(t))) return true;
if (aTokens.some((t) => t && t.length >= 2 && e.includes(t))) return true;
return false;
};
switch (op) {
case 'eq':
return isStringMatch(actStr, expStr);
case 'neq':
return !isStringMatch(actStr, expStr);
case 'gt':
return actStr > expStr;
case 'gte':
return actStr >= expStr;
case 'lt':
return actStr < expStr;
case 'lte':
return actStr <= expStr;
case 'contains':
return isStringMatch(actStr, expStr);
case 'not_contains':
return !isStringMatch(actStr, expStr);
case 'starts_with':
return actStr.startsWith(expStr);
case 'ends_with':
return actStr.endsWith(expStr);
case 'in':
const inList = expStr.split(',').map((s) => s.trim());
return inList.some((item) => isStringMatch(actStr, item));
case 'not_in':
const notInList = expStr.split(',').map((s) => s.trim());
return !notInList.some((item) => isStringMatch(actStr, item));
default:
return isStringMatch(actStr, expStr);
}
}
}
@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsDateString, IsBoolean } from 'class-validator'; import { IsString, IsNotEmpty, IsOptional, IsDateString, IsBoolean, IsNumber } from 'class-validator';
export class CreateRecoveryIncidentDto { export class CreateRecoveryIncidentDto {
@IsString() @IsString()
@@ -17,6 +17,34 @@ export class CreateRecoveryIncidentDto {
@IsOptional() @IsOptional()
pnr?: string; pnr?: string;
@IsString()
@IsOptional()
loyaltyTier?: string;
@IsString()
@IsOptional()
passengerType?: string;
@IsString()
@IsOptional()
nationality?: string;
@IsString()
@IsOptional()
specialAssistance?: string;
@IsString()
@IsOptional()
cabinClass?: string;
@IsString()
@IsOptional()
originalCabin?: string;
@IsString()
@IsOptional()
actualCabin?: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
flightNumber: string; flightNumber: string;
@@ -25,10 +53,30 @@ export class CreateRecoveryIncidentDto {
@IsNotEmpty() @IsNotEmpty()
flightRoute: string; flightRoute: string;
@IsString()
@IsOptional()
origin?: string;
@IsString()
@IsOptional()
destination?: string;
@IsString() @IsString()
@IsOptional() @IsOptional()
category?: string; category?: string;
@IsString()
@IsOptional()
scenario?: string;
@IsString()
@IsOptional()
jurisdiction?: string;
@IsNumber()
@IsOptional()
delayDuration?: number;
@IsString() @IsString()
@IsOptional() @IsOptional()
status?: string; status?: string;
@@ -0,0 +1,30 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import type { RecoveryIncident } from './recovery-incident.entity';
@Entity({ name: 'tbl_incident_audit_logs', schema: 'recovery_incident' })
export class IncidentAuditLog {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'tenant_id', type: 'varchar', length: 255 })
tenantId!: string;
@Column({ name: 'incident_id', type: 'uuid' })
incidentId!: string;
@ManyToOne('RecoveryIncident', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'incident_id' })
incident?: RecoveryIncident;
@Column({ type: 'varchar', length: 255 })
title!: string;
@Column({ type: 'text' })
description!: string;
@Column({ name: 'performed_by', type: 'varchar', length: 255, nullable: true })
performedBy?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,45 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import type { IncidentEvaluation } from './incident-evaluation.entity';
@Entity({ name: 'tbl_incident_evaluation_actions', schema: 'recovery_incident' })
export class IncidentEvaluationAction {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'evaluation_id', type: 'uuid' })
evaluationId!: string;
@ManyToOne('IncidentEvaluation', 'actions', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'evaluation_id' })
evaluation?: IncidentEvaluation;
@Column({ name: 'action_type_code', type: 'varchar', length: 100, nullable: true })
actionTypeCode?: string;
@Column({ type: 'varchar', length: 255 })
title!: string;
@Column({ type: 'varchar', length: 100 })
category!: string;
@Column({ type: 'numeric', precision: 10, scale: 2, nullable: true })
amount?: number;
@Column({ type: 'varchar', length: 100, nullable: true })
currency?: string;
@Column({ type: 'varchar', length: 100, default: 'Pending Approval' })
status!: string;
@Column({ type: 'integer', default: 1 })
sequence!: number;
@Column({ type: 'text', nullable: true })
description?: string;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,43 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne, JoinColumn, OneToMany } from 'typeorm';
import type { RecoveryIncident } from './recovery-incident.entity';
import { IncidentEvaluationAction } from './incident-evaluation-action.entity';
@Entity({ name: 'tbl_incident_evaluations', schema: 'recovery_incident' })
export class IncidentEvaluation {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'incident_id', type: 'uuid' })
incidentId!: string;
@OneToOne('RecoveryIncident', 'evaluation', { onDelete: 'CASCADE' })
@JoinColumn({ name: 'incident_id' })
incident?: RecoveryIncident;
@Column({ name: 'policy_id', type: 'uuid', nullable: true })
policyId?: string;
@Column({ name: 'policy_name', type: 'varchar', length: 255 })
policyName!: string;
@Column({ name: 'recovery_score', type: 'integer', default: 75 })
recoveryScore!: number;
@Column({ name: 'matched_cohort_name', type: 'varchar', length: 255, nullable: true })
matchedCohortName?: string;
@Column({ type: 'varchar', length: 100, default: 'Applied' })
status!: string;
@Column({ name: 'ai_assessment', type: 'text', nullable: true })
aiAssessment?: string;
@OneToMany(() => IncidentEvaluationAction, (action) => action.evaluation, { cascade: true })
actions!: IncidentEvaluationAction[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -1,4 +1,5 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne } from 'typeorm';
import type { IncidentEvaluation } from './incident-evaluation.entity';
@Entity({ name: 'tbl_recovery_incidents', schema: 'recovery_incident' }) @Entity({ name: 'tbl_recovery_incidents', schema: 'recovery_incident' })
export class RecoveryIncident { export class RecoveryIncident {
@@ -17,18 +18,54 @@ export class RecoveryIncident {
@Column({ name: 'passenger_name', type: 'varchar', length: 255, nullable: true }) @Column({ name: 'passenger_name', type: 'varchar', length: 255, nullable: true })
passengerName?: string; passengerName?: string;
@Column({ type: 'varchar', length: 10, nullable: true }) @Column({ type: 'varchar', length: 20, nullable: true })
pnr?: string; pnr?: string;
@Column({ name: 'loyalty_tier', type: 'varchar', length: 100, nullable: true })
loyaltyTier?: string;
@Column({ name: 'passenger_type', type: 'varchar', length: 100, nullable: true })
passengerType?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
nationality?: string;
@Column({ name: 'special_assistance', type: 'varchar', length: 255, nullable: true })
specialAssistance?: string;
@Column({ name: 'cabin_class', type: 'varchar', length: 100, nullable: true })
cabinClass?: string;
@Column({ name: 'original_cabin', type: 'varchar', length: 100, nullable: true })
originalCabin?: string;
@Column({ name: 'actual_cabin', type: 'varchar', length: 100, nullable: true })
actualCabin?: string;
@Column({ name: 'flight_number', type: 'varchar', length: 20 }) @Column({ name: 'flight_number', type: 'varchar', length: 20 })
flightNumber!: string; flightNumber!: string;
@Column({ name: 'flight_route', type: 'varchar', length: 50 }) @Column({ name: 'flight_route', type: 'varchar', length: 100 })
flightRoute!: string; flightRoute!: string;
@Column({ type: 'varchar', length: 20, nullable: true })
origin?: string;
@Column({ type: 'varchar', length: 20, nullable: true })
destination?: string;
@Column({ type: 'varchar', length: 255, nullable: true }) @Column({ type: 'varchar', length: 255, nullable: true })
category?: string; category?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
scenario?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
jurisdiction?: string;
@Column({ name: 'delay_duration', type: 'integer', nullable: true })
delayDuration?: number;
@Column({ type: 'varchar', length: 100, nullable: true, default: 'Pending' }) @Column({ type: 'varchar', length: 100, nullable: true, default: 'Pending' })
status?: string; status?: string;
@@ -38,6 +75,9 @@ export class RecoveryIncident {
@Column({ name: 'is_perks_claimed', type: 'boolean', default: false }) @Column({ name: 'is_perks_claimed', type: 'boolean', default: false })
isPerksClaimed!: boolean; isPerksClaimed!: boolean;
@OneToOne('IncidentEvaluation', 'incident', { onDelete: 'CASCADE' })
evaluation?: IncidentEvaluation;
@CreateDateColumn({ name: 'created_at' }) @CreateDateColumn({ name: 'created_at' })
createdAt!: Date; createdAt!: Date;
@@ -34,6 +34,18 @@ export class RecoveryIncidentController {
return this.recoveryIncidentService.findOne(id); return this.recoveryIncidentService.findOne(id);
} }
@Get(':id/audit-trail')
@ApiOperation({ summary: 'Get dynamic audit trail steps with real timestamps for a recovery incident' })
getAuditTrail(@Param('id') id: string) {
return this.recoveryIncidentService.getAuditTrail(id);
}
@Post(':id/evaluate')
@ApiOperation({ summary: 'Re-run policy engine evaluation for a recovery incident' })
evaluate(@Param('id') id: string) {
return this.recoveryIncidentService.runPolicyEvaluation(id);
}
@Patch(':id/status') @Patch(':id/status')
@ApiOperation({ summary: 'Update status of a recovery incident' }) @ApiOperation({ summary: 'Update status of a recovery incident' })
updateStatus( updateStatus(
@@ -3,10 +3,23 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RecoveryIncidentService } from './recovery-incident.service'; import { RecoveryIncidentService } from './recovery-incident.service';
import { RecoveryIncidentController } from './recovery-incident.controller'; import { RecoveryIncidentController } from './recovery-incident.controller';
import { RecoveryIncident } from './entities/recovery-incident.entity'; import { RecoveryIncident } from './entities/recovery-incident.entity';
import { IncidentEvaluation } from './entities/incident-evaluation.entity';
import { IncidentEvaluationAction } from './entities/incident-evaluation-action.entity';
import { IncidentAuditLog } from './entities/incident-audit-log.entity';
import { AuditLogModule } from '../audit-log/audit-log.module'; import { AuditLogModule } from '../audit-log/audit-log.module';
import { PolicyEngineModule } from '../policy-engine/policy-engine.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([RecoveryIncident]), AuditLogModule], imports: [
TypeOrmModule.forFeature([
RecoveryIncident,
IncidentEvaluation,
IncidentEvaluationAction,
IncidentAuditLog,
]),
AuditLogModule,
PolicyEngineModule,
],
controllers: [RecoveryIncidentController], controllers: [RecoveryIncidentController],
providers: [RecoveryIncidentService], providers: [RecoveryIncidentService],
exports: [RecoveryIncidentService], exports: [RecoveryIncidentService],
@@ -2,10 +2,14 @@ import { Injectable, NotFoundException, HttpException, HttpStatus } from '@nestj
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RecoveryIncident } from './entities/recovery-incident.entity'; import { RecoveryIncident } from './entities/recovery-incident.entity';
import { IncidentEvaluation } from './entities/incident-evaluation.entity';
import { IncidentEvaluationAction } from './entities/incident-evaluation-action.entity';
import { IncidentAuditLog } from './entities/incident-audit-log.entity';
import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto'; import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto';
import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto'; import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto';
import { getTenantId } from '../../common/tenant/tenant.context'; import { getTenantId } from '../../common/tenant/tenant.context';
import { AuditLogService } from '../audit-log/audit-log.service'; import { AuditLogService } from '../audit-log/audit-log.service';
import { PolicyEvaluationService } from '../policy-engine/policy-evaluation.service';
export interface MetricCardData { export interface MetricCardData {
id?: string; id?: string;
@@ -22,9 +26,33 @@ export class RecoveryIncidentService {
constructor( constructor(
@InjectRepository(RecoveryIncident) @InjectRepository(RecoveryIncident)
private readonly recoveryIncidentRepo: Repository<RecoveryIncident>, private readonly recoveryIncidentRepo: Repository<RecoveryIncident>,
@InjectRepository(IncidentEvaluation)
private readonly evaluationRepo: Repository<IncidentEvaluation>,
@InjectRepository(IncidentEvaluationAction)
private readonly evaluationActionRepo: Repository<IncidentEvaluationAction>,
@InjectRepository(IncidentAuditLog)
private readonly incidentAuditLogRepo: Repository<IncidentAuditLog>,
private readonly policyEvaluationService: PolicyEvaluationService,
private readonly auditLogService: AuditLogService, private readonly auditLogService: AuditLogService,
) {} ) {}
async logIncidentStep(
incidentId: string,
title: string,
description: string,
performedBy?: string,
): Promise<IncidentAuditLog> {
const tenantId = getTenantId();
const logEntry = this.incidentAuditLogRepo.create({
tenantId,
incidentId,
title,
description,
performedBy: performedBy || tenantId,
});
return this.incidentAuditLogRepo.save(logEntry);
}
async create(createDto: CreateRecoveryIncidentDto): Promise<RecoveryIncident> { async create(createDto: CreateRecoveryIncidentDto): Promise<RecoveryIncident> {
const tenantId = getTenantId(); const tenantId = getTenantId();
const incident = this.recoveryIncidentRepo.create({ const incident = this.recoveryIncidentRepo.create({
@@ -32,8 +60,147 @@ export class RecoveryIncidentService {
date: new Date(createDto.date), date: new Date(createDto.date),
tenantId, tenantId,
}); });
return this.recoveryIncidentRepo.save(incident); const saved = await this.recoveryIncidentRepo.save(incident);
// CREATE is logged automatically by AuditInterceptor (POST handler)
// Log Step 1: Flight Disruption Recorded
await this.logIncidentStep(
saved.id,
'Flight Disruption Recorded',
`Disruption identified for ${saved.passengerName || 'passenger'} on flight ${saved.flightNumber} (${saved.flightRoute || 'Route'}).`,
);
// Run Policy Evaluation & persist evaluation outcome (isRerun = false)
await this.runPolicyEvaluation(saved.id, false);
return this.findOne(saved.id);
}
async runPolicyEvaluation(incidentId: string, isRerun: boolean = true): Promise<RecoveryIncident> {
const incident = await this.recoveryIncidentRepo.findOne({
where: { id: incidentId, tenantId: getTenantId() },
});
if (!incident) throw new NotFoundException(`Incident ${incidentId} not found`);
// Run Evaluation engine
const { evaluation, normalizedActions } = await this.policyEvaluationService.evaluateIncident({
recoveryCode: incident.recoveryCode,
passengerName: incident.passengerName,
pnr: incident.pnr,
loyaltyTier: incident.loyaltyTier,
passengerType: incident.passengerType,
cabinClass: incident.cabinClass,
originalCabin: incident.originalCabin,
actualCabin: incident.actualCabin,
flightNumber: incident.flightNumber,
flightRoute: incident.flightRoute,
origin: incident.origin,
destination: incident.destination,
category: incident.category,
scenario: incident.scenario,
jurisdiction: incident.jurisdiction,
delayDuration: incident.delayDuration,
});
// Delete existing evaluation for incident if any
await this.evaluationRepo.delete({ incidentId });
// Save new normalized evaluation record
const newEvaluation = this.evaluationRepo.create({
...evaluation,
incidentId,
actions: (normalizedActions || []).map((act) => this.evaluationActionRepo.create(act)),
});
await this.evaluationRepo.save(newEvaluation);
// Log audit steps directly into tbl_incident_audit_logs
if (!isRerun) {
await this.logIncidentStep(
incidentId,
'Target Audience Cohort Evaluated',
`Automated audience eligibility assessment performed against active frameworks. Matched cohort: "${evaluation.matchedCohortName || 'General Audience'}".`,
);
await this.logIncidentStep(
incidentId,
'Policy Evaluated',
`Evaluated against active rules under "${evaluation.policyName || 'Standard Policy'}". Recovery Score: ${evaluation.recoveryScore || 0}/100.`,
);
} else {
await this.logIncidentStep(
incidentId,
'Policy Engine Rerun',
`Manual re-assessment triggered. Applied: "${evaluation.policyName || 'Standard Policy'}" (${(normalizedActions || []).length} action(s) evaluated).`,
);
}
return this.findOne(incidentId);
}
async getAuditTrail(id: string): Promise<IncidentAuditLog[]> {
const tenantId = getTenantId();
const incident = await this.recoveryIncidentRepo.findOne({
where: { id, tenantId },
relations: { evaluation: true },
});
if (!incident) {
throw new NotFoundException(`RecoveryIncident with ID ${id} not found`);
}
const logs = await this.incidentAuditLogRepo.find({
where: { incidentId: id },
order: { createdAt: 'ASC' },
});
// Fallback: Seed default steps for existing records if table is empty
if (logs.length === 0) {
const createdTime = incident.createdAt || incident.date || new Date();
const passenger = incident.passengerName || 'Passenger';
const flight = incident.flightNumber || 'Flight';
const route = incident.flightRoute || 'Route';
const status = incident.status || 'Pending';
const policyName = incident.evaluation?.policyName || 'Standard Policy';
const cohortName = incident.evaluation?.matchedCohortName || 'General Audience';
const score = incident.evaluation?.recoveryScore || 0;
const initialSteps = [
{
tenantId,
incidentId: id,
title: 'Flight Disruption Recorded',
description: `Disruption identified for ${passenger} on flight ${flight} (${route}).`,
createdAt: createdTime,
},
{
tenantId,
incidentId: id,
title: 'Target Audience Cohort Evaluated',
description: `Automated audience eligibility assessment performed against active frameworks. Matched cohort: "${cohortName}".`,
createdAt: createdTime,
},
{
tenantId,
incidentId: id,
title: 'Policy Evaluated',
description: `Evaluated against active rules under "${policyName}". Recovery Score: ${score}/100.`,
createdAt: incident.evaluation?.createdAt || createdTime,
},
{
tenantId,
incidentId: id,
title: `Status: ${status}`,
description: `Case officer assigned. Case status updated to "${status}".`,
createdAt: incident.updatedAt || createdTime,
},
];
const savedLogs = await this.incidentAuditLogRepo.save(
this.incidentAuditLogRepo.create(initialSteps),
);
return savedLogs;
}
return logs;
} }
async findAll(): Promise<RecoveryIncident[]> { async findAll(): Promise<RecoveryIncident[]> {
@@ -41,6 +208,11 @@ export class RecoveryIncidentService {
return this.recoveryIncidentRepo.find({ return this.recoveryIncidentRepo.find({
where: { tenantId }, where: { tenantId },
order: { createdAt: 'DESC' }, order: { createdAt: 'DESC' },
relations: {
evaluation: {
actions: true,
},
},
}); });
} }
@@ -66,14 +238,12 @@ export class RecoveryIncidentService {
return d >= fourteenDaysAgo && d < sevenDaysAgo; return d >= fourteenDaysAgo && d < sevenDaysAgo;
}); });
// Helper for parsing currency string into number
const parseValue = (val?: string): number => { const parseValue = (val?: string): number => {
if (!val) return 0; if (!val) return 0;
const num = parseFloat(val.replace(/[^0-9.]/g, '')) || 0; const num = parseFloat(val.replace(/[^0-9.]/g, '')) || 0;
return num; return num;
}; };
// Helper for formatting sum into string (e.g. $412k, $1.2M, $500)
const formatCurrency = (amount: number): string => { const formatCurrency = (amount: number): string => {
if (amount >= 1000000) { if (amount >= 1000000) {
return `$${(amount / 1000000).toFixed(1).replace(/\.0$/, '')}M`; return `$${(amount / 1000000).toFixed(1).replace(/\.0$/, '')}M`;
@@ -84,19 +254,16 @@ export class RecoveryIncidentService {
return `$${amount.toLocaleString()}`; return `$${amount.toLocaleString()}`;
}; };
// Helper to check if an incident is satisfied
const isSatisfied = (i: RecoveryIncident): boolean => { const isSatisfied = (i: RecoveryIncident): boolean => {
const s = (i.status || '').toLowerCase(); const s = (i.status || '').toLowerCase();
return s.includes('appr') || s.includes('active') || s.includes('success') || Boolean(i.isPerksClaimed); return s.includes('appr') || s.includes('active') || s.includes('success') || Boolean(i.isPerksClaimed);
}; };
// Helper to check if an incident is pending
const isPending = (i: RecoveryIncident): boolean => { const isPending = (i: RecoveryIncident): boolean => {
const s = (i.status || '').toLowerCase(); const s = (i.status || '').toLowerCase();
return s.includes('pending') || s.includes('review') || s.includes('new'); return s.includes('pending') || s.includes('review') || s.includes('new');
}; };
// 1. Total Recoveries
const totalCount = incidents.length; const totalCount = incidents.length;
const currentWeekTotal = currentWeekIncidents.length; const currentWeekTotal = currentWeekIncidents.length;
const previousWeekTotal = previousWeekIncidents.length; const previousWeekTotal = previousWeekIncidents.length;
@@ -115,7 +282,6 @@ export class RecoveryIncidentService {
totalSparkColor = 'green'; totalSparkColor = 'green';
} }
// 2. Pending Approval
const pendingIncidents = incidents.filter(isPending); const pendingIncidents = incidents.filter(isPending);
const pendingCount = pendingIncidents.length; const pendingCount = pendingIncidents.length;
const currentWeekPending = currentWeekIncidents.filter(isPending).length; const currentWeekPending = currentWeekIncidents.filter(isPending).length;
@@ -135,7 +301,6 @@ export class RecoveryIncidentService {
pendingSparkColor = 'green'; pendingSparkColor = 'green';
} }
// 3. Refund Value
const totalRefundSum = incidents.reduce((acc, curr) => acc + parseValue(curr.value), 0); const totalRefundSum = incidents.reduce((acc, curr) => acc + parseValue(curr.value), 0);
const currentWeekRefundSum = currentWeekIncidents.reduce((acc, curr) => acc + parseValue(curr.value), 0); const currentWeekRefundSum = currentWeekIncidents.reduce((acc, curr) => acc + parseValue(curr.value), 0);
const previousWeekRefundSum = previousWeekIncidents.reduce((acc, curr) => acc + parseValue(curr.value), 0); const previousWeekRefundSum = previousWeekIncidents.reduce((acc, curr) => acc + parseValue(curr.value), 0);
@@ -154,7 +319,6 @@ export class RecoveryIncidentService {
refundSparkColor = 'green'; refundSparkColor = 'green';
} }
// 4. Customer Satisfaction
const satisfiedCount = incidents.filter(isSatisfied).length; const satisfiedCount = incidents.filter(isSatisfied).length;
const satisfactionRate = totalCount > 0 ? Math.round((satisfiedCount / totalCount) * 100) : 0; const satisfactionRate = totalCount > 0 ? Math.round((satisfiedCount / totalCount) * 100) : 0;
@@ -223,6 +387,11 @@ export class RecoveryIncidentService {
const tenantId = getTenantId(); const tenantId = getTenantId();
const incident = await this.recoveryIncidentRepo.findOne({ const incident = await this.recoveryIncidentRepo.findOne({
where: { id, tenantId }, where: { id, tenantId },
relations: {
evaluation: {
actions: true,
},
},
}); });
if (!incident) { if (!incident) {
@@ -244,45 +413,51 @@ export class RecoveryIncidentService {
Object.assign(before, updateData); Object.assign(before, updateData);
const after = await this.recoveryIncidentRepo.save(before); const after = await this.recoveryIncidentRepo.save(before);
// Log UPDATE with before/after snapshots // Re-run policy engine evaluation upon update
await this.auditLogService.log({ await this.runPolicyEvaluation(id, true);
module: 'recovery-incident',
action: 'UPDATE',
entityId: id,
entityLabel: before.recoveryCode ?? before.flightNumber,
before: beforeSnapshot,
after: { ...after },
performedBy: getTenantId(),
});
return after; // Log UPDATE step to incident audit log table
await this.logIncidentStep(
id,
'Incident Updated',
`Incident parameters updated for passenger ${after.passengerName || 'passenger'}.`,
);
return this.findOne(id);
} }
async updateStatus(id: string, status: string): Promise<RecoveryIncident> { async updateStatus(id: string, status: string): Promise<RecoveryIncident> {
const incident = await this.findOne(id); const incident = await this.findOne(id);
incident.status = status; incident.status = status;
return this.recoveryIncidentRepo.save(incident); await this.recoveryIncidentRepo.save(incident);
// Log status update step to incident audit log table
await this.logIncidentStep(
id,
`Status: ${status}`,
`Case status updated to "${status}".`,
);
return this.findOne(id);
} }
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const incident = await this.findOne(id); const tenantId = getTenantId();
const beforeSnapshot = { ...incident }; const incident = await this.recoveryIncidentRepo.findOne({
where: { id, tenantId },
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(),
}); });
if (!incident) {
throw new NotFoundException(`RecoveryIncident with ID ${id} not found`);
}
// Delete audit log records associated with this incident
await this.incidentAuditLogRepo.delete({ incidentId: id });
// Delete evaluation records associated with this incident
await this.evaluationRepo.delete({ incidentId: id });
// Delete the recovery incident record strictly by ID
await this.recoveryIncidentRepo.delete({ id, tenantId });
} }
// ─── AviationStack proxy ──────────────────────────────────────────────────
async flightLookup(flightIata: string): Promise<any> { async flightLookup(flightIata: string): Promise<any> {
const apiKey = process.env.AVIATION_STACK_API_KEY; const apiKey = process.env.AVIATION_STACK_API_KEY;
if (!apiKey) { if (!apiKey) {