Compare commits

..

2 Commits

Author SHA1 Message Date
Eric Gullickson
9b4f94e1ee docs: Update vehicles README with VIN decode endpoint (refs #9)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m36s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 28s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 6s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Add VIN decode endpoint to API section
- Document request/response format with confidence levels
- Add error response examples (400, 403, 502)
- Update architecture diagram with external/ directory

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:56:32 -06:00
Eric Gullickson
2aae89acbe 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>
2026-01-11 13:55:26 -06:00
12 changed files with 808 additions and 14 deletions

View File

@@ -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;
/** /**

View File

@@ -12,6 +12,9 @@ Primary entity for vehicle management consuming MVP Platform Vehicles Service. H
- `PUT /api/vehicles/:id` - Update vehicle details - `PUT /api/vehicles/:id` - Update vehicle details
- `DELETE /api/vehicles/:id` - Soft delete vehicle - `DELETE /api/vehicles/:id` - Soft delete vehicle
### VIN Decoding (Pro/Enterprise Only)
- `POST /api/vehicles/decode-vin` - Decode VIN using NHTSA vPIC API
### Hierarchical Vehicle Dropdowns ### Hierarchical Vehicle Dropdowns
**Status**: Vehicles service now proxies the platform vehicle catalog to provide fully dynamic dropdowns. Each selection step filters the next list, ensuring only valid combinations are shown. **Status**: Vehicles service now proxies the platform vehicle catalog to provide fully dynamic dropdowns. Each selection step filters the next list, ensuring only valid combinations are shown.
@@ -100,6 +103,12 @@ vehicles/
│ └── name-normalizer.ts │ └── name-normalizer.ts
├── data/ # Database layer ├── data/ # Database layer
│ └── vehicles.repository.ts │ └── vehicles.repository.ts
├── external/ # External service integrations
│ ├── CLAUDE.md # Integration pattern docs
│ └── nhtsa/ # NHTSA vPIC API client
│ ├── nhtsa.client.ts
│ ├── nhtsa.types.ts
│ └── index.ts
├── migrations/ # Feature schema ├── migrations/ # Feature schema
│ └── 001_create_vehicles_tables.sql │ └── 001_create_vehicles_tables.sql
├── tests/ # All tests ├── tests/ # All tests
@@ -112,11 +121,45 @@ vehicles/
## Key Features ## Key Features
### 🔍 Automatic VIN Decoding ### 🔍 VIN Decoding (NHTSA vPIC API)
- **Platform Service**: MVP Platform Vehicles Service VIN decode endpoint - **Tier Gating**: Pro and Enterprise users only (`vehicle.vinDecode` feature key)
- **Caching**: Platform service handles caching strategy - **NHTSA API**: Calls official NHTSA vPIC API for authoritative vehicle data
- **Fallback**: Circuit breaker pattern with graceful degradation - **Caching**: Results cached in `vin_cache` table (1-year TTL, VIN data is static)
- **Validation**: 17-character VIN format validation - **Validation**: 17-character VIN format, excludes I/O/Q characters
- **Matching**: Case-insensitive exact match against dropdown options
- **Confidence Levels**: High (exact match), Medium (normalized match), None (hint only)
- **Timeout**: 5-second timeout for NHTSA API calls
#### Decode VIN Request
```json
POST /api/vehicles/decode-vin
Authorization: Bearer <jwt>
{
"vin": "1HGBH41JXMN109186"
}
Response (200):
{
"year": { "value": 2021, "nhtsaValue": "2021", "confidence": "high" },
"make": { "value": "Honda", "nhtsaValue": "HONDA", "confidence": "high" },
"model": { "value": "Civic", "nhtsaValue": "Civic", "confidence": "high" },
"trimLevel": { "value": "EX", "nhtsaValue": "EX", "confidence": "high" },
"engine": { "value": null, "nhtsaValue": "2.0L L4 DOHC 16V", "confidence": "none" },
"transmission": { "value": null, "nhtsaValue": "CVT", "confidence": "none" },
"bodyType": { "value": null, "nhtsaValue": "Sedan", "confidence": "none" },
"driveType": { "value": null, "nhtsaValue": "FWD", "confidence": "none" },
"fuelType": { "value": null, "nhtsaValue": "Gasoline", "confidence": "none" }
}
Error (400 - Invalid VIN):
{ "error": "INVALID_VIN", "message": "Invalid VIN format. VIN must be..." }
Error (403 - Tier Required):
{ "error": "TIER_REQUIRED", "requiredTier": "pro", "currentTier": "free", ... }
Error (502 - NHTSA Failure):
{ "error": "VIN_DECODE_FAILED", "message": "Unable to decode VIN from external service" }
```
### 📋 Hierarchical Vehicle Dropdowns ### 📋 Hierarchical Vehicle Dropdowns
- **Platform Service**: Consumes year-based hierarchical vehicle API - **Platform Service**: Consumes year-based hierarchical vehicle API

View File

@@ -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;

View File

@@ -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', {

View File

@@ -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,

View 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

View 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';

View 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;
}
}

View 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;
}

View File

@@ -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;
} }
}; };

View File

@@ -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,18 +506,47 @@ 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>
<input <div className="flex flex-col sm:flex-row gap-2">
{...register('vin')} <input
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" {...register('vin')}
placeholder="Enter 17-character VIN (optional if License Plate provided)" 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"
style={{ fontSize: '16px' }} placeholder="Enter 17-character VIN"
/> 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>
)} )}
</div> {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>
{/* Vehicle Specification Dropdowns */} {/* Vehicle Specification Dropdowns */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
@@ -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>
); );
}; };

View File

@@ -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>;
}