feat: Add VIN decoding with NHTSA vPIC API (refs #9)

- Add NHTSA client for VIN decoding with caching and validation
- Add POST /api/vehicles/decode-vin endpoint with tier gating
- Add dropdown matching service with confidence levels
- Add decode button to VehicleForm with tier check
- Responsive layout: stacks on mobile, inline on desktop
- Only populate empty fields (preserve user input)

Backend:
- NHTSAClient with 5s timeout, VIN validation, vin_cache table
- Tier gating with 'vehicle.vinDecode' feature key (Pro+)
- Tiered matching: high (exact), medium (normalized), none

Frontend:
- Decode button with loading state and error handling
- UpgradeRequiredDialog for free tier users
- Mobile-first responsive layout

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-01-11 13:55:26 -06:00
parent 84baa755d9
commit 2aae89acbe
11 changed files with 760 additions and 9 deletions

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