/** * @ai-summary NHTSA vPIC API client for VIN decoding * @ai-context Fetches vehicle data from NHTSA and caches results */ import axios, { AxiosError } from 'axios'; import { logger } from '../../../../core/logging/logger'; import { NHTSADecodeResponse, VinCacheEntry } from './nhtsa.types'; import { Pool } from 'pg'; /** * VIN validation regex * - 17 characters * - Excludes I, O, Q (not used in VINs) * - Alphanumeric only */ const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/; /** * Cache TTL: 1 year (VIN data is static - vehicle specs don't change) */ const CACHE_TTL_SECONDS = 365 * 24 * 60 * 60; export class NHTSAClient { private readonly baseURL = 'https://vpic.nhtsa.dot.gov/api'; private readonly timeout = 5000; // 5 seconds constructor(private readonly pool: Pool) {} /** * Validate VIN format * @throws Error if VIN format is invalid */ validateVin(vin: string): string { const sanitized = vin.trim().toUpperCase(); if (!sanitized) { throw new Error('VIN is required'); } if (!VIN_REGEX.test(sanitized)) { throw new Error('Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.'); } return sanitized; } /** * Check cache for existing VIN data */ async getCached(vin: string): Promise { try { const result = await this.pool.query<{ vin: string; make: string | null; model: string | null; year: number | null; engine_type: string | null; body_type: string | null; raw_data: NHTSADecodeResponse; cached_at: Date; }>( `SELECT vin, make, model, year, engine_type, body_type, raw_data, cached_at FROM vin_cache WHERE vin = $1 AND cached_at > NOW() - INTERVAL '${CACHE_TTL_SECONDS} seconds'`, [vin] ); if (result.rows.length === 0) { return null; } const row = result.rows[0]; return { vin: row.vin, make: row.make, model: row.model, year: row.year, engineType: row.engine_type, bodyType: row.body_type, rawData: row.raw_data, cachedAt: row.cached_at, }; } catch (error) { logger.error('Failed to check VIN cache', { vin, error }); return null; } } /** * Save VIN data to cache */ async saveToCache(vin: string, response: NHTSADecodeResponse): Promise { try { const findValue = (variable: string): string | null => { const result = response.Results.find(r => r.Variable === variable); return result?.Value || null; }; const year = findValue('Model Year'); const make = findValue('Make'); const model = findValue('Model'); const engineType = findValue('Engine Model'); const bodyType = findValue('Body Class'); await this.pool.query( `INSERT INTO vin_cache (vin, make, model, year, engine_type, body_type, raw_data, cached_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (vin) DO UPDATE SET make = EXCLUDED.make, model = EXCLUDED.model, year = EXCLUDED.year, engine_type = EXCLUDED.engine_type, body_type = EXCLUDED.body_type, raw_data = EXCLUDED.raw_data, cached_at = NOW()`, [vin, make, model, year ? parseInt(year) : null, engineType, bodyType, JSON.stringify(response)] ); logger.debug('VIN cached', { vin }); } catch (error) { logger.error('Failed to cache VIN data', { vin, error }); // Don't throw - caching failure shouldn't break the decode flow } } /** * Decode VIN using NHTSA vPIC API * @param vin - 17-character VIN * @returns Raw NHTSA decode response * @throws Error if VIN is invalid or API call fails */ async decodeVin(vin: string): Promise { // Validate and sanitize VIN const sanitizedVin = this.validateVin(vin); // Check cache first const cached = await this.getCached(sanitizedVin); if (cached) { logger.debug('VIN cache hit', { vin: sanitizedVin }); return cached.rawData; } // Call NHTSA API logger.info('Calling NHTSA vPIC API', { vin: sanitizedVin }); try { const response = await axios.get( `${this.baseURL}/vehicles/decodevin/${sanitizedVin}`, { params: { format: 'json' }, timeout: this.timeout, } ); // Check for NHTSA-level errors if (response.data.Count === 0) { throw new Error('NHTSA returned no results for this VIN'); } // Check for error messages in results const errorResult = response.data.Results.find( r => r.Variable === 'Error Code' && r.Value && r.Value !== '0' ); if (errorResult) { const errorText = response.data.Results.find(r => r.Variable === 'Error Text'); throw new Error(`NHTSA error: ${errorText?.Value || 'Unknown error'}`); } // Cache the successful response await this.saveToCache(sanitizedVin, response.data); return response.data; } catch (error) { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; if (axiosError.code === 'ECONNABORTED') { logger.error('NHTSA API timeout', { vin: sanitizedVin }); throw new Error('NHTSA API request timed out. Please try again.'); } if (axiosError.response) { logger.error('NHTSA API error response', { vin: sanitizedVin, status: axiosError.response.status, data: axiosError.response.data, }); throw new Error(`NHTSA API error: ${axiosError.response.status}`); } logger.error('NHTSA API network error', { vin: sanitizedVin, message: axiosError.message }); throw new Error('Unable to connect to NHTSA API. Please try again later.'); } throw error; } } /** * Extract a specific value from NHTSA response */ static extractValue(response: NHTSADecodeResponse, variable: string): string | null { const result = response.Results.find(r => r.Variable === variable); return result?.Value?.trim() || null; } /** * Extract year from NHTSA response */ static extractYear(response: NHTSADecodeResponse): number | null { const value = NHTSAClient.extractValue(response, 'Model Year'); if (!value) return null; const parsed = parseInt(value, 10); return isNaN(parsed) ? null : parsed; } /** * Extract engine description from NHTSA response * Combines multiple engine-related fields */ static extractEngine(response: NHTSADecodeResponse): string | null { const engineModel = NHTSAClient.extractValue(response, 'Engine Model'); if (engineModel) return engineModel; // Build engine description from components const cylinders = NHTSAClient.extractValue(response, 'Engine Number of Cylinders'); const displacement = NHTSAClient.extractValue(response, 'Displacement (L)'); const fuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary'); const parts: string[] = []; if (cylinders) parts.push(`${cylinders}-Cylinder`); if (displacement) parts.push(`${displacement}L`); if (fuelType && fuelType !== 'Gasoline') parts.push(fuelType); return parts.length > 0 ? parts.join(' ') : null; } }