/** * Service: EligibilityEvaluator * * Pure domain service for DSL-based eligibility evaluation. * Parses DSL expressions and evaluates them against rating data. * Provides explainable results with detailed reasons. */ import { EvaluationResultDto, EvaluationReason } from '../../application/dtos/EvaluationResultDto'; import { EligibilityFilterDto, ParsedEligibilityFilter, EligibilityCondition } from '../../application/dtos/EligibilityFilterDto'; export interface RatingData { platform: { [dimension: string]: number; }; external: { [game: string]: { [type: string]: number; }; }; } export class EligibilityEvaluator { /** * Main entry point: evaluate DSL against rating data */ evaluate(filter: EligibilityFilterDto, ratingData: RatingData): EvaluationResultDto { try { const parsed = this.parseDSL(filter.dsl); const reasons: EvaluationReason[] = []; // Evaluate each condition for (const condition of parsed.conditions) { const reason = this.evaluateCondition(condition, ratingData); reasons.push(reason); } // Determine overall eligibility based on logical operator const eligible = parsed.logicalOperator === 'AND' ? reasons.every(r => !r.failed) : reasons.some(r => !r.failed); // Build summary const summary = this.buildSummary(eligible, reasons, parsed.logicalOperator); const metadata: Record = { filter: filter.dsl, }; if (filter.context?.userId) { metadata.userId = filter.context.userId; } return { eligible, reasons, summary, evaluatedAt: new Date().toISOString(), metadata, }; } catch (error) { // Handle parsing errors const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error'; const metadata: Record = { filter: filter.dsl, error: errorMessage, }; if (filter.context?.userId) { metadata.userId = filter.context.userId; } return { eligible: false, reasons: [], summary: `Failed to evaluate filter: ${errorMessage}`, evaluatedAt: new Date().toISOString(), metadata, }; } } /** * Parse DSL string into structured conditions * Supports: platform.{dim} >= 55 OR external.{game}.{type} between 2000 2500 */ parseDSL(dsl: string): ParsedEligibilityFilter { // Normalize and tokenize const normalized = dsl.trim().replace(/\s+/g, ' '); // Determine logical operator const hasOR = normalized.toUpperCase().includes(' OR '); const hasAND = normalized.toUpperCase().includes(' AND '); if (hasOR && hasAND) { throw new Error('Mixed AND/OR not supported. Use parentheses or separate filters.'); } const logicalOperator = hasOR ? 'OR' : 'AND'; const separator = hasOR ? ' OR ' : ' AND '; // Split into individual conditions const conditionStrings = normalized.split(separator).map(s => s.trim()); const conditions: EligibilityCondition[] = conditionStrings.map(str => { return this.parseCondition(str); }); return { conditions, logicalOperator, }; } /** * Parse a single condition string * Examples: * - "platform.driving >= 55" * - "external.iracing.iRating between 2000 2500" */ parseCondition(conditionStr: string): EligibilityCondition { // Check for "between" operator const betweenMatch = conditionStr.match(/^(.+?)\s+between\s+(\d+)\s+(\d+)$/i); if (betweenMatch) { const targetExpr = betweenMatch[1]?.trim(); const minStr = betweenMatch[2]; const maxStr = betweenMatch[3]; if (!targetExpr || !minStr || !maxStr) { throw new Error(`Invalid between condition: "${conditionStr}"`); } const parsed = this.parseTargetExpression(targetExpr); return { target: parsed.target, dimension: parsed.dimension, game: parsed.game, operator: 'between', expected: [parseFloat(minStr), parseFloat(maxStr)], } as unknown as EligibilityCondition; } // Check for comparison operators const compareMatch = conditionStr.match(/^(.+?)\s*(>=|<=|>|<|=|!=)\s*(\d+(?:\.\d+)?)$/); if (compareMatch) { const targetExpr = compareMatch[1]?.trim(); const operator = compareMatch[2]; const valueStr = compareMatch[3]; if (!targetExpr || !operator || !valueStr) { throw new Error(`Invalid comparison condition: "${conditionStr}"`); } const parsed = this.parseTargetExpression(targetExpr); return { target: parsed.target, dimension: parsed.dimension, game: parsed.game, operator: operator as EligibilityCondition['operator'], expected: parseFloat(valueStr), } as unknown as EligibilityCondition; } throw new Error(`Invalid condition format: "${conditionStr}"`); } /** * Parse target expression like "platform.driving" or "external.iracing.iRating" */ private parseTargetExpression(expr: string): { target: 'platform' | 'external'; dimension?: string; game?: string } { const parts = expr.split('.'); if (parts[0] === 'platform') { if (parts.length !== 2) { throw new Error(`Invalid platform expression: "${expr}"`); } const dimension = parts[1]; if (!dimension) { throw new Error(`Invalid platform expression: "${expr}"`); } return { target: 'platform', dimension }; } if (parts[0] === 'external') { if (parts.length !== 3) { throw new Error(`Invalid external expression: "${expr}"`); } const game = parts[1]; const dimension = parts[2]; if (!game || !dimension) { throw new Error(`Invalid external expression: "${expr}"`); } return { target: 'external', game, dimension }; } throw new Error(`Unknown target: "${parts[0]}"`); } /** * Evaluate a single condition against rating data */ private evaluateCondition(condition: EligibilityCondition, ratingData: RatingData): EvaluationReason { // Get actual value let actual: number | undefined; if (condition.target === 'platform' && condition.dimension) { actual = ratingData.platform[condition.dimension]; } else if (condition.target === 'external' && condition.game && condition.dimension) { actual = ratingData.external[condition.game]?.[condition.dimension]; } // Handle missing data if (actual === undefined || actual === null || isNaN(actual)) { return { target: condition.target === 'platform' ? `platform.${condition.dimension}` : `external.${condition.game}.${condition.dimension}`, operator: condition.operator, expected: condition.expected, actual: 0, failed: true, message: `Missing data for ${condition.target === 'platform' ? `platform dimension "${condition.dimension}"` : `external game "${condition.game}" type "${condition.dimension}"`}`, }; } // Evaluate based on operator let failed = false; switch (condition.operator) { case '>=': failed = actual < (condition.expected as number); break; case '<=': failed = actual > (condition.expected as number); break; case '>': failed = actual <= (condition.expected as number); break; case '<': failed = actual >= (condition.expected as number); break; case '=': failed = actual !== (condition.expected as number); break; case '!=': failed = actual === (condition.expected as number); break; case 'between': { const [min, max] = condition.expected as [number, number]; failed = actual < min || actual > max; break; } default: throw new Error(`Unknown operator: ${condition.operator}`); } const targetStr = condition.target === 'platform' ? `platform.${condition.dimension}` : `external.${condition.game}.${condition.dimension}`; const expectedStr = condition.operator === 'between' ? `${(condition.expected as [number, number])[0]} to ${(condition.expected as [number, number])[1]}` : `${condition.operator} ${condition.expected}`; return { target: targetStr, operator: condition.operator, expected: condition.expected, actual, failed, message: failed ? `Expected ${targetStr} ${expectedStr}, but got ${actual}` : `${targetStr} ${expectedStr} ✓`, }; } /** * Build human-readable summary */ private buildSummary(eligible: boolean, reasons: EvaluationReason[], operator: 'AND' | 'OR'): string { const failedReasons = reasons.filter(r => r.failed); if (eligible) { if (operator === 'OR') { return `Eligible: At least one condition met (${reasons.filter(r => !r.failed).length}/${reasons.length})`; } return `Eligible: All conditions met (${reasons.length}/${reasons.length})`; } if (operator === 'AND') { return `Not eligible: ${failedReasons.length} condition(s) failed`; } return `Not eligible: All conditions failed (${failedReasons.length}/${reasons.length})`; } }