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(),
"tenantId" UUID NOT NULL REFERENCES tenant.tbl_tenants(id) ON DELETE CASCADE,
policy_name VARCHAR(200) NOT NULL,
jurisdiction_id UUID REFERENCES masters_lookup.tbl_jurisdictions(id) ON DELETE SET NULL,
description TEXT,
status VARCHAR NOT NULL DEFAULT 'draft',
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()
);
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 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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 });
await dataSource.initialize();
// Create schemas if they don't exist
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;`);
// Ensure schemas exist safely without dropping user created cohorts or policies
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_action_builder";`);
@@ -130,6 +130,11 @@ export class CreatePolicyDto {
@IsNotEmpty()
policyName: string;
@IsArray()
@IsOptional()
@IsUUID('all', { each: true })
jurisdictionIds?: string[];
@IsUUID()
@IsOptional()
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 { PolicyStatus, AudienceType } from './policy.enums';
import { PolicyTargetAudience } from './policy-target-audience.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' })
export class Policy extends TenantOwnedEntity {
@@ -13,9 +13,6 @@ export class Policy extends TenantOwnedEntity {
@Column({ name: 'policy_name', type: 'varchar', length: 200 })
policyName!: string;
@Column({ name: 'jurisdiction_id', type: 'uuid', nullable: true })
jurisdictionId!: string;
@Column({ type: 'text', nullable: true })
description!: string;
@@ -50,13 +47,12 @@ export class Policy extends TenantOwnedEntity {
updatedAt!: Date;
// Relations
@OneToMany(() => PolicyJurisdiction, (pj) => pj.policy, { cascade: true, onDelete: 'CASCADE' })
jurisdictions!: PolicyJurisdiction[];
@OneToMany(() => PolicyTargetAudience, (target) => target.policy, { cascade: true, onDelete: 'CASCADE' })
targetAudiences!: PolicyTargetAudience[];
@OneToMany(() => PolicyRule, (rule) => rule.policy, { cascade: true, onDelete: 'CASCADE' })
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 { TypeOrmModule } from '@nestjs/typeorm';
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 { Policy } from './entities/policy.entity';
import { PolicyJurisdiction } from './entities/policy-jurisdiction.entity';
import { PolicyTargetAudience } from './entities/policy-target-audience.entity';
import { PolicyRule } from './entities/policy-rule.entity';
import { RuleCondition } from './entities/rule-condition.entity';
import { PolicyAction } from './entities/policy-action.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 { AuditLogModule } from '../audit-log/audit-log.module';
@@ -15,19 +32,41 @@ import { AuditLogModule } from '../audit-log/audit-log.module';
imports: [
TypeOrmModule.forFeature([
Policy,
PolicyJurisdiction,
PolicyTargetAudience,
PolicyRule,
RuleCondition,
PolicyAction,
PolicyActionValue,
Cohort,
MembershipTier,
PassengerType,
CabinClass,
Jurisdiction,
ConditionField,
Operator,
]),
MasterDataModule,
AuditLogModule,
],
controllers: [PolicyEngineController],
providers: [PolicyEngineService],
exports: [PolicyEngineService],
providers: [
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 { getTenantId } from '../../common/tenant/tenant.context';
import { Policy } from './entities/policy.entity';
import { PolicyJurisdiction } from './entities/policy-jurisdiction.entity';
import { PolicyTargetAudience } from './entities/policy-target-audience.entity';
import { PolicyRule } from './entities/policy-rule.entity';
import { RuleCondition } from './entities/rule-condition.entity';
@@ -26,7 +27,9 @@ export class PolicyEngineService {
private getRelations() {
return {
jurisdiction: true,
jurisdictions: {
jurisdiction: true,
},
targetAudiences: true,
rules: {
ruleCategory: true,
@@ -43,13 +46,39 @@ export class PolicyEngineService {
async create(createPolicyDto: CreatePolicyDto): Promise<Policy> {
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({
...createPolicyDto,
...policyData,
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);
return this.findOne(saved.id);
// CREATE is logged automatically by AuditInterceptor (POST handler)
}
async findAll(
@@ -101,9 +130,10 @@ export class PolicyEngineService {
const before = await this.findOne(id);
const beforeSnapshot = { ...before };
const { targetAudiences, rules, ...policyData } = updatePolicyDto;
const { jurisdictionIds, jurisdictionId, targetAudiences, rules, ...policyData } = updatePolicyDto;
const after = await this.policyRepository.manager.transaction(async (transactionalEntityManager) => {
await transactionalEntityManager.delete(PolicyJurisdiction, { policyId: id });
await transactionalEntityManager.delete(PolicyTargetAudience, { policyId: id });
const existingRules = await transactionalEntityManager.find(PolicyRule, { where: { policyId: id } });
@@ -117,6 +147,16 @@ export class PolicyEngineService {
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) {
policy.targetAudiences = targetAudiences.map((target) =>
transactionalEntityManager.create(PolicyTargetAudience, { ...target, policyId: id }),
@@ -162,7 +202,7 @@ export class PolicyEngineService {
module: 'policy-engine',
action: 'UPDATE',
entityId: id,
entityLabel: (before as any).name ?? id,
entityLabel: (before as any).policyName ?? id,
before: beforeSnapshot,
after: { ...after },
performedBy: getTenantId(),
@@ -182,7 +222,7 @@ export class PolicyEngineService {
module: 'policy-engine',
action: 'STATUS_CHANGE',
entityId: id,
entityLabel: (policy as any).name ?? id,
entityLabel: (policy as any).policyName ?? id,
before: { status: beforeStatus },
after: { status },
performedBy: getTenantId(),
@@ -201,7 +241,7 @@ export class PolicyEngineService {
module: 'policy-engine',
action: 'DELETE',
entityId: id,
entityLabel: (beforeSnapshot as any).name ?? id,
entityLabel: (beforeSnapshot as any).policyName ?? id,
before: beforeSnapshot,
after: undefined,
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 {
@IsString()
@@ -17,6 +17,34 @@ export class CreateRecoveryIncidentDto {
@IsOptional()
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()
@IsNotEmpty()
flightNumber: string;
@@ -25,10 +53,30 @@ export class CreateRecoveryIncidentDto {
@IsNotEmpty()
flightRoute: string;
@IsString()
@IsOptional()
origin?: string;
@IsString()
@IsOptional()
destination?: string;
@IsString()
@IsOptional()
category?: string;
@IsString()
@IsOptional()
scenario?: string;
@IsString()
@IsOptional()
jurisdiction?: string;
@IsNumber()
@IsOptional()
delayDuration?: number;
@IsString()
@IsOptional()
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' })
export class RecoveryIncident {
@@ -17,18 +18,54 @@ export class RecoveryIncident {
@Column({ name: 'passenger_name', type: 'varchar', length: 255, nullable: true })
passengerName?: string;
@Column({ type: 'varchar', length: 10, nullable: true })
@Column({ type: 'varchar', length: 20, nullable: true })
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 })
flightNumber!: string;
@Column({ name: 'flight_route', type: 'varchar', length: 50 })
@Column({ name: 'flight_route', type: 'varchar', length: 100 })
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 })
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' })
status?: string;
@@ -38,6 +75,9 @@ export class RecoveryIncident {
@Column({ name: 'is_perks_claimed', type: 'boolean', default: false })
isPerksClaimed!: boolean;
@OneToOne('IncidentEvaluation', 'incident', { onDelete: 'CASCADE' })
evaluation?: IncidentEvaluation;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@@ -34,6 +34,18 @@ export class RecoveryIncidentController {
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')
@ApiOperation({ summary: 'Update status of a recovery incident' })
updateStatus(
@@ -3,10 +3,23 @@ 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 { 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 { PolicyEngineModule } from '../policy-engine/policy-engine.module';
@Module({
imports: [TypeOrmModule.forFeature([RecoveryIncident]), AuditLogModule],
imports: [
TypeOrmModule.forFeature([
RecoveryIncident,
IncidentEvaluation,
IncidentEvaluationAction,
IncidentAuditLog,
]),
AuditLogModule,
PolicyEngineModule,
],
controllers: [RecoveryIncidentController],
providers: [RecoveryIncidentService],
exports: [RecoveryIncidentService],
@@ -2,10 +2,14 @@ import { Injectable, NotFoundException, HttpException, HttpStatus } from '@nestj
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
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 { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto';
import { getTenantId } from '../../common/tenant/tenant.context';
import { AuditLogService } from '../audit-log/audit-log.service';
import { PolicyEvaluationService } from '../policy-engine/policy-evaluation.service';
export interface MetricCardData {
id?: string;
@@ -22,9 +26,33 @@ export class RecoveryIncidentService {
constructor(
@InjectRepository(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,
) {}
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> {
const tenantId = getTenantId();
const incident = this.recoveryIncidentRepo.create({
@@ -32,8 +60,147 @@ export class RecoveryIncidentService {
date: new Date(createDto.date),
tenantId,
});
return this.recoveryIncidentRepo.save(incident);
// CREATE is logged automatically by AuditInterceptor (POST handler)
const saved = await this.recoveryIncidentRepo.save(incident);
// 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[]> {
@@ -41,6 +208,11 @@ export class RecoveryIncidentService {
return this.recoveryIncidentRepo.find({
where: { tenantId },
order: { createdAt: 'DESC' },
relations: {
evaluation: {
actions: true,
},
},
});
}
@@ -66,14 +238,12 @@ export class RecoveryIncidentService {
return d >= fourteenDaysAgo && d < sevenDaysAgo;
});
// Helper for parsing currency string into number
const parseValue = (val?: string): number => {
if (!val) return 0;
const num = parseFloat(val.replace(/[^0-9.]/g, '')) || 0;
return num;
};
// Helper for formatting sum into string (e.g. $412k, $1.2M, $500)
const formatCurrency = (amount: number): string => {
if (amount >= 1000000) {
return `$${(amount / 1000000).toFixed(1).replace(/\.0$/, '')}M`;
@@ -84,19 +254,16 @@ export class RecoveryIncidentService {
return `$${amount.toLocaleString()}`;
};
// Helper to check if an incident is satisfied
const isSatisfied = (i: RecoveryIncident): boolean => {
const s = (i.status || '').toLowerCase();
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 s = (i.status || '').toLowerCase();
return s.includes('pending') || s.includes('review') || s.includes('new');
};
// 1. Total Recoveries
const totalCount = incidents.length;
const currentWeekTotal = currentWeekIncidents.length;
const previousWeekTotal = previousWeekIncidents.length;
@@ -115,7 +282,6 @@ export class RecoveryIncidentService {
totalSparkColor = 'green';
}
// 2. Pending Approval
const pendingIncidents = incidents.filter(isPending);
const pendingCount = pendingIncidents.length;
const currentWeekPending = currentWeekIncidents.filter(isPending).length;
@@ -135,7 +301,6 @@ export class RecoveryIncidentService {
pendingSparkColor = 'green';
}
// 3. Refund Value
const totalRefundSum = incidents.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);
@@ -154,7 +319,6 @@ export class RecoveryIncidentService {
refundSparkColor = 'green';
}
// 4. Customer Satisfaction
const satisfiedCount = incidents.filter(isSatisfied).length;
const satisfactionRate = totalCount > 0 ? Math.round((satisfiedCount / totalCount) * 100) : 0;
@@ -223,6 +387,11 @@ export class RecoveryIncidentService {
const tenantId = getTenantId();
const incident = await this.recoveryIncidentRepo.findOne({
where: { id, tenantId },
relations: {
evaluation: {
actions: true,
},
},
});
if (!incident) {
@@ -244,45 +413,51 @@ export class RecoveryIncidentService {
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(),
});
// Re-run policy engine evaluation upon update
await this.runPolicyEvaluation(id, true);
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> {
const incident = await this.findOne(id);
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> {
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(),
const tenantId = getTenantId();
const incident = await this.recoveryIncidentRepo.findOne({
where: { id, tenantId },
});
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> {
const apiKey = process.env.AVIATION_STACK_API_KEY;
if (!apiKey) {