- 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>
236 lines
7.3 KiB
TypeScript
236 lines
7.3 KiB
TypeScript
/**
|
|
* @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<VinCacheEntry | null> {
|
|
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<void> {
|
|
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<NHTSADecodeResponse> {
|
|
// 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<NHTSADecodeResponse>(
|
|
`${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;
|
|
}
|
|
}
|