diff --git a/database/schema.sql b/database/schema.sql index ab236ae..0deb6fe 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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, diff --git a/src/database/database.module.ts b/src/database/database.module.ts index 5b6bf23..b8df40a 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -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";`); diff --git a/src/modules/policy-engine/dto/create-policy.dto.ts b/src/modules/policy-engine/dto/create-policy.dto.ts index bc68b5c..43e0134 100644 --- a/src/modules/policy-engine/dto/create-policy.dto.ts +++ b/src/modules/policy-engine/dto/create-policy.dto.ts @@ -130,6 +130,11 @@ export class CreatePolicyDto { @IsNotEmpty() policyName: string; + @IsArray() + @IsOptional() + @IsUUID('all', { each: true }) + jurisdictionIds?: string[]; + @IsUUID() @IsOptional() jurisdictionId?: string; diff --git a/src/modules/policy-engine/entities/policy-jurisdiction.entity.ts b/src/modules/policy-engine/entities/policy-jurisdiction.entity.ts new file mode 100644 index 0000000..0b0e2e4 --- /dev/null +++ b/src/modules/policy-engine/entities/policy-jurisdiction.entity.ts @@ -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; +} diff --git a/src/modules/policy-engine/entities/policy.entity.ts b/src/modules/policy-engine/entities/policy.entity.ts index 5942443..0f528be 100644 --- a/src/modules/policy-engine/entities/policy.entity.ts +++ b/src/modules/policy-engine/entities/policy.entity.ts @@ -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; } diff --git a/src/modules/policy-engine/policy-engine.module.ts b/src/modules/policy-engine/policy-engine.module.ts index 6322699..27ff7a3 100644 --- a/src/modules/policy-engine/policy-engine.module.ts +++ b/src/modules/policy-engine/policy-engine.module.ts @@ -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 {} diff --git a/src/modules/policy-engine/policy-engine.service.ts b/src/modules/policy-engine/policy-engine.service.ts index ef3d3e3..300c171 100644 --- a/src/modules/policy-engine/policy-engine.service.ts +++ b/src/modules/policy-engine/policy-engine.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { getTenantId } from '../../common/tenant/tenant.context'; import { Policy } from './entities/policy.entity'; +import { PolicyJurisdiction } from './entities/policy-jurisdiction.entity'; import { PolicyTargetAudience } from './entities/policy-target-audience.entity'; import { PolicyRule } from './entities/policy-rule.entity'; import { RuleCondition } from './entities/rule-condition.entity'; @@ -26,7 +27,9 @@ export class PolicyEngineService { private getRelations() { return { - jurisdiction: true, + jurisdictions: { + jurisdiction: true, + }, targetAudiences: true, rules: { ruleCategory: true, @@ -43,13 +46,39 @@ export class PolicyEngineService { async create(createPolicyDto: CreatePolicyDto): Promise { 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(), diff --git a/src/modules/policy-engine/policy-evaluation.service.ts b/src/modules/policy-engine/policy-evaluation.service.ts new file mode 100644 index 0000000..6f51a00 --- /dev/null +++ b/src/modules/policy-engine/policy-evaluation.service.ts @@ -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; + normalizedActions: Partial[]; +} + +@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 { + 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, + }; + } +} diff --git a/src/modules/policy-engine/services/action-resolver.service.ts b/src/modules/policy-engine/services/action-resolver.service.ts new file mode 100644 index 0000000..0bba52b --- /dev/null +++ b/src/modules/policy-engine/services/action-resolver.service.ts @@ -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; + normalizedAction: Partial; +} + +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 { + 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 { + 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 { + 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 = {}; + 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 = { + 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, + 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'; + } +} diff --git a/src/modules/policy-engine/services/cohort-evaluation.service.ts b/src/modules/policy-engine/services/cohort-evaluation.service.ts new file mode 100644 index 0000000..c35c881 --- /dev/null +++ b/src/modules/policy-engine/services/cohort-evaluation.service.ts @@ -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, + ) {} + + async evaluateCohorts(ctx: IncidentContext): Promise { + 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; + } +} diff --git a/src/modules/policy-engine/services/master-value-resolver.service.ts b/src/modules/policy-engine/services/master-value-resolver.service.ts new file mode 100644 index 0000000..0d80c59 --- /dev/null +++ b/src/modules/policy-engine/services/master-value-resolver.service.ts @@ -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, + @InjectRepository(PassengerType) + private readonly passengerTypeRepo: Repository, + @InjectRepository(CabinClass) + private readonly cabinClassRepo: Repository, + @InjectRepository(Jurisdiction) + private readonly jurisdictionRepo: Repository, + ) {} + + async resolveIncidentContext(input: EvaluationInput): Promise { + 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, + }; + } +} diff --git a/src/modules/policy-engine/services/policy-applicability.service.ts b/src/modules/policy-engine/services/policy-applicability.service.ts new file mode 100644 index 0000000..0caeeb2 --- /dev/null +++ b/src/modules/policy-engine/services/policy-applicability.service.ts @@ -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, + ) {} + + async findApplicablePolicies( + ctx: IncidentContext, + matchedCohorts: CohortMatchResult[], + ): Promise { + 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; + } +} diff --git a/src/modules/policy-engine/services/rule-evaluation.service.ts b/src/modules/policy-engine/services/rule-evaluation.service.ts new file mode 100644 index 0000000..504ce0f --- /dev/null +++ b/src/modules/policy-engine/services/rule-evaluation.service.ts @@ -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, + @InjectRepository(Operator) + private readonly operatorRepo: Repository, + ) {} + + async evaluateRule( + rule: PolicyRule, + ctx: IncidentContext, + ): Promise { + 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); + } + } +} diff --git a/src/modules/recovery-incident/dto/create-recovery-incident.dto.ts b/src/modules/recovery-incident/dto/create-recovery-incident.dto.ts index 95b132a..67441c0 100644 --- a/src/modules/recovery-incident/dto/create-recovery-incident.dto.ts +++ b/src/modules/recovery-incident/dto/create-recovery-incident.dto.ts @@ -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; diff --git a/src/modules/recovery-incident/entities/incident-audit-log.entity.ts b/src/modules/recovery-incident/entities/incident-audit-log.entity.ts new file mode 100644 index 0000000..035b730 --- /dev/null +++ b/src/modules/recovery-incident/entities/incident-audit-log.entity.ts @@ -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; +} diff --git a/src/modules/recovery-incident/entities/incident-evaluation-action.entity.ts b/src/modules/recovery-incident/entities/incident-evaluation-action.entity.ts new file mode 100644 index 0000000..9ed4346 --- /dev/null +++ b/src/modules/recovery-incident/entities/incident-evaluation-action.entity.ts @@ -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; +} diff --git a/src/modules/recovery-incident/entities/incident-evaluation.entity.ts b/src/modules/recovery-incident/entities/incident-evaluation.entity.ts new file mode 100644 index 0000000..a33c1c7 --- /dev/null +++ b/src/modules/recovery-incident/entities/incident-evaluation.entity.ts @@ -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; +} diff --git a/src/modules/recovery-incident/entities/recovery-incident.entity.ts b/src/modules/recovery-incident/entities/recovery-incident.entity.ts index cb8a5d2..b9ca107 100644 --- a/src/modules/recovery-incident/entities/recovery-incident.entity.ts +++ b/src/modules/recovery-incident/entities/recovery-incident.entity.ts @@ -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; diff --git a/src/modules/recovery-incident/recovery-incident.controller.ts b/src/modules/recovery-incident/recovery-incident.controller.ts index 73e2a7d..7c81056 100644 --- a/src/modules/recovery-incident/recovery-incident.controller.ts +++ b/src/modules/recovery-incident/recovery-incident.controller.ts @@ -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( diff --git a/src/modules/recovery-incident/recovery-incident.module.ts b/src/modules/recovery-incident/recovery-incident.module.ts index d2c81a7..1282171 100644 --- a/src/modules/recovery-incident/recovery-incident.module.ts +++ b/src/modules/recovery-incident/recovery-incident.module.ts @@ -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], diff --git a/src/modules/recovery-incident/recovery-incident.service.ts b/src/modules/recovery-incident/recovery-incident.service.ts index 3c1137f..f52bc6b 100644 --- a/src/modules/recovery-incident/recovery-incident.service.ts +++ b/src/modules/recovery-incident/recovery-incident.service.ts @@ -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, + @InjectRepository(IncidentEvaluation) + private readonly evaluationRepo: Repository, + @InjectRepository(IncidentEvaluationAction) + private readonly evaluationActionRepo: Repository, + @InjectRepository(IncidentAuditLog) + private readonly incidentAuditLogRepo: Repository, + private readonly policyEvaluationService: PolicyEvaluationService, private readonly auditLogService: AuditLogService, ) {} + async logIncidentStep( + incidentId: string, + title: string, + description: string, + performedBy?: string, + ): Promise { + 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 { 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 { + 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 { + 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 { @@ -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 { 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 { - 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 { const apiKey = process.env.AVIATION_STACK_API_KEY; if (!apiKey) {