From 2aae89acbeb61a8c882717334081f2bc998d630a Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:55:26 -0600 Subject: [PATCH 1/6] feat: Add VIN decoding with NHTSA vPIC API (refs #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/core/config/feature-tiers.ts | 5 + .../vehicles/api/vehicles.controller.ts | 72 ++++++ .../features/vehicles/api/vehicles.routes.ts | 6 + .../vehicles/domain/vehicles.service.ts | 124 +++++++++ .../src/features/vehicles/external/CLAUDE.md | 43 ++++ .../features/vehicles/external/nhtsa/index.ts | 16 ++ .../vehicles/external/nhtsa/nhtsa.client.ts | 235 ++++++++++++++++++ .../vehicles/external/nhtsa/nhtsa.types.ts | 96 +++++++ .../src/features/vehicles/api/vehicles.api.ts | 11 +- .../vehicles/components/VehicleForm.tsx | 131 +++++++++- .../features/vehicles/types/vehicles.types.ts | 30 +++ 11 files changed, 760 insertions(+), 9 deletions(-) create mode 100644 backend/src/features/vehicles/external/CLAUDE.md create mode 100644 backend/src/features/vehicles/external/nhtsa/index.ts create mode 100644 backend/src/features/vehicles/external/nhtsa/nhtsa.client.ts create mode 100644 backend/src/features/vehicles/external/nhtsa/nhtsa.types.ts 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; +} From 9b4f94e1ee6d8bbf07d131fc264938f2aa9fefd5 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:56:32 -0600 Subject: [PATCH 2/6] docs: Update vehicles README with VIN decode endpoint (refs #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/src/features/vehicles/README.md | 53 ++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/backend/src/features/vehicles/README.md b/backend/src/features/vehicles/README.md index 24e4a80..f8ce50d 100644 --- a/backend/src/features/vehicles/README.md +++ b/backend/src/features/vehicles/README.md @@ -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 +{ + "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 From 19bc10a1f7eafe5bf29e0bcdb9102c07db6f7041 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 15:20:05 -0600 Subject: [PATCH 3/6] fix: Prevent cascade clearing of VIN decoded form values (refs #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VIN decode was setting year/make/model/trim values, but the cascading dropdown useEffects would immediately clear dependent fields because they detected a value change. Added isVinDecoding ref flag (mirroring the existing isInitializing pattern for edit mode) to skip cascade clearing during VIN decode and properly load dropdown options. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vehicles/components/VehicleForm.tsx | 69 ++++++++++++++----- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index 03dfbbf..ca289cb 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -95,6 +95,7 @@ export const VehicleForm: React.FC = ({ 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(undefined); const prevMake = useRef(''); @@ -224,8 +225,8 @@ export const VehicleForm: React.FC = ({ // 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 () => { @@ -262,8 +263,8 @@ export const VehicleForm: React.FC = ({ // 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 () => { @@ -297,8 +298,8 @@ export const VehicleForm: React.FC = ({ // 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 () => { @@ -329,8 +330,8 @@ export const VehicleForm: React.FC = ({ // 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 () => { @@ -443,7 +444,17 @@ export const VehicleForm: React.FC = ({ try { const decoded = await vehiclesApi.decodeVin(vin); - // Only populate empty fields (preserve existing user input) + // Prevent cascade useEffects from clearing values during VIN decode + isVinDecoding.current = true; + setLoadingDropdowns(true); + + // Track which values we're setting (only populate empty fields) + const yearToSet = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear; + const makeToSet = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake; + const modelToSet = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel; + const trimToSet = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim; + + // Set form values if (!watchedYear && decoded.year.value) { setValue('year', decoded.year.value); } @@ -463,16 +474,38 @@ export const VehicleForm: React.FC = ({ 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'); + // Load dropdown options hierarchically (like edit mode initialization) + // This ensures dropdowns have the right options for the decoded values + if (yearToSet) { + prevYear.current = yearToSet; + const makesData = await vehiclesApi.getMakes(yearToSet); + setMakes(makesData); - 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 + if (makeToSet) { + prevMake.current = makeToSet; + const modelsData = await vehiclesApi.getModels(yearToSet, makeToSet); + setModels(modelsData); + + if (modelToSet) { + prevModel.current = modelToSet; + const trimsData = await vehiclesApi.getTrims(yearToSet, makeToSet, modelToSet); + setTrims(trimsData); + + if (trimToSet) { + prevTrim.current = trimToSet; + const [enginesData, transmissionsData] = await Promise.all([ + vehiclesApi.getEngines(yearToSet, makeToSet, modelToSet, trimToSet), + vehiclesApi.getTransmissions(yearToSet, makeToSet, modelToSet, trimToSet) + ]); + setEngines(enginesData); + setTransmissions(transmissionsData); + } + } + } } + + setLoadingDropdowns(false); + isVinDecoding.current = false; } catch (error: any) { console.error('VIN decode failed:', error); if (error.response?.data?.error === 'TIER_REQUIRED') { @@ -484,6 +517,8 @@ export const VehicleForm: React.FC = ({ } } finally { setIsDecoding(false); + setLoadingDropdowns(false); + isVinDecoding.current = false; } }; From a6607d588270194ebcb625a5922f464e3dda9d23 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:12:09 -0600 Subject: [PATCH 4/6] feat: Add fuzzy matching to VIN decode for partial model/trim names (refs #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: Enhanced matchField function with prefix and contains matching so NHTSA values like "Sierra" match dropdown options like "Sierra 1500". Matching hierarchy: 1. Exact match (case-insensitive) -> high confidence 2. Normalized match (remove special chars) -> medium confidence 3. Prefix match (option starts with value) -> medium confidence (NEW) 4. Contains match (option contains value) -> medium confidence (NEW) Frontend: Fixed VIN decode form population by loading dropdown options before setting form values, preventing cascade useEffects from clearing decoded values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vehicles/domain/vehicles.service.ts | 15 +++- .../vehicles/components/VehicleForm.tsx | 74 ++++++++++--------- 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index a710480..4df852c 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -441,8 +441,9 @@ export class VehiclesService { } /** - * Match a value against dropdown options using case-insensitive exact matching + * 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 { if (!nhtsaValue || options.length === 0) { @@ -466,6 +467,18 @@ export class VehiclesService { 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' }; } diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index ca289cb..5732e6c 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -448,13 +448,45 @@ export const VehicleForm: React.FC = ({ isVinDecoding.current = true; setLoadingDropdowns(true); - // Track which values we're setting (only populate empty fields) - const yearToSet = !watchedYear && decoded.year.value ? decoded.year.value : watchedYear; - const makeToSet = !watchedMake && decoded.make.value ? decoded.make.value : watchedMake; - const modelToSet = !watchedModel && decoded.model.value ? decoded.model.value : watchedModel; - const trimToSet = !watchedTrim && decoded.trimLevel.value ? decoded.trimLevel.value : watchedTrim; + // 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; + const engineValue = !watchedEngine && decoded.engine.value ? decoded.engine.value : watchedEngine; + const transmissionValue = !watchedTransmission && decoded.transmission.value ? decoded.transmission.value : watchedTransmission; - // Set form values + // 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); } @@ -474,36 +506,6 @@ export const VehicleForm: React.FC = ({ setValue('transmission', decoded.transmission.value); } - // Load dropdown options hierarchically (like edit mode initialization) - // This ensures dropdowns have the right options for the decoded values - if (yearToSet) { - prevYear.current = yearToSet; - const makesData = await vehiclesApi.getMakes(yearToSet); - setMakes(makesData); - - if (makeToSet) { - prevMake.current = makeToSet; - const modelsData = await vehiclesApi.getModels(yearToSet, makeToSet); - setModels(modelsData); - - if (modelToSet) { - prevModel.current = modelToSet; - const trimsData = await vehiclesApi.getTrims(yearToSet, makeToSet, modelToSet); - setTrims(trimsData); - - if (trimToSet) { - prevTrim.current = trimToSet; - const [enginesData, transmissionsData] = await Promise.all([ - vehiclesApi.getEngines(yearToSet, makeToSet, modelToSet, trimToSet), - vehiclesApi.getTransmissions(yearToSet, makeToSet, modelToSet, trimToSet) - ]); - setEngines(enginesData); - setTransmissions(transmissionsData); - } - } - } - } - setLoadingDropdowns(false); isVinDecoding.current = false; } catch (error: any) { From 1bc0e602351e05fd539fdb6a600fdfd072f22efa Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:13:21 -0600 Subject: [PATCH 5/6] chore: Add hooks directory and update CLAUDE.md navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/CLAUDE.md | 2 + .claude/hooks/CLAUDE.md | 38 ++++++++++++++++++ .claude/hooks/enforce-agent-model.sh | 58 ++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .claude/hooks/CLAUDE.md create mode 100755 .claude/hooks/enforce-agent-model.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0e86b14..9bc8c9e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 | diff --git a/.claude/hooks/CLAUDE.md b/.claude/hooks/CLAUDE.md new file mode 100644 index 0000000..f98cfdf --- /dev/null +++ b/.claude/hooks/CLAUDE.md @@ -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. diff --git a/.claude/hooks/enforce-agent-model.sh b/.claude/hooks/enforce-agent-model.sh new file mode 100755 index 0000000..203b350 --- /dev/null +++ b/.claude/hooks/enforce-agent-model.sh @@ -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:-}'." + echo "Retry with: model: \"$EXPECTED_MODEL\"" + exit 1 +fi + +# Model matches, allow the call +exit 0 From f541c58fa7b3ac3880240923adc41569773468c6 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Sun, 11 Jan 2026 16:17:50 -0600 Subject: [PATCH 6/6] fix: Remove unused variables in VIN decode handler (refs #9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/features/vehicles/components/VehicleForm.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index 5732e6c..559d351 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -453,8 +453,6 @@ export const VehicleForm: React.FC = ({ 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; - const engineValue = !watchedEngine && decoded.engine.value ? decoded.engine.value : watchedEngine; - const transmissionValue = !watchedTransmission && decoded.transmission.value ? decoded.transmission.value : watchedTransmission; // FIRST: Load all dropdown options hierarchically (like edit mode initialization) // Options must exist BEFORE setting form values for selects to display correctly