feat: rewire vehicles controller to OCR VIN decode (refs #226)
Replace NHTSAClient with OcrClient in vehicles controller. Move cache logic into VehiclesService with format-aware reads (Gemini vs legacy NHTSA entries). Rename nhtsaValue to sourceValue in MatchedField. Remove vpic config from Zod schema and YAML config files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,14 +41,6 @@ const configSchema = z.object({
|
|||||||
audience: z.string(),
|
audience: z.string(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// External APIs configuration (optional)
|
|
||||||
external: z.object({
|
|
||||||
vpic: z.object({
|
|
||||||
url: z.string(),
|
|
||||||
timeout: z.string(),
|
|
||||||
}).optional(),
|
|
||||||
}).optional(),
|
|
||||||
|
|
||||||
// Service configuration
|
// Service configuration
|
||||||
service: z.object({
|
service: z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
|||||||
'vehicle.vinDecode': {
|
'vehicle.vinDecode': {
|
||||||
minTier: 'pro',
|
minTier: 'pro',
|
||||||
name: 'VIN Decode',
|
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': {
|
'fuelLog.receiptScan': {
|
||||||
minTier: 'pro',
|
minTier: 'pro',
|
||||||
|
|||||||
@@ -10,19 +10,18 @@ import { pool } from '../../../core/config/database';
|
|||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
|
import { CreateVehicleBody, UpdateVehicleBody, VehicleParams } from '../domain/vehicles.types';
|
||||||
import { getStorageService } from '../../../core/storage/storage.service';
|
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 crypto from 'crypto';
|
||||||
import FileType from 'file-type';
|
import FileType from 'file-type';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export class VehiclesController {
|
export class VehiclesController {
|
||||||
private vehiclesService: VehiclesService;
|
private vehiclesService: VehiclesService;
|
||||||
private nhtsaClient: NHTSAClient;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const repository = new VehiclesRepository(pool);
|
const repository = new VehiclesRepository(pool);
|
||||||
this.vehiclesService = new VehiclesService(repository, pool);
|
this.vehiclesService = new VehiclesService(repository, pool);
|
||||||
this.nhtsaClient = new NHTSAClient(pool);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
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
|
* POST /api/vehicles/decode-vin
|
||||||
* Requires Pro or Enterprise tier
|
* 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
|
logger.info('VIN decode requested', { userId, vin: sanitizedVin.substring(0, 6) + '...' });
|
||||||
const response = await this.nhtsaClient.decodeVin(vin);
|
|
||||||
|
|
||||||
// Extract and map fields from NHTSA response
|
// Check cache first
|
||||||
const decodedData = await this.vehiclesService.mapNHTSAResponse(response);
|
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', {
|
logger.info('VIN decode successful', {
|
||||||
userId,
|
userId,
|
||||||
@@ -414,7 +434,7 @@ export class VehiclesController {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('VIN decode failed', { error, userId });
|
logger.error('VIN decode failed', { error, userId });
|
||||||
|
|
||||||
// Handle validation errors
|
// Handle VIN validation errors
|
||||||
if (error.message?.includes('Invalid VIN')) {
|
if (error.message?.includes('Invalid VIN')) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
error: 'INVALID_VIN',
|
error: 'INVALID_VIN',
|
||||||
@@ -422,16 +442,25 @@ export class VehiclesController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle timeout
|
// Handle OCR service errors by status code
|
||||||
if (error.message?.includes('timed out')) {
|
if (error.statusCode === 503 || error.statusCode === 422) {
|
||||||
return reply.code(504).send({
|
return reply.code(502).send({
|
||||||
error: 'VIN_DECODE_TIMEOUT',
|
error: 'VIN_DECODE_FAILED',
|
||||||
message: 'NHTSA API request timed out. Please try again.'
|
message: 'VIN decode service unavailable',
|
||||||
|
details: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle NHTSA API errors
|
// Handle timeout
|
||||||
if (error.message?.includes('NHTSA')) {
|
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({
|
return reply.code(502).send({
|
||||||
error: 'VIN_DECODE_FAILED',
|
error: 'VIN_DECODE_FAILED',
|
||||||
message: 'Unable to decode VIN from external service',
|
message: 'Unable to decode VIN from external service',
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const vehiclesRoutes: FastifyPluginAsync = async (
|
|||||||
handler: vehiclesController.getDropdownOptions.bind(vehiclesController)
|
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', {
|
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
|
||||||
preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })],
|
preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })],
|
||||||
handler: vehiclesController.decodeVin.bind(vehiclesController)
|
handler: vehiclesController.decodeVin.bind(vehiclesController)
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/v
|
|||||||
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
import { normalizeMakeName, normalizeModelName } from './name-normalizer';
|
||||||
import { getVehicleDataService, getPool } from '../../platform';
|
import { getVehicleDataService, getPool } from '../../platform';
|
||||||
import { auditLogService } from '../../audit-log';
|
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 { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers';
|
||||||
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
|
||||||
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
import { SubscriptionTier } from '../../user-profile/domain/user-profile.types';
|
||||||
@@ -593,6 +594,71 @@ export class VehiclesService {
|
|||||||
await cacheService.del(cacheKey);
|
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<VinDecodeResponse | null> {
|
||||||
|
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<void> {
|
||||||
|
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<string[]> {
|
async getDropdownMakes(year: number): Promise<string[]> {
|
||||||
const vehicleDataService = getVehicleDataService();
|
const vehicleDataService = getVehicleDataService();
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
@@ -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
|
* with dropdown matching and confidence levels
|
||||||
*/
|
*/
|
||||||
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> {
|
async mapVinDecodeResponse(response: VinDecodeResponse): Promise<DecodedVehicleData> {
|
||||||
const vehicleDataService = getVehicleDataService();
|
const vehicleDataService = getVehicleDataService();
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
// Extract raw values from NHTSA response
|
// Read flat fields directly from Gemini response
|
||||||
const nhtsaYear = NHTSAClient.extractYear(response);
|
const sourceYear = response.year;
|
||||||
const nhtsaMake = NHTSAClient.extractValue(response, 'Make');
|
const sourceMake = response.make;
|
||||||
const nhtsaModel = NHTSAClient.extractValue(response, 'Model');
|
const sourceModel = response.model;
|
||||||
const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim');
|
const sourceTrim = response.trimLevel;
|
||||||
const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class');
|
const sourceBodyType = response.bodyType;
|
||||||
const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type');
|
const sourceDriveType = response.driveType;
|
||||||
const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary');
|
const sourceFuelType = response.fuelType;
|
||||||
const nhtsaEngine = NHTSAClient.extractEngine(response);
|
const sourceEngine = response.engine;
|
||||||
const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style');
|
const sourceTransmission = response.transmission;
|
||||||
|
|
||||||
// Year is always high confidence if present (exact numeric match)
|
// Year is always high confidence if present (exact numeric match)
|
||||||
const year: MatchedField<number> = {
|
const year: MatchedField<number> = {
|
||||||
value: nhtsaYear,
|
value: sourceYear,
|
||||||
nhtsaValue: nhtsaYear?.toString() || null,
|
sourceValue: sourceYear?.toString() || null,
|
||||||
confidence: nhtsaYear ? 'high' : 'none'
|
confidence: sourceYear ? 'high' : 'none'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Match make against dropdown options
|
// Match make against dropdown options
|
||||||
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' };
|
let make: MatchedField<string> = { value: null, sourceValue: sourceMake, confidence: 'none' };
|
||||||
if (nhtsaYear && nhtsaMake) {
|
if (sourceYear && sourceMake) {
|
||||||
const makes = await vehicleDataService.getMakes(pool, nhtsaYear);
|
const makes = await vehicleDataService.getMakes(pool, sourceYear);
|
||||||
make = this.matchField(nhtsaMake, makes);
|
make = this.matchField(sourceMake, makes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match model against dropdown options
|
// Match model against dropdown options
|
||||||
let model: MatchedField<string> = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' };
|
let model: MatchedField<string> = { value: null, sourceValue: sourceModel, confidence: 'none' };
|
||||||
if (nhtsaYear && make.value && nhtsaModel) {
|
if (sourceYear && make.value && sourceModel) {
|
||||||
const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value);
|
const models = await vehicleDataService.getModels(pool, sourceYear, make.value);
|
||||||
model = this.matchField(nhtsaModel, models);
|
model = this.matchField(sourceModel, models);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match trim against dropdown options
|
// Match trim against dropdown options
|
||||||
let trimLevel: MatchedField<string> = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' };
|
let trimLevel: MatchedField<string> = { value: null, sourceValue: sourceTrim, confidence: 'none' };
|
||||||
if (nhtsaYear && make.value && model.value && nhtsaTrim) {
|
if (sourceYear && make.value && model.value && sourceTrim) {
|
||||||
const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value);
|
const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value);
|
||||||
trimLevel = this.matchField(nhtsaTrim, trims);
|
trimLevel = this.matchField(sourceTrim, trims);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match engine against dropdown options
|
// Match engine against dropdown options
|
||||||
let engine: MatchedField<string> = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' };
|
let engine: MatchedField<string> = { value: null, sourceValue: sourceEngine, confidence: 'none' };
|
||||||
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) {
|
if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) {
|
||||||
const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value);
|
const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value);
|
||||||
engine = this.matchField(nhtsaEngine, engines);
|
engine = this.matchField(sourceEngine, engines);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match transmission against dropdown options
|
// Match transmission against dropdown options
|
||||||
let transmission: MatchedField<string> = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' };
|
let transmission: MatchedField<string> = { value: null, sourceValue: sourceTransmission, confidence: 'none' };
|
||||||
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) {
|
if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) {
|
||||||
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value);
|
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value);
|
||||||
transmission = this.matchField(nhtsaTransmission, transmissions);
|
transmission = this.matchField(sourceTransmission, transmissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Body type, drive type, and fuel type are display-only (no dropdown matching)
|
// Body type, drive type, and fuel type are display-only (no dropdown matching)
|
||||||
const bodyType: MatchedField<string> = {
|
const bodyType: MatchedField<string> = {
|
||||||
value: null,
|
value: null,
|
||||||
nhtsaValue: nhtsaBodyType,
|
sourceValue: sourceBodyType,
|
||||||
confidence: 'none'
|
confidence: 'none'
|
||||||
};
|
};
|
||||||
|
|
||||||
const driveType: MatchedField<string> = {
|
const driveType: MatchedField<string> = {
|
||||||
value: null,
|
value: null,
|
||||||
nhtsaValue: nhtsaDriveType,
|
sourceValue: sourceDriveType,
|
||||||
confidence: 'none'
|
confidence: 'none'
|
||||||
};
|
};
|
||||||
|
|
||||||
const fuelType: MatchedField<string> = {
|
const fuelType: MatchedField<string> = {
|
||||||
value: null,
|
value: null,
|
||||||
nhtsaValue: nhtsaFuelType,
|
sourceValue: sourceFuelType,
|
||||||
confidence: 'none'
|
confidence: 'none'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -754,42 +820,42 @@ export class VehiclesService {
|
|||||||
* Returns the matched dropdown value with confidence level
|
* Returns the matched dropdown value with confidence level
|
||||||
* Matching order: exact -> normalized -> prefix -> contains
|
* Matching order: exact -> normalized -> prefix -> contains
|
||||||
*/
|
*/
|
||||||
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> {
|
private matchField(sourceValue: string, options: string[]): MatchedField<string> {
|
||||||
if (!nhtsaValue || options.length === 0) {
|
if (!sourceValue || options.length === 0) {
|
||||||
return { value: null, nhtsaValue, confidence: 'none' };
|
return { value: null, sourceValue, confidence: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedNhtsa = nhtsaValue.toLowerCase().trim();
|
const normalizedSource = sourceValue.toLowerCase().trim();
|
||||||
|
|
||||||
// Try exact case-insensitive match
|
// 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) {
|
if (exactMatch) {
|
||||||
return { value: exactMatch, nhtsaValue, confidence: 'high' };
|
return { value: exactMatch, sourceValue, confidence: 'high' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try normalized comparison (remove special chars)
|
// Try normalized comparison (remove special chars)
|
||||||
const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
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) {
|
if (normalizedMatch) {
|
||||||
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' };
|
return { value: normalizedMatch, sourceValue, confidence: 'medium' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try prefix match - option starts with NHTSA value
|
// Try prefix match - option starts with source value
|
||||||
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa));
|
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource));
|
||||||
if (prefixMatch) {
|
if (prefixMatch) {
|
||||||
return { value: prefixMatch, nhtsaValue, confidence: 'medium' };
|
return { value: prefixMatch, sourceValue, confidence: 'medium' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try contains match - option contains NHTSA value
|
// Try contains match - option contains source value
|
||||||
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa));
|
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource));
|
||||||
if (containsMatch) {
|
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
|
// No match found - return source value as hint with no match
|
||||||
return { value: null, nhtsaValue, confidence: 'none' };
|
return { value: null, sourceValue, confidence: 'none' };
|
||||||
}
|
}
|
||||||
|
|
||||||
private toResponse(vehicle: Vehicle): VehicleResponse {
|
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||||
|
|||||||
@@ -215,3 +215,53 @@ export interface TCOResponse {
|
|||||||
distanceUnit: string;
|
distanceUnit: string;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Confidence level for matched dropdown values */
|
||||||
|
export type MatchConfidence = 'high' | 'medium' | 'none';
|
||||||
|
|
||||||
|
/** Matched field with confidence indicator */
|
||||||
|
export interface MatchedField<T> {
|
||||||
|
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<number>;
|
||||||
|
make: MatchedField<string>;
|
||||||
|
model: MatchedField<string>;
|
||||||
|
trimLevel: MatchedField<string>;
|
||||||
|
bodyType: MatchedField<string>;
|
||||||
|
driveType: MatchedField<string>;
|
||||||
|
fuelType: MatchedField<string>;
|
||||||
|
engine: MatchedField<string>;
|
||||||
|
transmission: MatchedField<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ platform:
|
|||||||
url: http://mvp-platform-vehicles-api:8000
|
url: http://mvp-platform-vehicles-api:8000
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|
||||||
external:
|
|
||||||
vpic:
|
|
||||||
url: https://vpic.nhtsa.dot.gov/api/vehicles
|
|
||||||
timeout: 10s
|
|
||||||
|
|
||||||
service:
|
service:
|
||||||
name: mvp-backend
|
name: mvp-backend
|
||||||
|
|
||||||
|
|||||||
@@ -21,5 +21,3 @@ auth0:
|
|||||||
domain: motovaultpro.us.auth0.com
|
domain: motovaultpro.us.auth0.com
|
||||||
audience: https://api.motovaultpro.com
|
audience: https://api.motovaultpro.com
|
||||||
|
|
||||||
external:
|
|
||||||
vpic_api_url: https://vpic.nhtsa.dot.gov/api/vehicles
|
|
||||||
|
|||||||
@@ -107,9 +107,6 @@ external_services:
|
|||||||
google_maps:
|
google_maps:
|
||||||
base_url: https://maps.googleapis.com/maps/api
|
base_url: https://maps.googleapis.com/maps/api
|
||||||
|
|
||||||
vpic:
|
|
||||||
base_url: https://vpic.nhtsa.dot.gov/api/vehicles
|
|
||||||
|
|
||||||
# Development Configuration
|
# Development Configuration
|
||||||
development:
|
development:
|
||||||
debug_enabled: false
|
debug_enabled: false
|
||||||
|
|||||||
Reference in New Issue
Block a user