import { FastifyReply, FastifyRequest } from 'fastify'; import { DocumentsService } from '../domain/documents.service'; import type { CreateBody, IdParams, ListQuery, UpdateBody } from './documents.validation'; import { getStorageService } from '../../../core/storage/storage.service'; import { logger } from '../../../core/logging/logger'; import path from 'path'; import { Transform, TransformCallback } from 'stream'; import crypto from 'crypto'; import FileType from 'file-type'; import { Readable } from 'stream'; import { canAccessFeature, getFeatureConfig } from '../../../core/config/feature-tiers'; import { SubscriptionTier } from '../../user-profile/domain/user-profile.types'; export class DocumentsController { private readonly service = new DocumentsService(); async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; logger.info('Documents list requested', { operation: 'documents.list', userId, filters: { vehicleId: request.query.vehicleId, type: request.query.type, expiresBefore: request.query.expiresBefore, }, }); const docs = await this.service.listDocuments(userId, { vehicleId: request.query.vehicleId, type: request.query.type, expiresBefore: request.query.expiresBefore, }); logger.info('Documents list retrieved', { operation: 'documents.list.success', userId, documentCount: docs.length, }); return reply.code(200).send(docs); } async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const documentId = request.params.id; logger.info('Document get requested', { operation: 'documents.get', userId, documentId, }); const doc = await this.service.getDocument(userId, documentId); if (!doc) { logger.warn('Document not found', { operation: 'documents.get.not_found', userId, documentId, }); return reply.code(404).send({ error: 'Not Found' }); } logger.info('Document retrieved', { operation: 'documents.get.success', userId, documentId, vehicleId: doc.vehicleId, documentType: doc.documentType, }); return reply.code(200).send(doc); } async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free'; logger.info('Document create requested', { operation: 'documents.create', userId, vehicleId: request.body.vehicleId, documentType: request.body.documentType, title: request.body.title, }); // Tier validation: scanForMaintenance requires Pro tier const featureKey = 'document.scanMaintenanceSchedule'; if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) { const config = getFeatureConfig(featureKey); logger.warn('Tier required for scanForMaintenance', { operation: 'documents.create.tier_required', userId, userTier, requiredTier: config?.minTier, }); return reply.code(403).send({ error: 'TIER_REQUIRED', requiredTier: config?.minTier || 'pro', currentTier: userTier, feature: featureKey, featureName: config?.name || null, upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.', }); } const created = await this.service.createDocument(userId, request.body); logger.info('Document created', { operation: 'documents.create.success', userId, documentId: created.id, vehicleId: created.vehicleId, documentType: created.documentType, title: created.title, }); return reply.code(201).send(created); } async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const userTier: SubscriptionTier = request.userContext?.subscriptionTier || 'free'; const documentId = request.params.id; logger.info('Document update requested', { operation: 'documents.update', userId, documentId, updateFields: Object.keys(request.body), }); // Tier validation: scanForMaintenance requires Pro tier const featureKey = 'document.scanMaintenanceSchedule'; if (request.body.scanForMaintenance && !canAccessFeature(userTier, featureKey)) { const config = getFeatureConfig(featureKey); logger.warn('Tier required for scanForMaintenance', { operation: 'documents.update.tier_required', userId, documentId, userTier, requiredTier: config?.minTier, }); return reply.code(403).send({ error: 'TIER_REQUIRED', requiredTier: config?.minTier || 'pro', currentTier: userTier, feature: featureKey, featureName: config?.name || null, upgradePrompt: config?.upgradePrompt || 'Upgrade to Pro to access this feature.', }); } const updated = await this.service.updateDocument(userId, documentId, request.body); if (!updated) { logger.warn('Document not found for update', { operation: 'documents.update.not_found', userId, documentId, }); return reply.code(404).send({ error: 'Not Found' }); } logger.info('Document updated', { operation: 'documents.update.success', userId, documentId, vehicleId: updated.vehicleId, title: updated.title, }); return reply.code(200).send(updated); } async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const documentId = request.params.id; logger.info('Document delete requested', { operation: 'documents.delete', userId, documentId, }); // If object exists, delete it from storage first const existing = await this.service.getDocument(userId, documentId); if (existing && existing.storageBucket && existing.storageKey) { const storage = getStorageService(); try { await storage.deleteObject(existing.storageBucket, existing.storageKey); logger.info('Document file deleted from storage', { operation: 'documents.delete.storage_cleanup', userId, documentId, storageKey: existing.storageKey, }); } catch (e) { logger.warn('Failed to delete document file from storage', { operation: 'documents.delete.storage_cleanup_failed', userId, documentId, storageKey: existing.storageKey, error: e instanceof Error ? e.message : 'Unknown error', }); // Non-fatal: proceed with soft delete } } await this.service.deleteDocument(userId, documentId); logger.info('Document deleted', { operation: 'documents.delete.success', userId, documentId, vehicleId: existing?.vehicleId, hadFile: !!(existing?.storageKey), }); return reply.code(204).send(); } async upload(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const documentId = request.params.id; logger.info('Document upload requested', { operation: 'documents.upload', userId, documentId, }); const doc = await this.service.getDocument(userId, documentId); if (!doc) { logger.warn('Document not found for upload', { operation: 'documents.upload.not_found', userId, documentId, }); return reply.code(404).send({ error: 'Not Found' }); } const mp = await (request as any).file({ limits: { files: 1 } }); if (!mp) { logger.warn('No file provided for upload', { operation: 'documents.upload.no_file', userId, documentId, }); return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' }); } // Define allowed MIME types and their corresponding magic byte signatures const allowedTypes = new Map([ ['application/pdf', new Set(['application/pdf'])], ['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 file type for upload (header validation)', { operation: 'documents.upload.unsupported_type', userId, documentId, contentType, fileName: mp.filename, }); return reply.code(415).send({ error: 'Unsupported Media Type', message: 'Only PDF, JPEG, and PNG files are allowed' }); } // Collect ALL file chunks first (breaking early from async iterator corrupts stream state) const chunks: Buffer[] = []; for await (const chunk of mp.file) { chunks.push(chunk); } const fullBuffer = Buffer.concat(chunks); // Use first 4100 bytes for file type detection via magic bytes const headerBuffer = fullBuffer.subarray(0, Math.min(4100, fullBuffer.length)); // 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: 'documents.upload.type_detection_failed', userId, documentId, 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: 'documents.upload.type_mismatch', userId, documentId, 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})` }); } const originalName: string = mp.filename || 'upload'; const ext = (() => { const e = path.extname(originalName).replace(/^\./, '').toLowerCase(); if (e) return e; if (contentType === 'application/pdf') return 'pdf'; if (contentType === 'image/jpeg') return 'jpg'; if (contentType === 'image/png') return 'png'; return 'bin'; })(); 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 readable stream from the complete buffer and pipe through counter const fileStream = Readable.from([fullBuffer]); fileStream.pipe(counter); const storage = getStorageService(); const bucket = 'documents'; const version = 'v1'; const unique = cryptoRandom(); const key = `documents/${userId}/${doc.vehicleId}/${doc.id}/${version}/${unique}.${ext}`; await storage.putObject(bucket, key, counter, contentType, { 'x-original-filename': originalName }); const updated = await this.service['repo'].updateStorageMeta(doc.id, userId, { storageBucket: bucket, storageKey: key, fileName: originalName, contentType: contentType, fileSize: counter.bytes, fileHash: null, }); logger.info('Document upload completed', { operation: 'documents.upload.success', userId, documentId, vehicleId: doc.vehicleId, fileName: originalName, contentType, detectedType: detectedType.mime, fileSize: counter.bytes, storageKey: key, }); return reply.code(200).send(updated); } async download(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const documentId = request.params.id; logger.info('Document download requested', { operation: 'documents.download', userId, documentId, }); const doc = await this.service.getDocument(userId, documentId); if (!doc || !doc.storageBucket || !doc.storageKey) { logger.warn('Document or file not found for download', { operation: 'documents.download.not_found', userId, documentId, hasDocument: !!doc, hasStorageInfo: !!(doc?.storageBucket && doc?.storageKey), }); return reply.code(404).send({ error: 'Not Found' }); } const storage = getStorageService(); let head: Partial = {}; try { head = await storage.headObject(doc.storageBucket, doc.storageKey); } catch { /* ignore */ } const contentType = head.contentType || doc.contentType || 'application/octet-stream'; const filename = doc.fileName || path.basename(doc.storageKey); const inlineTypes = new Set(['application/pdf', 'image/jpeg', 'image/png']); const disposition = inlineTypes.has(contentType) ? 'inline' : 'attachment'; reply.header('Content-Type', contentType); reply.header('Content-Disposition', `${disposition}; filename="${encodeURIComponent(filename)}"`); logger.info('Document download initiated', { operation: 'documents.download.success', userId, documentId, vehicleId: doc.vehicleId, fileName: filename, contentType, disposition, fileSize: head.size || doc.fileSize, }); const stream = await storage.getObjectStream(doc.storageBucket, doc.storageKey); return reply.send(stream); } async listByVehicle(request: FastifyRequest<{ Params: { vehicleId: string } }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const vehicleId = request.params.vehicleId; logger.info('Documents by vehicle requested', { operation: 'documents.listByVehicle', userId, vehicleId, }); try { const docs = await this.service.getDocumentsByVehicle(userId, vehicleId); logger.info('Documents by vehicle retrieved', { operation: 'documents.listByVehicle.success', userId, vehicleId, documentCount: docs.length, }); return reply.code(200).send(docs); } catch (e: any) { if (e.statusCode === 403) { logger.warn('Vehicle not found or not owned', { operation: 'documents.listByVehicle.forbidden', userId, vehicleId, }); return reply.code(403).send({ error: 'Forbidden', message: e.message }); } throw e; } } async addVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const { id: documentId, vehicleId } = request.params; logger.info('Add vehicle to document requested', { operation: 'documents.addVehicle', userId, documentId, vehicleId, }); try { const updated = await this.service.addVehicleToDocument(userId, documentId, vehicleId); if (!updated) { logger.warn('Document not updated (possibly duplicate vehicle)', { operation: 'documents.addVehicle.not_updated', userId, documentId, vehicleId, }); return reply.code(400).send({ error: 'Bad Request', message: 'Vehicle could not be added' }); } logger.info('Vehicle added to document', { operation: 'documents.addVehicle.success', userId, documentId, vehicleId, sharedVehicleCount: updated.sharedVehicleIds.length, }); return reply.code(200).send(updated); } catch (e: any) { if (e.statusCode === 404) { logger.warn('Document not found for adding vehicle', { operation: 'documents.addVehicle.not_found', userId, documentId, vehicleId, }); return reply.code(404).send({ error: 'Not Found', message: e.message }); } if (e.statusCode === 400) { logger.warn('Bad request for adding vehicle', { operation: 'documents.addVehicle.bad_request', userId, documentId, vehicleId, reason: e.message, }); return reply.code(400).send({ error: 'Bad Request', message: e.message }); } if (e.statusCode === 403) { logger.warn('Forbidden - vehicle not owned', { operation: 'documents.addVehicle.forbidden', userId, documentId, vehicleId, }); return reply.code(403).send({ error: 'Forbidden', message: e.message }); } throw e; } } async removeVehicle(request: FastifyRequest<{ Params: { id: string; vehicleId: string } }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; const { id: documentId, vehicleId } = request.params; logger.info('Remove vehicle from document requested', { operation: 'documents.removeVehicle', userId, documentId, vehicleId, }); try { const updated = await this.service.removeVehicleFromDocument(userId, documentId, vehicleId); if (!updated) { // Document was soft deleted logger.info('Document soft deleted (primary vehicle removed, no shared vehicles)', { operation: 'documents.removeVehicle.deleted', userId, documentId, vehicleId, }); return reply.code(204).send(); } logger.info('Vehicle removed from document', { operation: 'documents.removeVehicle.success', userId, documentId, vehicleId, sharedVehicleCount: updated.sharedVehicleIds.length, primaryVehicleId: updated.vehicleId, }); return reply.code(200).send(updated); } catch (e: any) { if (e.statusCode === 404) { logger.warn('Document not found for removing vehicle', { operation: 'documents.removeVehicle.not_found', userId, documentId, vehicleId, }); return reply.code(404).send({ error: 'Not Found', message: e.message }); } if (e.statusCode === 400) { logger.warn('Bad request for removing vehicle', { operation: 'documents.removeVehicle.bad_request', userId, documentId, vehicleId, reason: e.message, }); return reply.code(400).send({ error: 'Bad Request', message: e.message }); } throw e; } } } function cryptoRandom(): string { // Cryptographically secure random suffix for object keys return crypto.randomBytes(32).toString('hex'); }