All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m40s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 24s
Deploy to Staging / Verify Staging (pull_request) Successful in 10s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
16 controllers still used request.user.sub (Auth0 ID) instead of request.userContext.userId (UUID) after the user_id column migration, causing 500 errors on all authenticated endpoints including dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
719 lines
25 KiB
TypeScript
719 lines
25 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, 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.userContext!.userId;
|
|
// Use tier-aware method to filter out locked vehicles after downgrade
|
|
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
|
|
// Only return active vehicles (filter out locked ones)
|
|
const vehicles = vehiclesWithStatus
|
|
.filter(v => v.tierStatus === 'active')
|
|
.map(({ tierStatus, ...vehicle }) => vehicle);
|
|
|
|
return reply.code(200).send(vehicles);
|
|
} catch (error) {
|
|
logger.error('Error getting user vehicles', { error, userId: request.userContext?.userId });
|
|
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.userContext!.userId;
|
|
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.userContext?.userId });
|
|
|
|
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.userContext!.userId;
|
|
const { id } = request.params;
|
|
|
|
// Check tier status - block access to locked vehicles
|
|
const vehiclesWithStatus = await this.vehiclesService.getUserVehiclesWithTierStatus(userId);
|
|
const vehicleStatus = vehiclesWithStatus.find(v => v.id === id);
|
|
if (vehicleStatus && vehicleStatus.tierStatus === 'locked') {
|
|
return reply.code(403).send({
|
|
error: 'TIER_REQUIRED',
|
|
requiredTier: 'pro',
|
|
feature: 'vehicle.access',
|
|
featureName: 'Vehicle Access',
|
|
upgradePrompt: 'Upgrade to Pro to access all your vehicles',
|
|
message: 'This vehicle is not available on your current subscription tier'
|
|
});
|
|
}
|
|
|
|
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.userContext?.userId });
|
|
|
|
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.userContext!.userId;
|
|
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.userContext?.userId });
|
|
|
|
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.userContext!.userId;
|
|
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.userContext?.userId });
|
|
|
|
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 getTCO(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
|
try {
|
|
const userId = request.userContext!.userId;
|
|
const { id } = request.params;
|
|
|
|
const tco = await this.vehiclesService.getTCO(id, userId);
|
|
return reply.code(200).send(tco);
|
|
} catch (error: any) {
|
|
logger.error('Error getting vehicle TCO', { error, vehicleId: request.params.id, userId: request.userContext?.userId });
|
|
|
|
if (error.statusCode === 404 || error.message === 'Vehicle not found') {
|
|
return reply.code(404).send({
|
|
error: 'Not Found',
|
|
message: 'Vehicle not found'
|
|
});
|
|
}
|
|
|
|
if (error.statusCode === 403 || error.message === 'Unauthorized') {
|
|
return reply.code(403).send({
|
|
error: 'Forbidden',
|
|
message: 'Not authorized to access this vehicle'
|
|
});
|
|
}
|
|
|
|
return reply.code(500).send({
|
|
error: 'Internal server error',
|
|
message: 'Failed to calculate TCO'
|
|
});
|
|
}
|
|
}
|
|
|
|
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.userContext?.userId;
|
|
|
|
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.userContext!.userId;
|
|
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.userContext!.userId;
|
|
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.userContext!.userId;
|
|
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'
|
|
});
|
|
}
|
|
}
|
|
}
|