feat: Add VIN decoding with NHTSA vPIC API (refs #9)
- Add NHTSA client for VIN decoding with caching and validation - Add POST /api/vehicles/decode-vin endpoint with tier gating - Add dropdown matching service with confidence levels - Add decode button to VehicleForm with tier check - Responsive layout: stacks on mobile, inline on desktop - Only populate empty fields (preserve user input) Backend: - NHTSAClient with 5s timeout, VIN validation, vin_cache table - Tier gating with 'vehicle.vinDecode' feature key (Pro+) - Tiered matching: high (exact), medium (normalized), none Frontend: - Decode button with loading state and error handling - UpgradeRequiredDialog for free tier users - Mobile-first responsive layout 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ 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';
|
||||
|
||||
export class VehiclesService {
|
||||
private readonly cachePrefix = 'vehicles';
|
||||
@@ -346,6 +347,129 @@ export class VehiclesService {
|
||||
return vehicleDataService.getYears(pool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map NHTSA decode response to internal decoded vehicle data format
|
||||
* with dropdown matching and confidence levels
|
||||
*/
|
||||
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> {
|
||||
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');
|
||||
|
||||
// Year is always high confidence if present (exact numeric match)
|
||||
const year: MatchedField<number> = {
|
||||
value: nhtsaYear,
|
||||
nhtsaValue: nhtsaYear?.toString() || null,
|
||||
confidence: nhtsaYear ? 'high' : 'none'
|
||||
};
|
||||
|
||||
// Match make against dropdown options
|
||||
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' };
|
||||
if (nhtsaYear && nhtsaMake) {
|
||||
const makes = await vehicleDataService.getMakes(pool, nhtsaYear);
|
||||
make = this.matchField(nhtsaMake, makes);
|
||||
}
|
||||
|
||||
// Match model against dropdown options
|
||||
let model: MatchedField<string> = { 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);
|
||||
}
|
||||
|
||||
// Match trim against dropdown options
|
||||
let trimLevel: MatchedField<string> = { 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);
|
||||
}
|
||||
|
||||
// Match engine against dropdown options
|
||||
let engine: MatchedField<string> = { 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);
|
||||
}
|
||||
|
||||
// Match transmission against dropdown options
|
||||
let transmission: MatchedField<string> = { 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);
|
||||
}
|
||||
|
||||
// Body type, drive type, and fuel type are display-only (no dropdown matching)
|
||||
const bodyType: MatchedField<string> = {
|
||||
value: null,
|
||||
nhtsaValue: nhtsaBodyType,
|
||||
confidence: 'none'
|
||||
};
|
||||
|
||||
const driveType: MatchedField<string> = {
|
||||
value: null,
|
||||
nhtsaValue: nhtsaDriveType,
|
||||
confidence: 'none'
|
||||
};
|
||||
|
||||
const fuelType: MatchedField<string> = {
|
||||
value: null,
|
||||
nhtsaValue: nhtsaFuelType,
|
||||
confidence: 'none'
|
||||
};
|
||||
|
||||
return {
|
||||
year,
|
||||
make,
|
||||
model,
|
||||
trimLevel,
|
||||
bodyType,
|
||||
driveType,
|
||||
fuelType,
|
||||
engine,
|
||||
transmission
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a value against dropdown options using case-insensitive exact matching
|
||||
* Returns the matched dropdown value with confidence level
|
||||
*/
|
||||
private matchField(nhtsaValue: string, options: string[]): MatchedField<string> {
|
||||
if (!nhtsaValue || options.length === 0) {
|
||||
return { value: null, nhtsaValue, confidence: 'none' };
|
||||
}
|
||||
|
||||
const normalizedNhtsa = nhtsaValue.toLowerCase().trim();
|
||||
|
||||
// Try exact case-insensitive match
|
||||
const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedNhtsa);
|
||||
if (exactMatch) {
|
||||
return { value: exactMatch, nhtsaValue, confidence: 'high' };
|
||||
}
|
||||
|
||||
// Try normalized comparison (remove special chars)
|
||||
const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const normalizedNhtsaClean = normalizeForCompare(nhtsaValue);
|
||||
|
||||
const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedNhtsaClean);
|
||||
if (normalizedMatch) {
|
||||
return { value: normalizedMatch, nhtsaValue, confidence: 'medium' };
|
||||
}
|
||||
|
||||
// No match found - return NHTSA value as hint with no match
|
||||
return { value: null, nhtsaValue, confidence: 'none' };
|
||||
}
|
||||
|
||||
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||
return {
|
||||
id: vehicle.id,
|
||||
|
||||
Reference in New Issue
Block a user