/** * @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, VehicleLimitExceededError } 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, pool); 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 instanceof VehicleLimitExceededError) { return reply.code(403).send({ error: 'TIER_REQUIRED', requiredTier: error.tier === 'free' ? 'pro' : 'enterprise', currentTier: error.tier, feature: 'vehicle.addBeyondLimit', featureName: 'Additional Vehicles', upgradePrompt: error.upgradePrompt, context: { limit: error.limit, count: error.currentCount } }); } 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' }); } } }