Merge pull request 'feat: Add VIN decoding with NHTSA vPIC API (#9)' (#24) from issue-9-vin-decoding into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 23s
Deploy to Staging / Deploy to Staging (push) Successful in 37s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

Reviewed-on: #24
This commit was merged in pull request #24.
This commit is contained in:
2026-01-11 22:22:35 +00:00
15 changed files with 962 additions and 22 deletions

View File

@@ -7,6 +7,7 @@
| `role-agents/` | Developer, TW, QR, Debugger agents | Delegating execution |
| `agents/` | Domain agents (Feature, Frontend, Platform, Quality) | Domain-specific work |
| `skills/` | Reusable skills | Complex multi-step workflows |
| `hooks/` | PreToolUse hooks (model enforcement) | Debugging hook behavior |
| `output-styles/` | Output formatting templates | Customizing agent output |
| `tdd-guard/` | TDD enforcement utilities | Test-driven development |
@@ -24,4 +25,5 @@
| `skills/incoherence/` | Detect doc/code drift | Periodic audits |
| `skills/prompt-engineer/` | Prompt optimization | Improving AI prompts |
| `agents/` | Domain agents (Feature, Frontend, Platform, Quality) | Domain-specific work |
| `hooks/` | PreToolUse hooks (model enforcement) | Debugging hook behavior |
| `.ai/workflow-contract.json` | Sprint process, skill integration | Issue workflow |

38
.claude/hooks/CLAUDE.md Normal file
View File

@@ -0,0 +1,38 @@
# hooks/
## Files
| File | What | When to read |
| ---- | ---- | ------------ |
| `enforce-agent-model.sh` | Enforces correct model for Task tool calls | Debugging agent model issues |
## enforce-agent-model.sh
PreToolUse hook that ensures Task tool calls use the correct model based on `subagent_type`.
### Agent Model Mapping
| Agent | Required Model |
|-------|----------------|
| feature-agent | sonnet |
| first-frontend-agent | sonnet |
| platform-agent | sonnet |
| quality-agent | sonnet |
| developer | sonnet |
| technical-writer | sonnet |
| debugger | sonnet |
| quality-reviewer | opus |
| Explore | sonnet |
| Plan | sonnet |
| Bash | sonnet |
| general-purpose | sonnet |
### Behavior
- Blocks Task calls where `model` parameter doesn't match expected value
- Returns error message instructing Claude to retry with correct model
- Unknown agent types are allowed through (no enforcement)
### Adding New Agents
Edit the `get_expected_model()` function in `enforce-agent-model.sh` to add new agent mappings.

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Enforces correct model usage for Task tool based on agent definitions
# Blocks Task calls that don't specify the correct model for the subagent_type
# Read tool input from stdin
INPUT=$(cat)
# Extract subagent_type and model from the input
SUBAGENT_TYPE=$(echo "$INPUT" | jq -r '.subagent_type // empty')
MODEL=$(echo "$INPUT" | jq -r '.model // empty')
# If no subagent_type, allow (not an agent call)
if [[ -z "$SUBAGENT_TYPE" ]]; then
exit 0
fi
# Get expected model for agent type
# Most agents use sonnet, quality-reviewer uses opus
get_expected_model() {
case "$1" in
# Custom project agents
feature-agent|first-frontend-agent|platform-agent|quality-agent)
echo "sonnet"
;;
# Role agents
developer|technical-writer|debugger)
echo "sonnet"
;;
quality-reviewer)
echo "opus"
;;
# Built-in agents - default to sonnet for cost efficiency
Explore|Plan|Bash|general-purpose)
echo "sonnet"
;;
*)
# Unknown agent, no enforcement
echo ""
;;
esac
}
EXPECTED_MODEL=$(get_expected_model "$SUBAGENT_TYPE")
# If agent not in mapping, allow (unknown agent type)
if [[ -z "$EXPECTED_MODEL" ]]; then
exit 0
fi
# Check if model matches expected
if [[ "$MODEL" != "$EXPECTED_MODEL" ]]; then
echo "BLOCKED: Agent '$SUBAGENT_TYPE' requires model: '$EXPECTED_MODEL' but got '${MODEL:-<not specified>}'."
echo "Retry with: model: \"$EXPECTED_MODEL\""
exit 1
fi
# Model matches, allow the call
exit 0

View File

