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:
@@ -26,6 +26,11 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
|||||||
name: 'Scan for Maintenance Schedule',
|
name: 'Scan for Maintenance Schedule',
|
||||||
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
|
upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your vehicle manuals.',
|
||||||
},
|
},
|
||||||
|
'vehicle.vinDecode': {
|
||||||
|
minTier: 'pro',
|
||||||
|
name: 'VIN Decode',
|
||||||
|
upgradePrompt: 'Upgrade to Pro to automatically decode VIN and populate vehicle details from the NHTSA database.',
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,16 +10,19 @@ 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 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);
|
this.vehiclesService = new VehiclesService(repository);
|
||||||
|
this.nhtsaClient = new NHTSAClient(pool);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
async getUserVehicles(request: FastifyRequest, reply: FastifyReply) {
|
||||||
@@ -309,6 +312,75 @@ export class VehiclesController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode VIN using NHTSA vPIC API
|
||||||
|
* POST /api/vehicles/decode-vin
|
||||||
|
* Requires Pro or Enterprise tier
|
||||||
|
*/
|
||||||
|
async decodeVin(request: FastifyRequest<{ Body: DecodeVinRequest }>, reply: FastifyReply) {
|
||||||
|
const userId = (request as any).user?.sub;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { vin } = request.body;
|
||||||
|
|
||||||
|
if (!vin || typeof vin !== 'string') {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'INVALID_VIN',
|
||||||
|
message: 'VIN is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('VIN decode requested', { userId, vin: vin.substring(0, 6) + '...' });
|
||||||
|
|
||||||
|
// Validate and decode VIN
|
||||||
|
const response = await this.nhtsaClient.decodeVin(vin);
|
||||||
|
|
||||||
|
// Extract and map fields from NHTSA response
|
||||||
|
const decodedData = await this.vehiclesService.mapNHTSAResponse(response);
|
||||||
|
|
||||||
|
logger.info('VIN decode successful', {
|
||||||
|
userId,
|
||||||
|
hasYear: !!decodedData.year.value,
|
||||||
|
hasMake: !!decodedData.make.value,
|
||||||
|
hasModel: !!decodedData.model.value
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(decodedData);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('VIN decode failed', { error, userId });
|
||||||
|
|
||||||
|
// Handle validation errors
|
||||||
|
if (error.message?.includes('Invalid VIN')) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'INVALID_VIN',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 NHTSA API errors
|
||||||
|
if (error.message?.includes('NHTSA')) {
|
||||||
|
return reply.code(502).send({
|
||||||
|
error: 'VIN_DECODE_FAILED',
|
||||||
|
message: 'Unable to decode VIN from external service',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: 'Failed to decode VIN'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async uploadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
||||||
const userId = (request as any).user.sub;
|
const userId = (request as any).user.sub;
|
||||||
const vehicleId = request.params.id;
|
const vehicleId = request.params.id;
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ 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)
|
||||||
|
fastify.post<{ Body: { vin: string } }>('/vehicles/decode-vin', {
|
||||||
|
preHandler: [fastify.authenticate, fastify.requireTier({ featureKey: 'vehicle.vinDecode' })],
|
||||||
|
handler: vehiclesController.decodeVin.bind(vehiclesController)
|
||||||
|
});
|
||||||
|
|
||||||
// Vehicle image routes - must be before :id to avoid conflicts
|
// Vehicle image routes - must be before :id to avoid conflicts
|
||||||
// POST /api/vehicles/:id/image - Upload vehicle image
|
// POST /api/vehicles/:id/image - Upload vehicle image
|
||||||
fastify.post<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
fastify.post<{ Params: VehicleParams }>('/vehicles/:id/image', {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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';
|
||||||
|
|
||||||
export class VehiclesService {
|
export class VehiclesService {
|
||||||
private readonly cachePrefix = 'vehicles';
|
private readonly cachePrefix = 'vehicles';
|
||||||
@@ -346,6 +347,129 @@ export class VehiclesService {
|
|||||||
return vehicleDataService.getYears(pool);
|
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 {
|
private toResponse(vehicle: Vehicle): VehicleResponse {
|
||||||
return {
|
return {
|
||||||
id: vehicle.id,
|
id: vehicle.id,
|
||||||
|
|||||||
43
backend/src/features/vehicles/external/CLAUDE.md
vendored
Normal file
43
backend/src/features/vehicles/external/CLAUDE.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# vehicles/external/
|
||||||
|
|
||||||
|
External service integrations for the vehicles feature.
|
||||||
|
|
||||||
|
## Subdirectories
|
||||||
|
|
||||||
|
| Directory | What | When to read |
|
||||||
|
| --------- | ---- | ------------ |
|
||||||
|
| `nhtsa/` | NHTSA vPIC API client for VIN decoding | VIN decode feature work |
|
||||||
|
|
||||||
|
## Integration Pattern
|
||||||
|
|
||||||
|
External integrations follow a consistent pattern:
|
||||||
|
|
||||||
|
1. **Client class** (`{service}.client.ts`) - axios-based HTTP client with:
|
||||||
|
- Timeout configuration (prevent hanging requests)
|
||||||
|
- Error handling with specific error types
|
||||||
|
- Caching strategy (database or Redis)
|
||||||
|
- Input validation before API calls
|
||||||
|
|
||||||
|
2. **Types file** (`{service}.types.ts`) - TypeScript interfaces for:
|
||||||
|
- Raw API response types
|
||||||
|
- Mapped internal types (camelCase)
|
||||||
|
- Error types
|
||||||
|
|
||||||
|
3. **Cache strategy** - Each integration defines:
|
||||||
|
- Cache location (vin_cache table for NHTSA)
|
||||||
|
- TTL (1 year for static vehicle data)
|
||||||
|
- Cache invalidation rules (if applicable)
|
||||||
|
|
||||||
|
## Adding New Integrations
|
||||||
|
|
||||||
|
To add a new external service (e.g., Carfax, KBB):
|
||||||
|
|
||||||
|
1. Create subdirectory: `external/{service}/`
|
||||||
|
2. Add client: `{service}.client.ts` following NHTSAClient pattern
|
||||||
|
3. Add types: `{service}.types.ts`
|
||||||
|
4. Update this CLAUDE.md with new directory
|
||||||
|
5. Add tests in `tests/unit/{service}.client.test.ts`
|
||||||
|
|
||||||
|
## See Also
|
||||||
|
|
||||||
|
- `../../../stations/external/google-maps/` - Sister pattern for Google Maps integration
|
||||||
16
backend/src/features/vehicles/external/nhtsa/index.ts
vendored
Normal file
16
backend/src/features/vehicles/external/nhtsa/index.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary NHTSA vPIC integration exports
|
||||||
|
* @ai-context Public API for VIN decoding functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { NHTSAClient } from './nhtsa.client';
|
||||||
|
export type {
|
||||||
|
NHTSADecodeResponse,
|
||||||
|
NHTSAResult,
|
||||||
|
DecodedVehicleData,
|
||||||
|
MatchedField,
|
||||||
|
MatchConfidence,
|
||||||
|
VinCacheEntry,
|
||||||
|
DecodeVinRequest,
|
||||||
|
VinDecodeError,
|
||||||
|
} from './nhtsa.types';
|
||||||
235
backend/src/features/vehicles/external/nhtsa/nhtsa.client.ts
vendored
Normal file
235
backend/src/features/vehicles/external/nhtsa/nhtsa.client.ts
vendored
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/src/features/vehicles/external/nhtsa/nhtsa.types.ts
vendored
Normal file
96
backend/src/features/vehicles/external/nhtsa/nhtsa.types.ts
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Type definitions for NHTSA vPIC API
|
||||||
|
* @ai-context Defines request/response types for VIN decoding
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual result from NHTSA DecodeVin API
|
||||||
|
*/
|
||||||
|
export interface NHTSAResult {
|
||||||
|
Value: string | null;
|
||||||
|
ValueId: string | null;
|
||||||
|
Variable: string;
|
||||||
|
VariableId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw response from NHTSA DecodeVin API
|
||||||
|
* GET https://vpic.nhtsa.dot.gov/api/vehicles/decodevin/{VIN}?format=json
|
||||||
|
*/
|
||||||
|
export interface NHTSADecodeResponse {
|
||||||
|
Count: number;
|
||||||
|
Message: string;
|
||||||
|
SearchCriteria: string;
|
||||||
|
Results: NHTSAResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confidence level for matched dropdown values
|
||||||
|
*/
|
||||||
|
export type MatchConfidence = 'high' | 'medium' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matched field with confidence indicator
|
||||||
|
*/
|
||||||
|
export interface MatchedField<T> {
|
||||||
|
value: T | null;
|
||||||
|
nhtsaValue: string | null;
|
||||||
|
confidence: MatchConfidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded vehicle data with match confidence per field
|
||||||
|
* Maps NHTSA response fields to internal field names (camelCase)
|
||||||
|
*
|
||||||
|
* NHTSA Field Mappings:
|
||||||
|
* - ModelYear -> year
|
||||||
|
* - Make -> make
|
||||||
|
* - Model -> model
|
||||||
|
* - Trim -> trimLevel
|
||||||
|
* - BodyClass -> bodyType
|
||||||
|
* - DriveType -> driveType
|
||||||
|
* - FuelTypePrimary -> fuelType
|
||||||
|
* - EngineModel / EngineCylinders + EngineDisplacementL -> engine
|
||||||
|
* - TransmissionStyle -> transmission
|
||||||
|
*/
|
||||||
|
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: NHTSADecodeResponse;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../../../core/api/client';
|
import { apiClient } from '../../../core/api/client';
|
||||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest } from '../types/vehicles.types';
|
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DecodedVehicleData } from '../types/vehicles.types';
|
||||||
|
|
||||||
// All requests (including dropdowns) use authenticated apiClient
|
// All requests (including dropdowns) use authenticated apiClient
|
||||||
|
|
||||||
@@ -79,5 +79,14 @@ export const vehiclesApi = {
|
|||||||
|
|
||||||
getImageUrl: (vehicleId: string): string => {
|
getImageUrl: (vehicleId: string): string => {
|
||||||
return `/api/vehicles/${vehicleId}/image`;
|
return `/api/vehicles/${vehicleId}/image`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode VIN using NHTSA vPIC API
|
||||||
|
* Requires Pro or Enterprise tier
|
||||||
|
*/
|
||||||
|
decodeVin: async (vin: string): Promise<DecodedVehicleData> => {
|
||||||
|
const response = await apiClient.post('/vehicles/decode-vin', { vin });
|
||||||
|
return response.data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { Button } from '../../../shared-minimal/components/Button';
|
|||||||
import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types';
|
import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types';
|
||||||
import { vehiclesApi } from '../api/vehicles.api';
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
import { VehicleImageUpload } from './VehicleImageUpload';
|
import { VehicleImageUpload } from './VehicleImageUpload';
|
||||||
|
import { useTierAccess } from '../../../core/hooks/useTierAccess';
|
||||||
|
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
|
||||||
|
|
||||||
const vehicleSchema = z
|
const vehicleSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -100,6 +102,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
const prevTrim = useRef<string>('');
|
const prevTrim = useRef<string>('');
|
||||||
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [isDecoding, setIsDecoding] = useState(false);
|
||||||
|
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
||||||
|
const [decodeError, setDecodeError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Tier access check for VIN decode feature
|
||||||
|
const { hasAccess: canDecodeVin } = useTierAccess();
|
||||||
|
const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode');
|
||||||
|
|
||||||
const isEditMode = !!initialData?.id;
|
const isEditMode = !!initialData?.id;
|
||||||
const vehicleId = initialData?.id;
|
const vehicleId = initialData?.id;
|
||||||
@@ -408,6 +417,76 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
imageUrl: previewUrl || currentImageUrl,
|
imageUrl: previewUrl || currentImageUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Watch VIN for decode button
|
||||||
|
const watchedVin = watch('vin');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle VIN decode button click
|
||||||
|
* Calls NHTSA API and populates empty form fields
|
||||||
|
*/
|
||||||
|
const handleDecodeVin = async () => {
|
||||||
|
// Check tier access first
|
||||||
|
if (!hasVinDecodeAccess) {
|
||||||
|
setShowUpgradeDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vin = watchedVin?.trim();
|
||||||
|
if (!vin || vin.length !== 17) {
|
||||||
|
setDecodeError('Please enter a valid 17-character VIN');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDecoding(true);
|
||||||
|
setDecodeError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = await vehiclesApi.decodeVin(vin);
|
||||||
|
|
||||||
|
// Only populate empty fields (preserve existing user input)
|
||||||
|
if (!watchedYear && decoded.year.value) {
|
||||||
|
setValue('year', decoded.year.value);
|
||||||
|
}
|
||||||
|
if (!watchedMake && decoded.make.value) {
|
||||||
|
setValue('make', decoded.make.value);
|
||||||
|
}
|
||||||
|
if (!watchedModel && decoded.model.value) {
|
||||||
|
setValue('model', decoded.model.value);
|
||||||
|
}
|
||||||
|
if (!watchedTrim && decoded.trimLevel.value) {
|
||||||
|
setValue('trimLevel', decoded.trimLevel.value);
|
||||||
|
}
|
||||||
|
if (!watchedEngine && decoded.engine.value) {
|
||||||
|
setValue('engine', decoded.engine.value);
|
||||||
|
}
|
||||||
|
if (!watchedTransmission && decoded.transmission.value) {
|
||||||
|
setValue('transmission', decoded.transmission.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body type, drive type, fuel type - check if fields are empty and we have values
|
||||||
|
const currentDriveType = watch('driveType');
|
||||||
|
const currentFuelType = watch('fuelType');
|
||||||
|
|
||||||
|
if (!currentDriveType && decoded.driveType.nhtsaValue) {
|
||||||
|
// For now just show hint - user can select from dropdown
|
||||||
|
}
|
||||||
|
if (!currentFuelType && decoded.fuelType.nhtsaValue) {
|
||||||
|
// For now just show hint - user can select from dropdown
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('VIN decode failed:', error);
|
||||||
|
if (error.response?.data?.error === 'TIER_REQUIRED') {
|
||||||
|
setShowUpgradeDialog(true);
|
||||||
|
} else if (error.response?.data?.error === 'INVALID_VIN') {
|
||||||
|
setDecodeError(error.response.data.message || 'Invalid VIN format');
|
||||||
|
} else {
|
||||||
|
setDecodeError('Failed to decode VIN. Please try again.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsDecoding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -427,17 +506,46 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
VIN Number <span className="text-red-500">*</span>
|
VIN Number <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
|
<p className="text-xs text-gray-600 dark:text-titanio mb-2">
|
||||||
Enter vehicle VIN (optional)
|
Enter vehicle VIN (optional if License Plate provided)
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
{...register('vin')}
|
{...register('vin')}
|
||||||
className="w-full px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
className="flex-1 px-3 py-2 border rounded-md text-base bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
placeholder="Enter 17-character VIN (optional if License Plate provided)"
|
placeholder="Enter 17-character VIN"
|
||||||
style={{ fontSize: '16px' }}
|
style={{ fontSize: '16px' }}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDecodeVin}
|
||||||
|
disabled={isDecoding || loading || loadingDropdowns || !watchedVin?.trim()}
|
||||||
|
className="px-4 py-2 min-h-[44px] min-w-full sm:min-w-[120px] bg-primary-600 text-white rounded-md hover:bg-primary-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2 dark:bg-abudhabi dark:hover:bg-primary-600"
|
||||||
|
style={{ fontSize: '16px' }}
|
||||||
|
>
|
||||||
|
{isDecoding ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Decoding...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Decode VIN'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{errors.vin && (
|
{errors.vin && (
|
||||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.vin.message}</p>
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.vin.message}</p>
|
||||||
)}
|
)}
|
||||||
|
{decodeError && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{decodeError}</p>
|
||||||
|
)}
|
||||||
|
{!hasVinDecodeAccess && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-titanio">
|
||||||
|
VIN decode requires Pro or Enterprise subscription
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vehicle Specification Dropdowns */}
|
{/* Vehicle Specification Dropdowns */}
|
||||||
@@ -689,6 +797,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
|||||||
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upgrade Required Dialog for VIN Decode */}
|
||||||
|
<UpgradeRequiredDialog
|
||||||
|
featureKey="vehicle.vinDecode"
|
||||||
|
open={showUpgradeDialog}
|
||||||
|
onClose={() => setShowUpgradeDialog(false)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,3 +55,33 @@ export interface UpdateVehicleRequest {
|
|||||||
licensePlate?: string;
|
licensePlate?: string;
|
||||||
odometerReading?: number;
|
odometerReading?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confidence level for matched dropdown values from VIN decode
|
||||||
|
*/
|
||||||
|
export type MatchConfidence = 'high' | 'medium' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matched field with confidence indicator
|
||||||
|
*/
|
||||||
|
export interface MatchedField<T> {
|
||||||
|
value: T | null;
|
||||||
|
nhtsaValue: string | null;
|
||||||
|
confidence: MatchConfidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded vehicle data from NHTSA vPIC API
|
||||||
|
* with match confidence per field
|
||||||
|
*/
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user