diff --git a/backend/src/core/config/feature-tiers.ts b/backend/src/core/config/feature-tiers.ts index fb5dfc0..7fd4e6a 100644 --- a/backend/src/core/config/feature-tiers.ts +++ b/backend/src/core/config/feature-tiers.ts @@ -26,6 +26,11 @@ export const FEATURE_TIERS: Record = { name: 'Scan for Maintenance Schedule', 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; /** diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts index b422421..6fbc6d6 100644 --- a/backend/src/features/vehicles/api/vehicles.controller.ts +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -10,16 +10,19 @@ 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 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); + this.nhtsaClient = new NHTSAClient(pool); } 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) { const userId = (request as any).user.sub; const vehicleId = request.params.id; diff --git a/backend/src/features/vehicles/api/vehicles.routes.ts b/backend/src/features/vehicles/api/vehicles.routes.ts index 1582146..c874441 100644 --- a/backend/src/features/vehicles/api/vehicles.routes.ts +++ b/backend/src/features/vehicles/api/vehicles.routes.ts @@ -75,6 +75,12 @@ export const vehiclesRoutes: FastifyPluginAsync = async ( 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 // POST /api/vehicles/:id/image - Upload vehicle image fastify.post<{ Params: VehicleParams }>('/vehicles/:id/image', { diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index d8de86b..a710480 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -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 { + 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 = { + value: nhtsaYear, + nhtsaValue: nhtsaYear?.toString() || null, + confidence: nhtsaYear ? '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); + } + + // 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); + } + + // 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); + } + + // 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); + } + + // 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); + } + + // Body type, drive type, and fuel type are display-only (no dropdown matching) + const bodyType: MatchedField = { + value: null, + nhtsaValue: nhtsaBodyType, + confidence: 'none' + }; + + const driveType: MatchedField = { + value: null, + nhtsaValue: nhtsaDriveType, + confidence: 'none' + }; + + const fuelType: MatchedField = { + 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 { + 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, diff --git a/backend/src/features/vehicles/external/CLAUDE.md b/backend/src/features/vehicles/external/CLAUDE.md new file mode 100644 index 0000000..92b34ca --- /dev/null +++ b/backend/src/features/vehicles/external/CLAUDE.md @@ -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 diff --git a/backend/src/features/vehicles/external/nhtsa/index.ts b/backend/src/features/vehicles/external/nhtsa/index.ts new file mode 100644 index 0000000..9b2a5d4 --- /dev/null +++ b/backend/src/features/vehicles/external/nhtsa/index.ts @@ -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'; diff --git a/backend/src/features/vehicles/external/nhtsa/nhtsa.client.ts b/backend/src/features/vehicles/external/nhtsa/nhtsa.client.ts new file mode 100644 index 0000000..1a3a8a2 --- /dev/null +++ b/backend/src/features/vehicles/external/nhtsa/nhtsa.client.ts @@ -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 { + 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 { + 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 { + // 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( + `${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; + } +} diff --git a/backend/src/features/vehicles/external/nhtsa/nhtsa.types.ts b/backend/src/features/vehicles/external/nhtsa/nhtsa.types.ts new file mode 100644 index 0000000..23a18fb --- /dev/null +++ b/backend/src/features/vehicles/external/nhtsa/nhtsa.types.ts @@ -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 { + 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; + 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: 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; +} diff --git a/frontend/src/features/vehicles/api/vehicles.api.ts b/frontend/src/features/vehicles/api/vehicles.api.ts index d6b3f64..ed409c7 100644 --- a/frontend/src/features/vehicles/api/vehicles.api.ts +++ b/frontend/src/features/vehicles/api/vehicles.api.ts @@ -3,7 +3,7 @@ */ 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 @@ -79,5 +79,14 @@ export const vehiclesApi = { getImageUrl: (vehicleId: string): string => { return `/api/vehicles/${vehicleId}/image`; + }, + + /** + * Decode VIN using NHTSA vPIC API + * Requires Pro or Enterprise tier + */ + decodeVin: async (vin: string): Promise => { + const response = await apiClient.post('/vehicles/decode-vin', { vin }); + return response.data; } }; diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index 88ec36d..03dfbbf 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -10,6 +10,8 @@ import { Button } from '../../../shared-minimal/components/Button'; import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types'; import { vehiclesApi } from '../api/vehicles.api'; import { VehicleImageUpload } from './VehicleImageUpload'; +import { useTierAccess } from '../../../core/hooks/useTierAccess'; +import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog'; const vehicleSchema = z .object({ @@ -100,6 +102,13 @@ export const VehicleForm: React.FC = ({ const prevTrim = useRef(''); const [currentImageUrl, setCurrentImageUrl] = useState(initialData?.imageUrl); const [previewUrl, setPreviewUrl] = useState(null); + const [isDecoding, setIsDecoding] = useState(false); + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + const [decodeError, setDecodeError] = useState(null); + + // Tier access check for VIN decode feature + const { hasAccess: canDecodeVin } = useTierAccess(); + const hasVinDecodeAccess = canDecodeVin('vehicle.vinDecode'); const isEditMode = !!initialData?.id; const vehicleId = initialData?.id; @@ -408,6 +417,76 @@ export const VehicleForm: React.FC = ({ 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 (
@@ -427,18 +506,47 @@ export const VehicleForm: React.FC = ({ VIN Number *

- Enter vehicle VIN (optional) + Enter vehicle VIN (optional if License Plate provided)

- +
+ + +
{errors.vin && (

{errors.vin.message}

)} -
+ {decodeError && ( +

{decodeError}

+ )} + {!hasVinDecodeAccess && ( +

+ VIN decode requires Pro or Enterprise subscription +

+ )} + {/* Vehicle Specification Dropdowns */}
@@ -689,6 +797,13 @@ export const VehicleForm: React.FC = ({ {initialData ? 'Update Vehicle' : 'Add Vehicle'}
+ + {/* Upgrade Required Dialog for VIN Decode */} + setShowUpgradeDialog(false)} + /> ); }; diff --git a/frontend/src/features/vehicles/types/vehicles.types.ts b/frontend/src/features/vehicles/types/vehicles.types.ts index 7cf3d27..13bab8e 100644 --- a/frontend/src/features/vehicles/types/vehicles.types.ts +++ b/frontend/src/features/vehicles/types/vehicles.types.ts @@ -55,3 +55,33 @@ export interface UpdateVehicleRequest { licensePlate?: string; 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 { + 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; + make: MatchedField; + model: MatchedField; + trimLevel: MatchedField; + bodyType: MatchedField; + driveType: MatchedField; + fuelType: MatchedField; + engine: MatchedField; + transmission: MatchedField; +}