feat: implement policy engine and recovery incident modules with evaluation logic and database schema
This commit is contained in:
+7
-1
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user