Files
motovaultpro/backend/src/features/vehicles/api/vehicles.controller.ts
Eric Gullickson 2aae89acbe feat: Add VIN decoding with NHTSA vPIC API (refs #9)
- Add NHTSA client for VIN decoding with caching and validation
- Add POST /api/vehicles/decode-vin endpoint with tier gating
- Add dropdown matching service with confidence levels
- Add decode button to VehicleForm with tier check
- Responsive layout: stacks on mobile, inline on desktop
- Only populate empty fields (preserve user input)

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:55:26 -06:00

654 lines
23 KiB
TypeScript

/**
* @ai-summary Fastify route handlers for vehicles API
* @ai-context HTTP request/response handling with Fastify reply methods
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { VehiclesService } from '../domain/vehicles.service';
import { VehiclesRepository } from '../data/vehicles.repository';
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) {
try {
const userId = (request as any).user.sub;
const vehicles = await this.vehiclesService.getUserVehicles(userId);
return reply.code(200).send(vehicles);
} catch (error) {
logger.error('Error getting user vehicles', { error, userId: (request as any).user?.sub });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get vehicles'
});
}
}
async createVehicle(request: FastifyRequest<{ Body: CreateVehicleBody }>, reply: FastifyReply) {
try {
// Pre-1981 vehicles have no VIN/plate requirement
const year = request.body?.year;
const isPreModern = year && year < 1981;
if (!isPreModern) {
// Require either a valid 17-char VIN or a non-empty license plate
const vin = request.body?.vin?.trim();
const plate = request.body?.licensePlate?.trim();
const hasValidVin = !!vin && vin.length === 17;
const hasPlate = !!plate && plate.length > 0;
if (!hasValidVin && !hasPlate) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Either a valid 17-character VIN or a license plate is required'
});
}
}
const userId = (request as any).user.sub;
const vehicle = await this.vehiclesService.createVehicle(request.body, userId);
return reply.code(201).send(vehicle);
} catch (error: any) {
logger.error('Error creating vehicle', { error, userId: (request as any).user?.sub });
if (error.message === 'Invalid VIN format') {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
if (error.message === 'Vehicle with this VIN already exists') {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to create vehicle'
});
}
}
async getVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const { id } = request.params;
const vehicle = await this.vehiclesService.getVehicle(id, userId);
return reply.code(200).send(vehicle);
} catch (error: any) {
logger.error('Error getting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({
error: 'Not Found',
message: 'Vehicle not found'
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get vehicle'
});
}
}
async updateVehicle(request: FastifyRequest<{ Params: VehicleParams; Body: UpdateVehicleBody }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const { id } = request.params;
const vehicle = await this.vehiclesService.updateVehicle(id, request.body, userId);
return reply.code(200).send(vehicle);
} catch (error: any) {
logger.error('Error updating vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({
error: 'Not Found',
message: 'Vehicle not found'
});
}
if (error.message === 'Invalid VIN format' ||
error.message === 'Invalid VIN format for pre-1981 vehicle' ||
error.message === 'Vehicle with this VIN already exists') {
return reply.code(400).send({
error: 'Bad Request',
message: error.message
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to update vehicle'
});
}
}
async deleteVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
try {
const userId = (request as any).user.sub;
const { id } = request.params;
await this.vehiclesService.deleteVehicle(id, userId);
return reply.code(204).send();
} catch (error: any) {
logger.error('Error deleting vehicle', { error, vehicleId: request.params.id, userId: (request as any).user?.sub });
if (error.message === 'Vehicle not found' || error.message === 'Unauthorized') {
return reply.code(404).send({
error: 'Not Found',
message: 'Vehicle not found'
});
}
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to delete vehicle'
});
}
}
async getDropdownMakes(request: FastifyRequest<{ Querystring: { year: number } }>, reply: FastifyReply) {
try {
const { year } = request.query;
if (!year || isNaN(year)) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year parameter is required'
});
}
const makes = await this.vehiclesService.getDropdownMakes(year);
return reply.code(200).send(makes);
} catch (error) {
logger.error('Error getting dropdown makes', { error, year: request.query?.year });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get makes'
});
}
}
async getDropdownModels(request: FastifyRequest<{ Querystring: { year: number; make: string } }>, reply: FastifyReply) {
try {
const { year, make } = request.query;
if (!year || isNaN(year) || !make || make.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year and make parameters are required'
});
}
const models = await this.vehiclesService.getDropdownModels(year, make);
return reply.code(200).send(models);
} catch (error) {
logger.error('Error getting dropdown models', { error, year: request.query?.year, make: request.query?.make });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get models'
});
}
}
async getDropdownTransmissions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
try {
const { year, make, model, trim } = request.query;
if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, model, and trim parameters are required'
});
}
const transmissions = await this.vehiclesService.getDropdownTransmissions(year, make, model, trim);
return reply.code(200).send(transmissions);
} catch (error) {
logger.error('Error getting dropdown transmissions', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model, trim: request.query?.trim });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get transmissions'
});
}
}
async getDropdownEngines(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string } }>, reply: FastifyReply) {
try {
const { year, make, model, trim } = request.query;
if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, model, and trim parameters are required'
});
}
const engines = await this.vehiclesService.getDropdownEngines(year, make, model, trim);
return reply.code(200).send(engines);
} catch (error) {
logger.error('Error getting dropdown engines', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model, trim: request.query?.trim });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get engines'
});
}
}
async getDropdownTrims(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string } }>, reply: FastifyReply) {
try {
const { year, make, model } = request.query;
if (!year || isNaN(year) || !make || !model || make.trim().length === 0 || model.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, and model parameters are required'
});
}
const trims = await this.vehiclesService.getDropdownTrims(year, make, model);
return reply.code(200).send(trims);
} catch (error) {
logger.error('Error getting dropdown trims', { error, year: request.query?.year, make: request.query?.make, model: request.query?.model });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get trims'
});
}
}
async getDropdownYears(_request: FastifyRequest, reply: FastifyReply) {
try {
// Use platform client through VehiclesService's integration
const years = await this.vehiclesService.getDropdownYears();
return reply.code(200).send(years);
} catch (error) {
logger.error('Error getting dropdown years', { error });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get years'
});
}
}
async getDropdownOptions(request: FastifyRequest<{ Querystring: { year: number; make: string; model: string; trim: string; engine?: string; transmission?: string } }>, reply: FastifyReply) {
try {
const { year, make, model, trim, engine, transmission } = request.query;
if (!year || isNaN(year) || !make || !model || !trim || make.trim().length === 0 || model.trim().length === 0 || trim.trim().length === 0) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Valid year, make, model, and trim parameters are required'
});
}
const options = await this.vehiclesService.getDropdownOptions(year, make, model, trim, engine, transmission);
return reply.code(200).send(options);
} catch (error) {
logger.error('Error getting dropdown options', { error, query: request.query });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to get engine/transmission options'
});
}
}
/**
* 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;
logger.info('Vehicle image upload requested', {
operation: 'vehicles.uploadImage',
userId,
vehicleId,
});
try {
// Verify vehicle ownership
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
if (!vehicle) {
logger.warn('Vehicle not found for image upload', {
operation: 'vehicles.uploadImage.not_found',
userId,
vehicleId,
});
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
}
const mp = await (request as any).file({ limits: { files: 1 } });
if (!mp) {
logger.warn('No file provided for image upload', {
operation: 'vehicles.uploadImage.no_file',
userId,
vehicleId,
});
return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' });
}
// Allowed image types
const allowedTypes = new Map([
['image/jpeg', new Set(['image/jpeg'])],
['image/png', new Set(['image/png'])],
]);
const contentType = mp.mimetype as string | undefined;
if (!contentType || !allowedTypes.has(contentType)) {
logger.warn('Unsupported image type', {
operation: 'vehicles.uploadImage.unsupported_type',
userId,
vehicleId,
contentType,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: 'Only JPEG and PNG images are allowed'
});
}
// Buffer the entire file for reliable processing
// Vehicle images are typically small (< 10MB) so this is safe
const chunks: Buffer[] = [];
for await (const chunk of mp.file) {
chunks.push(chunk);
}
const fileBuffer = Buffer.concat(chunks);
// Validate actual file content using magic bytes
const detectedType = await FileType.fromBuffer(fileBuffer);
if (!detectedType) {
logger.warn('Unable to detect file type from content', {
operation: 'vehicles.uploadImage.type_detection_failed',
userId,
vehicleId,
contentType,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: 'Unable to verify file type from content'
});
}
// Verify detected type matches claimed Content-Type
const allowedDetectedTypes = allowedTypes.get(contentType);
if (!allowedDetectedTypes || !allowedDetectedTypes.has(detectedType.mime)) {
logger.warn('File content does not match Content-Type header', {
operation: 'vehicles.uploadImage.type_mismatch',
userId,
vehicleId,
claimedType: contentType,
detectedType: detectedType.mime,
fileName: mp.filename,
});
return reply.code(415).send({
error: 'Unsupported Media Type',
message: `File content (${detectedType.mime}) does not match claimed type (${contentType})`
});
}
// Delete existing image if present
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
const storage = getStorageService();
try {
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
logger.info('Deleted existing vehicle image', {
operation: 'vehicles.uploadImage.delete_existing',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
});
} catch (e) {
logger.warn('Failed to delete existing vehicle image', {
operation: 'vehicles.uploadImage.delete_existing_failed',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
error: e instanceof Error ? e.message : 'Unknown error',
});
}
}
const originalName: string = mp.filename || 'vehicle-image';
const ext = contentType === 'image/jpeg' ? 'jpg' : 'png';
const storage = getStorageService();
const bucket = 'vehicle-images';
const unique = crypto.randomBytes(32).toString('hex');
const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`;
// Write buffer directly to storage
await storage.putObject(bucket, key, fileBuffer, contentType, { 'x-original-filename': originalName });
const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, {
imageStorageBucket: bucket,
imageStorageKey: key,
imageFileName: originalName,
imageContentType: contentType,
imageFileSize: fileBuffer.length,
});
logger.info('Vehicle image upload completed', {
operation: 'vehicles.uploadImage.success',
userId,
vehicleId,
fileName: originalName,
contentType,
detectedType: detectedType.mime,
fileSize: fileBuffer.length,
storageKey: key,
});
return reply.code(200).send(updated);
} catch (error) {
logger.error('Error uploading vehicle image', { error, vehicleId, userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to upload image'
});
}
}
async downloadImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const vehicleId = request.params.id;
logger.info('Vehicle image download requested', {
operation: 'vehicles.downloadImage',
userId,
vehicleId,
});
try {
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
if (!vehicle || !vehicle.imageStorageBucket || !vehicle.imageStorageKey) {
logger.warn('Vehicle or image not found for download', {
operation: 'vehicles.downloadImage.not_found',
userId,
vehicleId,
hasVehicle: !!vehicle,
hasStorageInfo: !!(vehicle?.imageStorageBucket && vehicle?.imageStorageKey),
});
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle image not found' });
}
const storage = getStorageService();
const contentType = vehicle.imageContentType || 'application/octet-stream';
const filename = vehicle.imageFileName || path.basename(vehicle.imageStorageKey);
reply.header('Content-Type', contentType);
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
logger.info('Vehicle image download initiated', {
operation: 'vehicles.downloadImage.success',
userId,
vehicleId,
fileName: filename,
contentType,
fileSize: vehicle.imageFileSize,
});
const stream = await storage.getObjectStream(vehicle.imageStorageBucket, vehicle.imageStorageKey);
return reply.send(stream);
} catch (error) {
logger.error('Error downloading vehicle image', { error, vehicleId, userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to download image'
});
}
}
async deleteImage(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
const userId = (request as any).user.sub;
const vehicleId = request.params.id;
logger.info('Vehicle image delete requested', {
operation: 'vehicles.deleteImage',
userId,
vehicleId,
});
try {
const vehicle = await this.vehiclesService.getVehicleRaw(vehicleId, userId);
if (!vehicle) {
logger.warn('Vehicle not found for image delete', {
operation: 'vehicles.deleteImage.not_found',
userId,
vehicleId,
});
return reply.code(404).send({ error: 'Not Found', message: 'Vehicle not found' });
}
// Delete file from storage if exists
if (vehicle.imageStorageKey && vehicle.imageStorageBucket) {
const storage = getStorageService();
try {
await storage.deleteObject(vehicle.imageStorageBucket, vehicle.imageStorageKey);
logger.info('Vehicle image file deleted from storage', {
operation: 'vehicles.deleteImage.storage_cleanup',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
});
} catch (e) {
logger.warn('Failed to delete vehicle image file from storage', {
operation: 'vehicles.deleteImage.storage_cleanup_failed',
userId,
vehicleId,
storageKey: vehicle.imageStorageKey,
error: e instanceof Error ? e.message : 'Unknown error',
});
}
}
// Clear image metadata from database
await this.vehiclesService.updateVehicleImage(vehicleId, userId, null);
logger.info('Vehicle image deleted', {
operation: 'vehicles.deleteImage.success',
userId,
vehicleId,
hadFile: !!(vehicle.imageStorageKey),
});
return reply.code(204).send();
} catch (error) {
logger.error('Error deleting vehicle image', { error, vehicleId, userId });
return reply.code(500).send({
error: 'Internal server error',
message: 'Failed to delete image'
});
}
}
}