/** * @ai-summary VIN decoding service with circuit breaker and fallback * @ai-context PostgreSQL first, vPIC API fallback, Redis caching */ import { Pool } from 'pg'; import CircuitBreaker from 'opossum'; import { VehicleDataRepository } from '../data/vehicle-data.repository'; import { VPICClient } from '../data/vpic-client'; import { PlatformCacheService } from './platform-cache.service'; import { VINDecodeResponse, VINDecodeResult } from '../models/responses'; import { logger } from '../../../core/logging/logger'; export class VINDecodeService { private repository: VehicleDataRepository; private vpicClient: VPICClient; private cache: PlatformCacheService; private circuitBreaker: CircuitBreaker; constructor(cache: PlatformCacheService) { this.cache = cache; this.repository = new VehicleDataRepository(); this.vpicClient = new VPICClient(); this.circuitBreaker = new CircuitBreaker( async (vin: string) => this.vpicClient.decodeVIN(vin), { timeout: 6000, errorThresholdPercentage: 50, resetTimeout: 30000, name: 'vpic-api' } ); this.circuitBreaker.on('open', () => { logger.warn('Circuit breaker opened for vPIC API'); }); this.circuitBreaker.on('halfOpen', () => { logger.info('Circuit breaker half-open for vPIC API'); }); this.circuitBreaker.on('close', () => { logger.info('Circuit breaker closed for vPIC API'); }); } /** * Validate VIN format */ validateVIN(vin: string): { valid: boolean; error?: string } { if (vin.length !== 17) { return { valid: false, error: 'VIN must be exactly 17 characters' }; } const invalidChars = /[IOQ]/i; if (invalidChars.test(vin)) { return { valid: false, error: 'VIN contains invalid characters (cannot contain I, O, Q)' }; } const validFormat = /^[A-HJ-NPR-Z0-9]{17}$/i; if (!validFormat.test(vin)) { return { valid: false, error: 'VIN contains invalid characters' }; } return { valid: true }; } /** * Decode VIN with multi-tier strategy: * 1. Check cache * 2. Try PostgreSQL function * 3. Fallback to vPIC API (with circuit breaker) */ async decodeVIN(pool: Pool, vin: string): Promise { const normalizedVIN = vin.toUpperCase().trim(); const validation = this.validateVIN(normalizedVIN); if (!validation.valid) { return { vin: normalizedVIN, result: null, success: false, error: validation.error }; } try { const cached = await this.cache.getVINDecode(normalizedVIN); if (cached) { logger.debug('VIN decode result retrieved from cache', { vin: normalizedVIN }); return cached; } let result = await this.repository.decodeVIN(pool, normalizedVIN); if (result) { const response: VINDecodeResponse = { vin: normalizedVIN, result, success: true }; await this.cache.setVINDecode(normalizedVIN, response, true); logger.info('VIN decoded successfully via PostgreSQL', { vin: normalizedVIN, make: result.make, model: result.model, year: result.year }); return response; } logger.info('VIN not found in PostgreSQL, attempting vPIC fallback', { vin: normalizedVIN }); try { result = await this.circuitBreaker.fire(normalizedVIN) as VINDecodeResult | null; if (result) { const response: VINDecodeResponse = { vin: normalizedVIN, result, success: true }; await this.cache.setVINDecode(normalizedVIN, response, true); logger.info('VIN decoded successfully via vPIC fallback', { vin: normalizedVIN, make: result.make, model: result.model, year: result.year }); return response; } } catch (circuitError) { logger.warn('vPIC API unavailable or circuit breaker open', { vin: normalizedVIN, error: circuitError }); } const failureResponse: VINDecodeResponse = { vin: normalizedVIN, result: null, success: false, error: 'VIN not found in database and external API unavailable' }; await this.cache.setVINDecode(normalizedVIN, failureResponse, false); return failureResponse; } catch (error) { logger.error('VIN decode error', { vin: normalizedVIN, error }); return { vin: normalizedVIN, result: null, success: false, error: 'Internal server error during VIN decoding' }; } } /** * Get circuit breaker status */ getCircuitBreakerStatus(): { state: string; stats: any } { return { state: this.circuitBreaker.opened ? 'open' : this.circuitBreaker.halfOpen ? 'half-open' : 'closed', stats: this.circuitBreaker.stats }; } }