Photos for vehicles
This commit is contained in:
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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<Vehicle | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vehicle | null> {
|
||||
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<VehicleResponse | null> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user