diff --git a/backend/src/features/vehicles/api/vehicles.controller.ts b/backend/src/features/vehicles/api/vehicles.controller.ts index 2630f9b..db87fe5 100644 --- a/backend/src/features/vehicles/api/vehicles.controller.ts +++ b/backend/src/features/vehicles/api/vehicles.controller.ts @@ -9,6 +9,11 @@ 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 { Transform, TransformCallback, Readable } from 'stream'; +import crypto from 'crypto'; +import FileType from 'file-type'; +import path from 'path'; export class VehiclesController { private vehiclesService: VehiclesService; @@ -291,4 +296,300 @@ export class VehiclesController { }); } } + + 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' + }); + } + + // Read first 4100 bytes to detect file type via magic bytes + const chunks: Buffer[] = []; + let totalBytes = 0; + const targetBytes = 4100; + + for await (const chunk of mp.file) { + chunks.push(chunk); + totalBytes += chunk.length; + if (totalBytes >= targetBytes) { + break; + } + } + + const headerBuffer = Buffer.concat(chunks); + + // Validate actual file content using magic bytes + const detectedType = await FileType.fromBuffer(headerBuffer); + + 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'; + + class CountingStream extends Transform { + public bytes = 0; + override _transform(chunk: any, _enc: BufferEncoding, cb: TransformCallback) { + this.bytes += chunk.length || 0; + cb(null, chunk); + } + } + + const counter = new CountingStream(); + + // Create a new readable stream from the header buffer + remaining file chunks + const headerStream = Readable.from([headerBuffer]); + const remainingStream = mp.file; + + // Pipe header first, then remaining content through counter + headerStream.pipe(counter, { end: false }); + headerStream.on('end', () => { + remainingStream.pipe(counter); + }); + + const storage = getStorageService(); + const bucket = 'vehicle-images'; + const unique = crypto.randomBytes(32).toString('hex'); + const key = `vehicle-images/${userId}/${vehicleId}/${unique}.${ext}`; + + await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName }); + + const updated = await this.vehiclesService.updateVehicleImage(vehicleId, userId, { + imageStorageBucket: bucket, + imageStorageKey: key, + imageFileName: originalName, + imageContentType: contentType, + imageFileSize: counter.bytes, + }); + + logger.info('Vehicle image upload completed', { + operation: 'vehicles.uploadImage.success', + userId, + vehicleId, + fileName: originalName, + contentType, + detectedType: detectedType.mime, + fileSize: counter.bytes, + 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' + }); + } + } } diff --git a/backend/src/features/vehicles/api/vehicles.routes.ts b/backend/src/features/vehicles/api/vehicles.routes.ts index dfe0156..1582146 100644 --- a/backend/src/features/vehicles/api/vehicles.routes.ts +++ b/backend/src/features/vehicles/api/vehicles.routes.ts @@ -75,6 +75,25 @@ export const vehiclesRoutes: FastifyPluginAsync = async ( handler: vehiclesController.getDropdownOptions.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', { + preHandler: [fastify.authenticate], + handler: vehiclesController.uploadImage.bind(vehiclesController) + }); + + // GET /api/vehicles/:id/image - Download vehicle image + fastify.get<{ Params: VehicleParams }>('/vehicles/:id/image', { + preHandler: [fastify.authenticate], + handler: vehiclesController.downloadImage.bind(vehiclesController) + }); + + // DELETE /api/vehicles/:id/image - Delete vehicle image + fastify.delete<{ Params: VehicleParams }>('/vehicles/:id/image', { + preHandler: [fastify.authenticate], + handler: vehiclesController.deleteImage.bind(vehiclesController) + }); + // Dynamic :id routes MUST come last to avoid matching specific paths like "dropdown" // GET /api/vehicles/:id - Get specific vehicle fastify.get<{ Params: VehicleParams }>('/vehicles/:id', { diff --git a/backend/src/features/vehicles/data/vehicles.repository.ts b/backend/src/features/vehicles/data/vehicles.repository.ts index def3ad2..c555c5b 100644 --- a/backend/src/features/vehicles/data/vehicles.repository.ts +++ b/backend/src/features/vehicles/data/vehicles.repository.ts @@ -4,7 +4,7 @@ */ import { Pool } from 'pg'; -import { Vehicle, CreateVehicleRequest } from '../domain/vehicles.types'; +import { Vehicle, CreateVehicleRequest, VehicleImageMeta } from '../domain/vehicles.types'; export class VehiclesRepository { constructor(private pool: Pool) {} @@ -156,11 +156,50 @@ export class VehiclesRepository { SET is_active = false, deleted_at = NOW() WHERE id = $1 `; - + const result = await this.pool.query(query, [id]); return (result.rowCount ?? 0) > 0; } + async updateImageMeta(id: string, userId: string, meta: VehicleImageMeta | null): Promise { + if (meta === null) { + const query = ` + UPDATE vehicles SET + image_storage_bucket = NULL, + image_storage_key = NULL, + image_file_name = NULL, + image_content_type = NULL, + image_file_size = NULL, + updated_at = NOW() + WHERE id = $1 AND user_id = $2 + RETURNING * + `; + const result = await this.pool.query(query, [id, userId]); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } + + const query = ` + UPDATE vehicles SET + image_storage_bucket = $1, + image_storage_key = $2, + image_file_name = $3, + image_content_type = $4, + image_file_size = $5, + updated_at = NOW() + WHERE id = $6 AND user_id = $7 + RETURNING * + `; + const result = await this.pool.query(query, [ + meta.imageStorageBucket, + meta.imageStorageKey, + meta.imageFileName, + meta.imageContentType, + meta.imageFileSize, + id, + userId + ]); + return result.rows[0] ? this.mapRow(result.rows[0]) : null; + } private mapRow(row: any): Vehicle { return { @@ -183,6 +222,11 @@ export class VehiclesRepository { 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, }; } } diff --git a/backend/src/features/vehicles/domain/vehicles.service.ts b/backend/src/features/vehicles/domain/vehicles.service.ts index fa106a9..de38190 100644 --- a/backend/src/features/vehicles/domain/vehicles.service.ts +++ b/backend/src/features/vehicles/domain/vehicles.service.ts @@ -8,7 +8,8 @@ import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, - VehicleResponse + VehicleResponse, + VehicleImageMeta } from './vehicles.types'; import { logger } from '../../../core/logging/logger'; import { cacheService } from '../../../core/config/redis'; @@ -131,14 +132,31 @@ export class VehiclesService { if (existing.userId !== userId) { throw new Error('Unauthorized'); } - + // Soft delete await this.repository.softDelete(id); - + // Invalidate cache await this.invalidateUserCache(userId); } - + + 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); @@ -227,6 +245,7 @@ export class VehiclesService { isActive: vehicle.isActive, createdAt: vehicle.createdAt.toISOString(), updatedAt: vehicle.updatedAt.toISOString(), + imageUrl: vehicle.imageStorageKey ? `/api/vehicles/${vehicle.id}/image` : undefined, }; } } diff --git a/backend/src/features/vehicles/domain/vehicles.types.ts b/backend/src/features/vehicles/domain/vehicles.types.ts index 7df19f7..345d12d 100644 --- a/backend/src/features/vehicles/domain/vehicles.types.ts +++ b/backend/src/features/vehicles/domain/vehicles.types.ts @@ -23,6 +23,11 @@ export interface Vehicle { deletedAt?: Date; createdAt: Date; updatedAt: Date; + imageStorageBucket?: string; + imageStorageKey?: string; + imageFileName?: string; + imageContentType?: string; + imageFileSize?: number; } export interface CreateVehicleRequest { @@ -73,6 +78,15 @@ export interface VehicleResponse { isActive: boolean; createdAt: string; updatedAt: string; + imageUrl?: string; +} + +export interface VehicleImageMeta { + imageStorageBucket: string; + imageStorageKey: string; + imageFileName: string; + imageContentType: string; + imageFileSize: number; } export interface VINDecodeResult { diff --git a/backend/src/features/vehicles/migrations/005_add_vehicle_image.sql b/backend/src/features/vehicles/migrations/005_add_vehicle_image.sql new file mode 100644 index 0000000..9f15bd4 --- /dev/null +++ b/backend/src/features/vehicles/migrations/005_add_vehicle_image.sql @@ -0,0 +1,12 @@ +-- Add image metadata columns to vehicles table +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_storage_bucket VARCHAR(50); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_storage_key VARCHAR(500); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_file_name VARCHAR(255); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_content_type VARCHAR(100); +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS image_file_size INTEGER; + +COMMENT ON COLUMN vehicles.image_storage_bucket IS 'Storage bucket for vehicle image'; +COMMENT ON COLUMN vehicles.image_storage_key IS 'Storage key path for vehicle image file'; +COMMENT ON COLUMN vehicles.image_file_name IS 'Original filename of uploaded image'; +COMMENT ON COLUMN vehicles.image_content_type IS 'MIME type of vehicle image'; +COMMENT ON COLUMN vehicles.image_file_size IS 'Size of vehicle image in bytes'; diff --git a/data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm b/data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm new file mode 100644 index 0000000..d9a85e2 Binary files /dev/null and b/data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-shm differ diff --git a/data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal b/data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal new file mode 100644 index 0000000..fbbbfce Binary files /dev/null and b/data/vehicle-etl/snapshots/2025-12-16/snapshot.sqlite-wal differ diff --git a/frontend/src/features/vehicles/api/vehicles.api.ts b/frontend/src/features/vehicles/api/vehicles.api.ts index 9b80ad5..419c59f 100644 --- a/frontend/src/features/vehicles/api/vehicles.api.ts +++ b/frontend/src/features/vehicles/api/vehicles.api.ts @@ -61,5 +61,22 @@ export const vehiclesApi = { getTrims: async (year: number, make: string, model: string): Promise => { const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make=${encodeURIComponent(make)}&model=${encodeURIComponent(model)}`); return response.data; + }, + + uploadImage: async (vehicleId: string, file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + const response = await apiClient.post(`/vehicles/${vehicleId}/image`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + return response.data; + }, + + deleteImage: async (vehicleId: string): Promise => { + await apiClient.delete(`/vehicles/${vehicleId}/image`); + }, + + getImageUrl: (vehicleId: string): string => { + return `/api/vehicles/${vehicleId}/image`; } }; diff --git a/frontend/src/features/vehicles/components/VehicleCard.tsx b/frontend/src/features/vehicles/components/VehicleCard.tsx index 94d0df4..05ba0ea 100644 --- a/frontend/src/features/vehicles/components/VehicleCard.tsx +++ b/frontend/src/features/vehicles/components/VehicleCard.tsx @@ -8,6 +8,7 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import { Vehicle } from '../types/vehicles.types'; import { useUnits } from '../../../core/units/UnitsContext'; +import { VehicleImage } from './VehicleImage'; interface VehicleCardProps { vehicle: Vehicle; @@ -16,20 +17,6 @@ interface VehicleCardProps { onSelect: (id: string) => void; } -const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => ( - -); - export const VehicleCard: React.FC = ({ vehicle, onEdit, @@ -57,7 +44,7 @@ export const VehicleCard: React.FC = ({ sx={{ flexGrow: 1 }} > - + {displayName} diff --git a/frontend/src/features/vehicles/components/VehicleForm.tsx b/frontend/src/features/vehicles/components/VehicleForm.tsx index d66993a..3d916b1 100644 --- a/frontend/src/features/vehicles/components/VehicleForm.tsx +++ b/frontend/src/features/vehicles/components/VehicleForm.tsx @@ -7,8 +7,9 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Button } from '../../../shared-minimal/components/Button'; -import { CreateVehicleRequest } from '../types/vehicles.types'; +import { CreateVehicleRequest, Vehicle } from '../types/vehicles.types'; import { vehiclesApi } from '../api/vehicles.api'; +import { VehicleImageUpload } from './VehicleImageUpload'; const vehicleSchema = z .object({ @@ -57,8 +58,9 @@ const vehicleSchema = z interface VehicleFormProps { onSubmit: (data: CreateVehicleRequest) => void; onCancel: () => void; - initialData?: Partial; + initialData?: Partial & { id?: string; imageUrl?: string }; loading?: boolean; + onImageUpdate?: (vehicle: Vehicle) => void; } export const VehicleForm: React.FC = ({ @@ -66,6 +68,7 @@ export const VehicleForm: React.FC = ({ onCancel, initialData, loading, + onImageUpdate, }) => { const [years, setYears] = useState([]); const [makes, setMakes] = useState([]); @@ -80,6 +83,10 @@ export const VehicleForm: React.FC = ({ const [loadingDropdowns, setLoadingDropdowns] = useState(false); const hasInitialized = useRef(false); const isInitializing = useRef(false); + const [currentImageUrl, setCurrentImageUrl] = useState(initialData?.imageUrl); + + const isEditMode = !!initialData?.id; + const vehicleId = initialData?.id; const { register, @@ -332,8 +339,53 @@ export const VehicleForm: React.FC = ({ } }, [watchedYear, selectedMake, selectedModel, watch('trimLevel')]); + const handleImageUpload = async (file: File) => { + if (!vehicleId) return; + const updated = await vehiclesApi.uploadImage(vehicleId, file); + setCurrentImageUrl(updated.imageUrl); + onImageUpdate?.(updated); + }; + + const handleImageRemove = async () => { + if (!vehicleId) return; + await vehiclesApi.deleteImage(vehicleId); + setCurrentImageUrl(undefined); + if (initialData) { + onImageUpdate?.({ ...initialData, imageUrl: undefined } as Vehicle); + } + }; + + const vehicleForImage: Vehicle = { + id: vehicleId || '', + userId: '', + vin: initialData?.vin || '', + make: initialData?.make, + model: initialData?.model, + year: initialData?.year, + color: initialData?.color, + odometerReading: initialData?.odometerReading || 0, + isActive: true, + createdAt: '', + updatedAt: '', + imageUrl: currentImageUrl, + }; + return (
+ {isEditMode && ( +
+ + +
+ )} +