/** * @ai-summary Business logic for vehicles feature * @ai-context Handles VIN decoding and business rules */ import { Pool } from 'pg'; import { VehiclesRepository } from '../data/vehicles.repository'; import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, VehicleResponse, VehicleImageMeta, TCOResponse, CostInterval, PAYMENTS_PER_YEAR } from './vehicles.types'; import { logger } from '../../../core/logging/logger'; import { cacheService } from '../../../core/config/redis'; import { getStorageService } from '../../../core/storage/storage.service'; import * as fs from 'fs/promises'; import * as path from 'path'; import { isValidVIN, isValidPreModernVIN } from '../../../shared-minimal/utils/validators'; import { normalizeMakeName, normalizeModelName } from './name-normalizer'; import { getVehicleDataService, getPool } from '../../platform'; import { auditLogService } from '../../audit-log'; import type { VinDecodeResponse } from '../../ocr/domain/ocr.types'; import type { DecodedVehicleData, MatchedField } from './vehicles.types'; import { canAddVehicle, getVehicleLimitConfig } from '../../../core/config/feature-tiers'; import { UserProfileRepository } from '../../user-profile/data/user-profile.repository'; import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; import { OwnershipCostsService } from '../../ownership-costs'; import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service'; import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository'; import { MaintenanceService } from '../../maintenance/domain/maintenance.service'; import { UserSettingsService } from '../../fuel-logs/external/user-settings.service'; export class VehicleLimitExceededError extends Error { constructor( public tier: SubscriptionTier, public currentCount: number, public limit: number, public upgradePrompt: string ) { super('Vehicle limit exceeded'); this.name = 'VehicleLimitExceededError'; } } export class VehiclesService { private readonly cachePrefix = 'vehicles'; private readonly listCacheTTL = 300; // 5 minutes private userProfileRepository: UserProfileRepository; constructor( private repository: VehiclesRepository, private pool: Pool ) { // VIN decode service is now provided by platform feature this.userProfileRepository = new UserProfileRepository(pool); } async createVehicle(data: CreateVehicleRequest, userId: string): Promise { logger.info('Creating vehicle', { userId, vin: data.vin, licensePlate: data.licensePlate }); // Pre-1981 vehicles have relaxed VIN format requirements const isPreModern = data.year && data.year < 1981; if (data.vin) { // Validate VIN format based on vehicle year if (isPreModern) { if (!isValidPreModernVIN(data.vin)) { throw new Error('Invalid VIN format for pre-1981 vehicle'); } } else if (!isValidVIN(data.vin)) { throw new Error('Invalid VIN format'); } // Duplicate check only when VIN is present const existing = await this.repository.findByUserAndVIN(userId, data.vin); if (existing) { throw new Error('Vehicle with this VIN already exists'); } } // Get user's tier for limit enforcement const userProfile = await this.userProfileRepository.getById(userId); if (!userProfile) { throw new Error('User profile not found'); } const userTier = userProfile.subscriptionTier; // Tier limit enforcement with transaction + FOR UPDATE locking to prevent race condition const client = await this.pool.connect(); try { await client.query('BEGIN'); // Lock user's vehicle rows and get count // Note: Cannot use COUNT(*) with FOR UPDATE, so we select IDs and count in app const lockResult = await client.query( 'SELECT id FROM vehicles WHERE user_id = $1 AND is_active = true FOR UPDATE', [userId] ); const currentCount = lockResult.rows.length; // Check if user can add another vehicle if (!canAddVehicle(userTier, currentCount)) { await client.query('ROLLBACK'); const limitConfig = getVehicleLimitConfig(userTier); throw new VehicleLimitExceededError( userTier, currentCount, limitConfig.limit!, limitConfig.upgradePrompt ); } // Create vehicle with user-provided data (within transaction) const query = ` INSERT INTO vehicles ( user_id, vin, make, model, year, engine, transmission, trim_level, drive_type, fuel_type, nickname, color, license_plate, odometer_reading ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING * `; const values = [ userId, (data.vin && data.vin.trim().length > 0) ? data.vin.trim() : null, data.make ? normalizeMakeName(data.make) : null, data.model ? normalizeModelName(data.model) : null, data.year, data.engine, data.transmission, data.trimLevel, data.driveType, data.fuelType, data.nickname, data.color, data.licensePlate, data.odometerReading || 0 ]; const result = await client.query(query, values); await client.query('COMMIT'); const vehicle = this.mapVehicleRow(result.rows[0]); // Invalidate user's vehicle list cache await this.invalidateUserCache(userId); // Log vehicle creation to unified audit log const vehicleDesc = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' '); await auditLogService.info( 'vehicle', userId, `Vehicle created: ${vehicleDesc || vehicle.id}`, 'vehicle', vehicle.id, { vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year } ).catch(err => logger.error('Failed to log vehicle create audit event', { error: err })); return this.toResponse(vehicle); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } /** * Map database row to Vehicle domain object */ private mapVehicleRow(row: any): Vehicle { return { id: row.id, userId: row.user_id, vin: row.vin, make: row.make, model: row.model, year: row.year, engine: row.engine, transmission: row.transmission, trimLevel: row.trim_level, driveType: row.drive_type, fuelType: row.fuel_type, nickname: row.nickname, color: row.color, licensePlate: row.license_plate, odometerReading: row.odometer_reading, isActive: row.is_active, deletedAt: row.deleted_at, createdAt: row.created_at, updatedAt: row.updated_at, imageStorageBucket: row.image_storage_bucket, imageStorageKey: row.image_storage_key, imageFileName: row.image_file_name, imageContentType: row.image_content_type, imageFileSize: row.image_file_size, }; } async getUserVehicles(userId: string): Promise { const cacheKey = `${this.cachePrefix}:user:${userId}`; // Check cache const cached = await cacheService.get(cacheKey); if (cached) { logger.debug('Vehicle list cache hit', { userId }); return cached; } // Get from database const vehicles = await this.repository.findByUserId(userId); const response = vehicles.map((v: Vehicle) => this.toResponse(v)); // Cache result await cacheService.set(cacheKey, response, this.listCacheTTL); return response; } /** * Get user vehicles with tier-gated status * Returns vehicles with tierStatus: 'active' | 'locked' */ async getUserVehiclesWithTierStatus(userId: string): Promise> { // Get user's subscription tier const userProfile = await this.userProfileRepository.getById(userId); if (!userProfile) { throw new Error('User profile not found'); } const userTier = userProfile.subscriptionTier; // Get all vehicles const vehicles = await this.repository.findByUserId(userId); // Define tier limits const tierLimits: Record = { free: 2, pro: 5, enterprise: null, // unlimited }; const tierLimit = tierLimits[userTier]; // If tier has unlimited vehicles, all are active if (tierLimit === null) { return vehicles.map((v: Vehicle) => ({ ...this.toResponse(v), tierStatus: 'active' as const, })); } // If vehicle count is within tier limit, all are active if (vehicles.length <= tierLimit) { return vehicles.map((v: Vehicle) => ({ ...this.toResponse(v), tierStatus: 'active' as const, })); } // Vehicle count exceeds tier limit - check for tier_vehicle_selections // Get vehicle selections from subscriptions repository const { SubscriptionsRepository } = await import('../../subscriptions/data/subscriptions.repository'); const subscriptionsRepo = new SubscriptionsRepository(this.pool); const selections = await subscriptionsRepo.findVehicleSelectionsByUserId(userId); const selectedVehicleIds = new Set(selections.map(s => s.vehicleId)); // If no selections exist, return all as active (selections only exist after downgrade) if (selections.length === 0) { return vehicles.map((v: Vehicle) => ({ ...this.toResponse(v), tierStatus: 'active' as const, })); } // Mark vehicles as active or locked based on selections return vehicles.map((v: Vehicle) => ({ ...this.toResponse(v), tierStatus: selectedVehicleIds.has(v.id) ? ('active' as const) : ('locked' as const), })); } async getVehicle(id: string, userId: string): Promise { const vehicle = await this.repository.findById(id); if (!vehicle) { throw new Error('Vehicle not found'); } if (vehicle.userId !== userId) { throw new Error('Unauthorized'); } return this.toResponse(vehicle); } async updateVehicle( id: string, data: UpdateVehicleRequest, userId: string ): Promise { // Verify ownership const existing = await this.repository.findById(id); if (!existing) { throw new Error('Vehicle not found'); } if (existing.userId !== userId) { throw new Error('Unauthorized'); } // Determine the effective year for validation (use new year if provided, else existing) const effectiveYear = data.year !== undefined ? data.year : existing.year; const isPreModern = effectiveYear && effectiveYear < 1981; // Validate VIN if provided if (data.vin !== undefined && data.vin.trim().length > 0) { const trimmedVin = data.vin.trim(); // Validate VIN format based on vehicle year if (isPreModern) { if (!isValidPreModernVIN(trimmedVin)) { throw new Error('Invalid VIN format for pre-1981 vehicle'); } } else if (!isValidVIN(trimmedVin)) { throw new Error('Invalid VIN format'); } // Check for duplicate VIN (only if VIN is changing) if (trimmedVin !== existing.vin) { const duplicate = await this.repository.findByUserAndVIN(userId, trimmedVin); if (duplicate && duplicate.id !== id) { throw new Error('Vehicle with this VIN already exists'); } } } // Normalize any provided name fields const normalized: UpdateVehicleRequest = { ...data } as any; if (data.make !== undefined) { (normalized as any).make = normalizeMakeName(data.make); } if (data.model !== undefined) { (normalized as any).model = normalizeModelName(data.model); } // Update vehicle const updated = await this.repository.update(id, normalized); if (!updated) { throw new Error('Update failed'); } // Invalidate cache await this.invalidateUserCache(userId); // Log vehicle update to unified audit log const vehicleDesc = [updated.year, updated.make, updated.model].filter(Boolean).join(' '); await auditLogService.info( 'vehicle', userId, `Vehicle updated: ${vehicleDesc || id}`, 'vehicle', id, { updatedFields: Object.keys(data) } ).catch(err => logger.error('Failed to log vehicle update audit event', { error: err })); return this.toResponse(updated); } async deleteVehicle(id: string, userId: string): Promise { // Verify ownership const existing = await this.repository.findById(id); if (!existing) { throw new Error('Vehicle not found'); } if (existing.userId !== userId) { throw new Error('Unauthorized'); } logger.info('Deleting vehicle', { vehicleId: id, userId, hasImageKey: !!existing.imageStorageKey, hasImageBucket: !!existing.imageStorageBucket, imageStorageKey: existing.imageStorageKey, imageStorageBucket: existing.imageStorageBucket }); // Delete associated image from storage if present if (existing.imageStorageKey && existing.imageStorageBucket) { try { const storage = getStorageService(); logger.info('Attempting to delete vehicle image', { vehicleId: id, bucket: existing.imageStorageBucket, key: existing.imageStorageKey }); await storage.deleteObject(existing.imageStorageBucket, existing.imageStorageKey); logger.info('Successfully deleted vehicle image on vehicle deletion', { vehicleId: id, bucket: existing.imageStorageBucket, key: existing.imageStorageKey }); // Clean up empty vehicle directory // Key format: vehicle-images/{userId}/{vehicleId}/{filename} const basePath = '/app/data/documents'; const vehicleDir = path.join(basePath, path.dirname(existing.imageStorageKey)); try { await fs.rmdir(vehicleDir); logger.info('Removed empty vehicle image directory', { vehicleId: id, directory: vehicleDir }); } catch (dirError) { // Directory might not be empty or might not exist, ignore logger.debug('Could not remove vehicle image directory', { vehicleId: id, directory: vehicleDir, error: dirError instanceof Error ? dirError.message : String(dirError) }); } } catch (error) { // Log warning but don't block vehicle deletion on storage failure logger.warn('Failed to delete vehicle image on vehicle deletion', { vehicleId: id, bucket: existing.imageStorageBucket, key: existing.imageStorageKey, error: error instanceof Error ? error.message : String(error) }); } } else { logger.info('No image to delete for vehicle', { vehicleId: id }); } // Soft delete await this.repository.softDelete(id); // Invalidate cache await this.invalidateUserCache(userId); // Log vehicle deletion to unified audit log const vehicleDesc = [existing.year, existing.make, existing.model].filter(Boolean).join(' '); await auditLogService.info( 'vehicle', userId, `Vehicle deleted: ${vehicleDesc || id}`, 'vehicle', id, { vin: existing.vin, make: existing.make, model: existing.model, year: existing.year } ).catch(err => logger.error('Failed to log vehicle delete audit event', { error: err })); } async getTCO(id: string, userId: string): Promise { // Get vehicle and verify ownership const vehicle = await this.repository.findById(id); if (!vehicle) { const err: any = new Error('Vehicle not found'); err.statusCode = 404; throw err; } if (vehicle.userId !== userId) { const err: any = new Error('Unauthorized'); err.statusCode = 403; throw err; } // Get user preferences for units const userSettings = await UserSettingsService.getUserSettings(userId); const distanceUnit = userSettings.unitSystem === 'metric' ? 'km' : 'mi'; const currencyCode = userSettings.currencyCode || 'USD'; // Get fuel costs from fuel-logs service const fuelLogsRepository = new FuelLogsRepository(this.pool); const fuelLogsService = new FuelLogsService(fuelLogsRepository); let fuelCosts = 0; try { const fuelStats = await fuelLogsService.getVehicleStats(id, userId); fuelCosts = fuelStats.totalCost || 0; } catch { // Vehicle may have no fuel logs fuelCosts = 0; } // Get maintenance costs from maintenance service const maintenanceService = new MaintenanceService(); let maintenanceCosts = 0; try { const maintenanceStats = await maintenanceService.getVehicleMaintenanceCosts(id, userId); maintenanceCosts = maintenanceStats.totalCost || 0; } catch { // Vehicle may have no maintenance records maintenanceCosts = 0; } // Get fixed costs from vehicle record const purchasePrice = vehicle.purchasePrice || 0; // Get recurring ownership costs from ownership-costs service const ownershipCostsService = new OwnershipCostsService(this.pool); let insuranceCosts = 0; let registrationCosts = 0; let taxCosts = 0; let otherCosts = 0; try { const ownershipStats = await ownershipCostsService.getVehicleCostStats(id, userId); insuranceCosts = ownershipStats.insuranceCosts || 0; registrationCosts = ownershipStats.registrationCosts || 0; taxCosts = ownershipStats.taxCosts || 0; otherCosts = ownershipStats.otherCosts || 0; } catch { // Vehicle may have no ownership cost records // Fall back to legacy vehicle fields if they exist insuranceCosts = this.normalizeRecurringCost( vehicle.insuranceCost, vehicle.insuranceInterval, vehicle.purchaseDate ); registrationCosts = this.normalizeRecurringCost( vehicle.registrationCost, vehicle.registrationInterval, vehicle.purchaseDate ); } // Calculate lifetime total (includes all ownership costs: insurance, registration, tax, other) const lifetimeTotal = purchasePrice + insuranceCosts + registrationCosts + taxCosts + otherCosts + fuelCosts + maintenanceCosts; // Calculate cost per distance const odometerReading = vehicle.odometerReading || 0; const costPerDistance = odometerReading > 0 ? lifetimeTotal / odometerReading : 0; return { vehicleId: id, purchasePrice, insuranceCosts, registrationCosts, taxCosts, otherCosts, fuelCosts, maintenanceCosts, lifetimeTotal, costPerDistance, distanceUnit, currencyCode }; } private normalizeRecurringCost( cost: number | null | undefined, interval: CostInterval | null | undefined, purchaseDate: string | null | undefined ): number { if (!cost || !interval || !purchaseDate) return 0; const monthsOwned = Math.max(1, this.calculateMonthsOwned(purchaseDate)); const paymentsPerYear = PAYMENTS_PER_YEAR[interval]; if (!paymentsPerYear) { throw new Error(`Invalid cost interval: ${interval}`); } const totalPayments = (monthsOwned / 12) * paymentsPerYear; return cost * totalPayments; } private calculateMonthsOwned(purchaseDate: string): number { const purchase = new Date(purchaseDate); const now = new Date(); // Guard against future dates - treat as 0 months owned if (purchase > now) { return 0; } const yearDiff = now.getFullYear() - purchase.getFullYear(); const monthDiff = now.getMonth() - purchase.getMonth(); return yearDiff * 12 + monthDiff; } async getVehicleRaw(id: string, userId: string): Promise { const vehicle = await this.repository.findById(id); if (!vehicle || vehicle.userId !== userId) { return null; } return vehicle; } async updateVehicleImage(id: string, userId: string, meta: VehicleImageMeta | null): Promise { const updated = await this.repository.updateImageMeta(id, userId, meta); if (!updated) { return null; } await this.invalidateUserCache(userId); return this.toResponse(updated); } private async invalidateUserCache(userId: string): Promise { const cacheKey = `${this.cachePrefix}:user:${userId}`; await cacheService.del(cacheKey); } async getDropdownMakes(year: number): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown makes via platform module', { year }); return vehicleDataService.getMakes(pool, year); } async getDropdownModels(year: number, make: string): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown models via platform module', { year, make }); return vehicleDataService.getModels(pool, year, make); } async getDropdownTransmissions(year: number, make: string, model: string, trim: string): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown transmissions via platform module', { year, make, model, trim }); return vehicleDataService.getTransmissionsForTrim(pool, year, make, model, trim); } async getDropdownEngines(year: number, make: string, model: string, trim: string): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown engines via platform module', { year, make, model, trim }); return vehicleDataService.getEngines(pool, year, make, model, trim); } async getDropdownTrims(year: number, make: string, model: string): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown trims via platform module', { year, make, model }); return vehicleDataService.getTrims(pool, year, make, model); } async getDropdownOptions( year: number, make: string, model: string, trim: string, engine?: string, transmission?: string ): Promise<{ engines: string[]; transmissions: string[] }> { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown options via platform module', { year, make, model, trim, engine, transmission }); return vehicleDataService.getOptions(pool, year, make, model, trim, engine, transmission); } async getDropdownYears(): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); logger.info('Fetching dropdown years via platform module'); return vehicleDataService.getYears(pool); } /** * Map VIN decode response to internal decoded vehicle data format * with dropdown matching and confidence levels */ async mapVinDecodeResponse(response: VinDecodeResponse): Promise { const vehicleDataService = getVehicleDataService(); const pool = getPool(); // Read flat fields directly from Gemini response const sourceYear = response.year; const sourceMake = response.make; const sourceModel = response.model; const sourceTrim = response.trimLevel; const sourceBodyType = response.bodyType; const sourceDriveType = response.driveType; const sourceFuelType = response.fuelType; const sourceEngine = response.engine; const sourceTransmission = response.transmission; logger.debug('VIN decode raw values', { vin: response.vin, year: sourceYear, make: sourceMake, model: sourceModel, trim: sourceTrim, confidence: response.confidence }); // Year is always high confidence if present (exact numeric match) const year: MatchedField = { value: sourceYear, sourceValue: sourceYear?.toString() || null, confidence: sourceYear ? 'high' : 'none' }; // Match make against dropdown options let make: MatchedField = { value: null, sourceValue: sourceMake, confidence: 'none' }; if (sourceYear && sourceMake) { const makes = await vehicleDataService.getMakes(pool, sourceYear); make = this.matchField(sourceMake, makes); } // Match model against dropdown options let model: MatchedField = { value: null, sourceValue: sourceModel, confidence: 'none' }; if (sourceYear && make.value && sourceModel) { const models = await vehicleDataService.getModels(pool, sourceYear, make.value); model = this.matchField(sourceModel, models); } // Match trim against dropdown options let trimLevel: MatchedField = { value: null, sourceValue: sourceTrim, confidence: 'none' }; if (sourceYear && make.value && model.value && sourceTrim) { const trims = await vehicleDataService.getTrims(pool, sourceYear, make.value, model.value); trimLevel = this.matchField(sourceTrim, trims); } // Match engine against dropdown options let engine: MatchedField = { value: null, sourceValue: sourceEngine, confidence: 'none' }; if (sourceYear && make.value && model.value && trimLevel.value && sourceEngine) { const engines = await vehicleDataService.getEngines(pool, sourceYear, make.value, model.value, trimLevel.value); engine = this.matchField(sourceEngine, engines); } // Match transmission against dropdown options let transmission: MatchedField = { value: null, sourceValue: sourceTransmission, confidence: 'none' }; if (sourceYear && make.value && model.value && trimLevel.value && sourceTransmission) { const transmissions = await vehicleDataService.getTransmissionsForTrim(pool, sourceYear, make.value, model.value, trimLevel.value); transmission = this.matchField(sourceTransmission, transmissions); } // Body type, drive type, and fuel type are display-only (no dropdown matching) const bodyType: MatchedField = { value: null, sourceValue: sourceBodyType, confidence: 'none' }; const driveType: MatchedField = { value: null, sourceValue: sourceDriveType, confidence: 'none' }; const fuelType: MatchedField = { value: null, sourceValue: sourceFuelType, confidence: 'none' }; return { year, make, model, trimLevel, bodyType, driveType, fuelType, engine, transmission }; } /** * Match a value against dropdown options using fuzzy matching * Returns the matched dropdown value with confidence level * Matching order: exact -> normalized -> prefix -> contains */ private matchField(sourceValue: string, options: string[]): MatchedField { if (!sourceValue || options.length === 0) { return { value: null, sourceValue, confidence: 'none' }; } const normalizedSource = sourceValue.toLowerCase().trim(); // Try exact case-insensitive match const exactMatch = options.find(opt => opt.toLowerCase().trim() === normalizedSource); if (exactMatch) { return { value: exactMatch, sourceValue, confidence: 'high' }; } // Try normalized comparison (remove special chars) const normalizeForCompare = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const normalizedSourceClean = normalizeForCompare(sourceValue); const normalizedMatch = options.find(opt => normalizeForCompare(opt) === normalizedSourceClean); if (normalizedMatch) { return { value: normalizedMatch, sourceValue, confidence: 'medium' }; } // Try prefix match - option starts with source value const prefixMatch = options.find(opt => opt.toLowerCase().trim().startsWith(normalizedSource)); if (prefixMatch) { return { value: prefixMatch, sourceValue, confidence: 'medium' }; } // Try contains match - option contains source value const containsMatch = options.find(opt => opt.toLowerCase().trim().includes(normalizedSource)); if (containsMatch) { return { value: containsMatch, sourceValue, confidence: 'medium' }; } // Try reverse contains - source value contains option (e.g., source "X5 xDrive35i" contains option "X5") // Prefer the longest matching option to avoid false positives (e.g., "X5 M" over "X5") const reverseMatches = options.filter(opt => { const normalizedOpt = opt.toLowerCase().trim(); return normalizedSource.includes(normalizedOpt) && normalizedOpt.length > 0; }); if (reverseMatches.length > 0) { const bestMatch = reverseMatches.reduce((a, b) => a.length >= b.length ? a : b); return { value: bestMatch, sourceValue, confidence: 'medium' }; } // Try word-start match - source starts with option + separator (e.g., "X5 xDrive" starts with "X5 ") const wordStartMatch = options.find(opt => { const normalizedOpt = opt.toLowerCase().trim(); return normalizedSource.startsWith(normalizedOpt + ' ') || normalizedSource.startsWith(normalizedOpt + '-'); }); if (wordStartMatch) { return { value: wordStartMatch, sourceValue, confidence: 'medium' }; } // No match found - return source value as hint with no match return { value: null, sourceValue, confidence: 'none' }; } private toResponse(vehicle: Vehicle): VehicleResponse { return { id: vehicle.id, userId: vehicle.userId, vin: vehicle.vin, make: vehicle.make, model: vehicle.model, year: vehicle.year, engine: vehicle.engine, transmission: vehicle.transmission, trimLevel: vehicle.trimLevel, driveType: vehicle.driveType, fuelType: vehicle.fuelType, nickname: vehicle.nickname, color: vehicle.color, licensePlate: vehicle.licensePlate, odometerReading: vehicle.odometerReading, isActive: vehicle.isActive, createdAt: vehicle.createdAt.toISOString(), updatedAt: vehicle.updatedAt.toISOString(), imageUrl: vehicle.imageStorageKey ? `/api/vehicles/${vehicle.id}/image` : undefined, }; } }