305 lines
9.3 KiB
TypeScript
305 lines
9.3 KiB
TypeScript
/**
|
|
* 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 {
|
|
EligibilityCondition,
|
|
EligibilityFilterDto,
|
|
EvaluationReason,
|
|
EvaluationResultDto,
|
|
ParsedEligibilityFilter
|
|
} from '../types/Eligibility';
|
|
|
|
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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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})`;
|
|
}
|
|
}
|