diff --git a/backend/src/core/config/config-loader.ts b/backend/src/core/config/config-loader.ts index 30f7ca4..1de41e3 100644 --- a/backend/src/core/config/config-loader.ts +++ b/backend/src/core/config/config-loader.ts @@ -41,14 +41,6 @@ const configSchema = z.object({ audience: z.string(), }), - // External APIs configuration (optional) - external: z.object({ - vpic: z.object({ - url: z.string(), - timeout: z.string(), - }).optional(), - }).optional(), - // Service configuration service: z.object({ name: z.string(), diff --git a/backend/src/core/config/feature-tiers.ts b/backend/src/core/config/feature-tiers.ts index ca803df..1260c8c 100644 --- a/backend/src/core/config/feature-tiers.ts +++ b/backend/src/core/config/feature-tiers.ts @@ -29,7 +29,7 @@ export const FEATURE_TIERS: Record = { 'vehicle.vinDecode': { minTier: 'pro', name: 'VIN Decode', - upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the NHTSA database.', + upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the vehicle database.', }, 'fuelLog.receiptScan': { minTier: 'pro', diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts index 475abc6..79db275 100644 --- a/backend/src/features/vehicles/api/vehicles.controller.ts +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -10,19 +10,18 @@ import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types'; import { getStorageService } from '../../../core/storage/storage.service'; -import { NHTSAClient, DecodeVinRequest } from '../external/nhtsa'; +import { ocrClient } from '../../ocr/external/ocr-client'; +import type { DecodeVinRequest } from '../domain/vehicles.types'; import crypto from 'crypto'; import FileType from 'file-type'; import path from 'path'; export class VehiclesController { private vehiclesService: VehiclesService; - private nhtsaClient: NHTSAClient; constructor() { const repository = new VehiclesRepository(pool); this.vehiclesService = new VehiclesService(repository, pool); - this.nhtsaClient = new NHTSAClient(pool); } async getUserVehicles(request: FastifyRequest, reply: FastifyReply) { @@ -378,7 +377,7 @@ export class VehiclesController { } /** - * Decode VIN using NHTSA vPIC API + * Decode VIN using OCR service (Gemini) * POST /api/vehicles/decode-vin * Requires Pro or Enterprise tier */ @@ -395,13 +394,34 @@ export class VehiclesController { }); } - logger.info('VIN decode requested', { userId, vin: vin.substring(0, 6) + '...' }); + // Validate VIN format + const sanitizedVin = vin.trim().toUpperCase(); + const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/; + if (!VIN_REGEX.test(sanitizedVin)) { + return reply.code(400).send({ + error: 'INVALID_VIN', + message: 'Invalid VIN format. VIN must be exactly 17 characters and contain only letters (except I, O, Q) and numbers.' + }); + } - // Validate and decode VIN - const response = await this.nhtsaClient.decodeVin(vin); + logger.info('VIN decode requested', { userId, vin: sanitizedVin.substring(0, 6) + '...' }); - // Extract and map fields from NHTSA response - const decodedData = await this.vehiclesService.mapNHTSAResponse(response); + // Check cache first + const cached = await this.vehiclesService.getVinCached(sanitizedVin); + if (cached) { + logger.info('VIN decode cache hit', { userId }); + const decodedData = await this.vehiclesService.mapVinDecodeResponse(cached); + return reply.code(200).send(decodedData); + } + + // Call OCR service for VIN decode + const response = await ocrClient.decodeVin(sanitizedVin); + + // Cache the response + await this.vehiclesService.saveVinCache(sanitizedVin, response); + + // Map response to decoded vehicle data with dropdown matching + const decodedData = await this.vehiclesService.mapVinDecodeResponse(response); logger.info('VIN decode successful', { userId, @@ -414,7 +434,7 @@ export class VehiclesController { } catch (error: any) { logger.error('VIN decode failed', { error, userId }); - // Handle validation errors + // Handle VIN validation errors if (error.message?.includes('Invalid VIN')) { return reply.code(400).send({ error: 'INVALID_VIN', @@ -422,16 +442,25 @@ export class VehiclesController { }); } - // Handle timeout - if (error.message?.includes('timed out')) { - return reply.code(504).send({ - error: 'VIN_DECODE_TIMEOUT', - message: 'NHTSA API request timed out. Please try again.' + // Handle OCR service errors by status code + if (error.statusCode === 503 || error.statusCode === 422) { + return reply.code(502).send({ + error: 'VIN_DECODE_FAILED', + message: 'VIN decode service unavailable', + details: error.message }); } - // Handle NHTSA API errors - if (error.message?.includes('NHTSA')) { + // Handle timeout + if (error.message?.includes('timed out') || error.message?.includes('aborted')) { + return reply.code(504).send({ + error: 'VIN_DECODE_TIMEOUT', + message: 'VIN decode service timed out. Please try again.' + }); + } + + // Handle OCR service errors + if (error.message?.includes('OCR service error')) { return reply.code(502).send({ error: 'VIN_DECODE_FAILED', message: 'Unable to decode VIN from external service', diff --git a/backend/src/features/vehicles/api/vehicles.routes.ts b/backend/src/features/vehicles/api/vehicles.routes.ts index e82cb6c..9d404a3 100644 --- a/backend/src/features/vehicles/api/vehicles.routes.ts +++ b/backend/src/features/vehicles/api/vehicles.routes.ts @@ -75,7 +75,7 @@ export const vehiclesRoutes: FastifyPluginAsync = async ( handler: vehiclesController.getDropdownOptions.bind(vehiclesController) }); - // POST /api/vehicles/decode-vin - Decode VIN using NHTSA vPIC API (Pro/Enterprise only) + // POST /api/vehicles/decode-vin - Decode VIN via OCR service (Pro/Enterprise only) fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', { preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })], handler: vehiclesController.decodeVin.bind(vehiclesController) diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index ca7864d..bb5b668 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -24,7 +24,8 @@ import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/v import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { getVehicleDataService, getPool } from '../../platform'; import { auditLogService } from '../../audit-log'; -import { NHTSAClient, NHTSADecodeResponse, DecodedVehicleData, MatchedField } from '../external/nhtsa'; +import type { VinDecodeResponse } from '../../ocr/domain/ocr.types'; +import type { DecodedVehicleData, MatchedField } from './vehicles.types'; import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; @@ -592,6 +593,71 @@ export class VehiclesService { const cacheKey = `${this.cachePrefix}:user:${userId}`; await cacheService.del(cacheKey); } + + /** + * Check vin_cache for existing VIN data. + * Format-aware: validates raw_data has `success` field (Gemini format). + * Old NHTSA-format entries are treated as cache misses and expire via TTL. + */ + async getVinCached(vin: string): Promise { + try { + const result = await this.pool.query<{ + raw_data: any; + cached_at: Date; + }>( + `SELECT raw_data, cached_at + FROM vin_cache + WHERE vin = $1 + AND cached_at > NOW() - INTERVAL '365 days'`, + [vin] + ); + + if (result.rows.length === 0) { + return null; + } + + const rawData = result.rows[0].raw_data; + + // Format-aware check: Gemini responses have `success` field, + // old NHTSA responses do not. Treat old format as cache miss. + if (!rawData || typeof rawData !== 'object' || !('success' in rawData)) { + logger.debug('VIN cache format mismatch (legacy NHTSA entry), treating as miss', { vin }); + return null; + } + + logger.debug('VIN cache hit', { vin }); + return rawData as VinDecodeResponse; + } catch (error) { + logger.error('Failed to check VIN cache', { vin, error }); + return null; + } + } + + /** + * Save VIN decode response to cache with ON CONFLICT upsert. + */ + async saveVinCache(vin: string, response: VinDecodeResponse): Promise { + try { + 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, response.make, response.model, response.year, response.engine, response.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 + } + } async getDropdownMakes(year: number): Promise { const vehicleDataService = getVehicleDataService(); @@ -657,82 +723,82 @@ export class VehiclesService { } /** - * Map NHTSA decode response to internal decoded vehicle data format + * Map VIN decode response to internal decoded vehicle data format * with dropdown matching and confidence levels */ - async mapNHTSAResponse(response: NHTSADecodeResponse): Promise { + async mapVinDecodeResponse(response: VinDecodeResponse): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); - // Extract raw values from NHTSA response - const nhtsaYear = NHTSAClient.extractYear(response); - const nhtsaMake = NHTSAClient.extractValue(response, 'Make'); - const nhtsaModel = NHTSAClient.extractValue(response, 'Model'); - const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim'); - const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class'); - const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type'); - const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary'); - const nhtsaEngine = NHTSAClient.extractEngine(response); - const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style'); + // Read flat fields directly from Gemini response + const sourceYear = response.year; + const sourceMake = response.make; + const sourceModel = response.model; + const sourceTrim = response.trimLevel; + const sourceBodyType = response.bodyType; + const sourceDriveType = response.driveType; + const sourceFuelType = response.fuelType; + const sourceEngine = response.engine; + const sourceTransmission = response.transmission; // Year is always high confidence if present (exact numeric match) const year: MatchedField = { - value: nhtsaYear, - nhtsaValue: nhtsaYear?.toString() || null, - confidence: nhtsaYear ? 'high' : 'none' + value: sourceYear, + sourceValue: sourceYear?.toString() || null, + confidence: sourceYear ? 'high' : 'none' }; // Match make against dropdown options - let make: MatchedField = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' }; - if (nhtsaYear && nhtsaMake) { - const makes = await vehicleDataService.getMakes(pool, nhtsaYear); - make = this.matchField(nhtsaMake, makes); + let make: MatchedField = { value: null, sourceValue: sourceMake, confidence: 'none' }; + if (sourceYear && sourceMake) { + const makes = await vehicleDataService.getMakes(pool, sourceYear); + make = this.matchField(sourceMake, makes); } // Match model against dropdown options - let model: MatchedField = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' }; - if (nhtsaYear && make.value && nhtsaModel) { - const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value); - model = this.matchField(nhtsaModel, models); + let model: MatchedField = { value: null, sourceValue: sourceModel, confidence: 'none' }; + if (sourceYear && make.value && sourceModel) { + const models = await vehicleDataService.getModels(pool, sourceYear, make.value); + model = this.matchField(sourceModel, models); } // Match trim against dropdown options - let trimLevel: MatchedField = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' }; - if (nhtsaYear && make.value && model.value && nhtsaTrim) { - const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value); - trimLevel = this.matchField(nhtsaTrim, trims); + let trimLevel: MatchedField = { value: null, sourceValue: sourceTrim, confidence: 'none' }; + if (sourceYear && make.value && model.value && sourceTrim) { + const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value); + trimLevel = this.matchField(sourceTrim, trims); } // Match engine against dropdown options - let engine: MatchedField = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' }; - if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) { - const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value); - engine = this.matchField(nhtsaEngine, engines); + let engine: MatchedField = { value: null, sourceValue: sourceEngine, confidence: 'none' }; + if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) { + const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value); + engine = this.matchField(sourceEngine, engines); } // Match transmission against dropdown options - let transmission: MatchedField = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' }; - if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) { - const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value); - transmission = this.matchField(nhtsaTransmission, transmissions); + let transmission: MatchedField = { value: null, sourceValue: sourceTransmission, confidence: 'none' }; + if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) { + const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value); + transmission = this.matchField(sourceTransmission, transmissions); } // Body type, drive type, and fuel type are display-only (no dropdown matching) const bodyType: MatchedField = { value: null, - nhtsaValue: nhtsaBodyType, + sourceValue: sourceBodyType, confidence: 'none' }; const driveType: MatchedField = { value: null, - nhtsaValue: nhtsaDriveType, + sourceValue: sourceDriveType, confidence: 'none' }; const fuelType: MatchedField = { value: null, - nhtsaValue: nhtsaFuelType, + sourceValue: sourceFuelType, confidence: 'none' }; @@ -754,42 +820,42 @@ export class VehiclesService { * Returns the matched dropdown value with confidence level * Matching order: exact -> normalized -> prefix -> contains */ - private matchField(nhtsaValue: string, options: string[]): MatchedField { - if (!nhtsaValue || options.length === 0) { - return { value: null, nhtsaValue, confidence: 'none' }; + private matchField(sourceValue: string, options: string[]): MatchedField { + if (!sourceValue || options.length === 0) { + return { value: null, sourceValue, confidence: 'none' }; } - const normalizedNhtsa = nhtsaValue.toLowerCase().trim(); + const normalizedSource = sourceValue.toLowerCase().trim(); // Try exact case-insensitive match - const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedNhtsa); + const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedSource); if (exactMatch) { - return { value: exactMatch, nhtsaValue, confidence: 'high' }; + return { value: exactMatch, sourceValue, confidence: 'high' }; } // Try normalized comparison (remove special chars) const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); - const normalizedNhtsaClean = normalizeForCompare(nhtsaValue); + const normalizedSourceClean = normalizeForCompare(sourceValue); - const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedNhtsaClean); + const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedSourceClean); if (normalizedMatch) { - return { value: normalizedMatch, nhtsaValue, confidence: 'medium' }; + return { value: normalizedMatch, sourceValue, confidence: 'medium' }; } - // Try prefix match - option starts with NHTSA value - const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa)); + // Try prefix match - option starts with source value + const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource)); if (prefixMatch) { - return { value: prefixMatch, nhtsaValue, confidence: 'medium' }; + return { value: prefixMatch, sourceValue, confidence: 'medium' }; } - // Try contains match - option contains NHTSA value - const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa)); + // Try contains match - option contains source value + const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource)); if (containsMatch) { - return { value: containsMatch, nhtsaValue, confidence: 'medium' }; + return { value: containsMatch, sourceValue, confidence: 'medium' }; } - // No match found - return NHTSA value as hint with no match - return { value: null, nhtsaValue, confidence: 'none' }; + // No match found - return source value as hint with no match + return { value: null, sourceValue, confidence: 'none' }; } private toResponse(vehicle: Vehicle): VehicleResponse { diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index e1380de..94a69a3 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -215,3 +215,53 @@ export interface TCOResponse { distanceUnit: string; currencyCode: string; } + +/** Confidence level for matched dropdown values */ +export type MatchConfidence = 'high' | 'medium' | 'none'; + +/** Matched field with confidence indicator */ +export interface MatchedField { + value: T | null; + sourceValue: string | null; + confidence: MatchConfidence; +} + +/** + * Decoded vehicle data with match confidence per field. + * Maps VIN decode response fields to internal field names. + */ +export interface DecodedVehicleData { + year: MatchedField; + make: MatchedField; + model: MatchedField; + trimLevel: MatchedField; + bodyType: MatchedField; + driveType: MatchedField; + fuelType: MatchedField; + engine: MatchedField; + transmission: MatchedField; +} + +/** Cached VIN data from vin_cache table */ +export interface VinCacheEntry { + vin: string; + make: string | null; + model: string | null; + year: number | null; + engineType: string | null; + bodyType: string | null; + rawData: import('../../ocr/domain/ocr.types').VinDecodeResponse; + cachedAt: Date; +} + +/** VIN decode request body */ +export interface DecodeVinRequest { + vin: string; +} + +/** VIN decode error response */ +export interface VinDecodeError { + error: 'INVALID_VIN' | 'VIN_DECODE_FAILED' | 'TIER_REQUIRED'; + message: string; + details?: string; +} diff --git a/config/app/ci.yml b/config/app/ci.yml index b84efff..8bbc4c3 100755 --- a/config/app/ci.yml +++ b/config/app/ci.yml @@ -22,11 +22,6 @@ platform: url: http://mvp-platform-vehicles-api:8000 timeout: 5s -external: - vpic: - url: https://vpic.nhtsa.dot.gov/api/vehicles - timeout: 10s - service: name: mvp-backend diff --git a/config/app/production.yml.example b/config/app/production.yml.example index 7d6226b..a956cec 100755 --- a/config/app/production.yml.example +++ b/config/app/production.yml.example @@ -21,5 +21,3 @@ auth0: domain: motovaultpro.us.auth0.com audience: https://api.motovaultpro.com -external: - vpic_api_url: https://vpic.nhtsa.dot.gov/api/vehicles diff --git a/config/shared/production.yml b/config/shared/production.yml index 0748010..b121295 100755 --- a/config/shared/production.yml +++ b/config/shared/production.yml @@ -107,9 +107,6 @@ external_services: google_maps: base_url: https://maps.googleapis.com/maps/api - vpic: - base_url: https://vpic.nhtsa.dot.gov/api/vehicles - # Development Configuration development: debug_enabled: false