@@ -26,6 +26,11 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
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;
/**

View File

@@ -12,6 +12,9 @@ Primary entity for vehicle management consuming MVP Platform Vehicles Service. H
- `PUT /api/vehicles/:id` - Update vehicle details
- `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
**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
├── data/ # Database layer
│ └── 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
│ └── 001_create_vehicles_tables.sql
├── tests/ # All tests
@@ -112,11 +121,45 @@ vehicles/
## Key Features
### 🔍 Automatic VIN Decoding
- **Platform Service**: MVP Platform Vehicles Service VIN decode endpoint
- **Caching**: Platform service handles caching strategy
- **Fallback**: Circuit breaker pattern with graceful degradation
- **Validation**: 17-character VIN format validation
### 🔍 VIN Decoding (NHTSA vPIC API)
- **Tier Gating**: Pro and Enterprise users only (`vehicle.vinDecode` feature key)
- **NHTSA API**: Calls official NHTSA vPIC API for authoritative vehicle data
- **Caching**: Results cached in `vin_cache` table (1-year TTL, VIN data is static)
- **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
- **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 { 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;

View File

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

View File

@@ -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,142 @@ export class VehiclesService {
return vehicleDataService.getYears(pool);
}
/**
* Map NHTSA decode response to internal decoded vehicle data format
* with dropdown matching and confidence levels
*/
async mapNHTSAResponse(response: NHTSADecodeResponse): Promise<DecodedVehicleData> {
const vehicleDataService = getVehicleDataService();
const pool = getPool();
// Extract raw values from NHTSA response
const nhtsaYear = NHTSAClient.extractYear(response);
const nhtsaMake = NHTSAClient.extractValue(response, 'Make');
const nhtsaModel = NHTSAClient.extractValue(response, 'Model');
const nhtsaTrim = NHTSAClient.extractValue(response, 'Trim');
const nhtsaBodyType = NHTSAClient.extractValue(response, 'Body Class');
const nhtsaDriveType = NHTSAClient.extractValue(response, 'Drive Type');
const nhtsaFuelType = NHTSAClient.extractValue(response, 'Fuel Type - Primary');
const nhtsaEngine = NHTSAClient.extractEngine(response);
const nhtsaTransmission = NHTSAClient.extractValue(response, 'Transmission Style');
// Year is always high confidence if present (exact numeric match)
const year: MatchedField<number> = {
value: nhtsaYear,
nhtsaValue: nhtsaYear?.toString() || null,
confidence: nhtsaYear ? 'high' : 'none'
};
// Match make against dropdown options
let make: MatchedField<string> = { value: null, nhtsaValue: nhtsaMake, confidence: 'none' };
if (nhtsaYear && nhtsaMake) {
const makes = await vehicleDataService.getMakes(pool, nhtsaYear);
make = this.matchField(nhtsaMake, makes);
}
// Match model against dropdown options
let model: MatchedField<string> = { value: null, nhtsaValue: nhtsaModel, confidence: 'none' };
if (nhtsaYear && make.value && nhtsaModel) {
const models = await vehicleDataService.getModels(pool, nhtsaYear, make.value);
model = this.matchField(nhtsaModel, models);
}
// Match trim against dropdown options
let trimLevel: MatchedField<string> = { value: null, nhtsaValue: nhtsaTrim, confidence: 'none' };
if (nhtsaYear && make.value && model.value && nhtsaTrim) {
const trims = await vehicleDataService.getTrims(pool, nhtsaYear, make.value, model.value);
trimLevel = this.matchField(nhtsaTrim, trims);
}
// Match engine against dropdown options
let engine: MatchedField<string> = { value: null, nhtsaValue: nhtsaEngine, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaEngine) {
const engines = await vehicleDataService.getEngines(pool, nhtsaYear, make.value, model.value, trimLevel.value);
engine = this.matchField(nhtsaEngine, engines);
}
// Match transmission against dropdown options
let transmission: MatchedField<string> = { value: null, nhtsaValue: nhtsaTransmission, confidence: 'none' };
if (nhtsaYear && make.value && model.value && trimLevel.value && nhtsaTransmission) {
const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, nhtsaYear, make.value, model.value, trimLevel.value);
transmission = this.matchField(nhtsaTransmission, transmissions);
}
// Body type, drive type, and fuel type are display-only (no dropdown matching)
const bodyType: MatchedField<string> = {
value: null,
nhtsaValue: nhtsaBodyType,
confidence: 'none'
};
const driveType: MatchedField<string> = {
value: null,
nhtsaValue: nhtsaDriveType,
confidence: 'none'
};
const fuelType: MatchedField<string> = {
value: null,
nhtsaValue: nhtsaFuelType,
confidence: 'none'
};
return {
year,
make,
model,
trimLevel,
bodyType,
driveType,
fuelType,
engine,
transmission
};
}
/**
* Match a value against dropdown options using fuzzy matching
* Returns the matched dropdown value with confidence level
* Matching order: exact -> normalized -> prefix -> contains
*/
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' };
}
// Try prefix match - option starts with NHTSA value
const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedNhtsa));
if (prefixMatch) {
return { value: prefixMatch, nhtsaValue, confidence: 'medium' };
}
// Try contains match - option contains NHTSA value
const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedNhtsa));
if (containsMatch) {
return { value: containsMatch, 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,

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 { 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<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 { 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({
@@ -93,6 +95,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
const hasInitialized = useRef(false);
const isInitializing = useRef(false);
const isVinDecoding = useRef(false);
// Track previous values for cascade change detection (replaces useState)
const prevYear = useRef<number | undefined>(undefined);
const prevMake = useRef<string>('');
@@ -100,6 +103,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
const prevTrim = useRef<string>('');
const [currentImageUrl, setCurrentImageUrl] = useState<string | undefined>(initialData?.imageUrl);
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 vehicleId = initialData?.id;
@@ -215,8 +225,8 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
// Load makes when year changes
useEffect(() => {
// Skip during initialization
if (isInitializing.current) return;
// Skip during initialization or VIN decoding
if (isInitializing.current || isVinDecoding.current) return;
if (watchedYear && watchedYear !== prevYear.current) {
const loadMakes = async () => {
@@ -253,8 +263,8 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
// Load models when make changes
useEffect(() => {
// Skip during initialization
if (isInitializing.current) return;
// Skip during initialization or VIN decoding
if (isInitializing.current || isVinDecoding.current) return;
if (watchedMake && watchedYear && watchedMake !== prevMake.current) {
const loadModels = async () => {
@@ -288,8 +298,8 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
// Load trims when model changes
useEffect(() => {
// Skip during initialization
if (isInitializing.current) return;
// Skip during initialization or VIN decoding
if (isInitializing.current || isVinDecoding.current) return;
if (watchedModel && watchedYear && watchedMake && watchedModel !== prevModel.current) {
const loadTrims = async () => {
@@ -320,8 +330,8 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
// Load engines and transmissions when trim changes
useEffect(() => {
// Skip during initialization
if (isInitializing.current) return;
// Skip during initialization or VIN decoding
if (isInitializing.current || isVinDecoding.current) return;
if (watchedTrim && watchedYear && watchedMake && watchedModel && watchedTrim !== prevTrim.current) {
const loadEnginesAndTransmissions = async () => {
@@ -408,6 +418,110 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
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);
// Prevent cascade useEffects from clearing values during VIN decode
isVinDecoding.current = true;
setLoadingDropdowns(true);
// Determine final values (decoded value if field is empty, otherwise keep existing)
const yearValue = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear;
const makeValue = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake;
const modelValue = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel;
const trimValue = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim;
// FIRST: Load all dropdown options hierarchically (like edit mode initialization)
// Options must exist BEFORE setting form values for selects to display correctly
if (yearValue) {
prevYear.current = yearValue;
const makesData = await vehiclesApi.getMakes(yearValue);
setMakes(makesData);
if (makeValue) {
prevMake.current = makeValue;
const modelsData = await vehiclesApi.getModels(yearValue, makeValue);
setModels(modelsData);
if (modelValue) {
prevModel.current = modelValue;
const trimsData = await vehiclesApi.getTrims(yearValue, makeValue, modelValue);
setTrims(trimsData);
if (trimValue) {
prevTrim.current = trimValue;
const [enginesData, transmissionsData] = await Promise.all([
vehiclesApi.getEngines(yearValue, makeValue, modelValue, trimValue),
vehiclesApi.getTransmissions(yearValue, makeValue, modelValue, trimValue)
]);
setEngines(enginesData);
setTransmissions(transmissionsData);
}
}
}
}
// THEN: Set form values (after options are loaded)
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);
}
setLoadingDropdowns(false);
isVinDecoding.current = false;
} 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);
setLoadingDropdowns(false);
isVinDecoding.current = false;
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6">
@@ -427,17 +541,46 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
VIN Number <span className="text-red-500">*</span>
</label>
<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>
<div className="flex flex-col sm:flex-row gap-2">
<input
{...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"
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"
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 && (
<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>
{/* Vehicle Specification Dropdowns */}
@@ -689,6 +832,13 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
{initialData ? 'Update Vehicle' : 'Add Vehicle'}
</Button>
</div>
{/* Upgrade Required Dialog for VIN Decode */}
<UpgradeRequiredDialog
featureKey="vehicle.vinDecode"
open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)}
/>
</form>
);
};

View File

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