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'; 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', user_id: userId, filters: { vehicle_id: request.query.vehicleId, type: request.query.type, expires_before: 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', user_id: userId, document_count: 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', user_id: userId, document_id: documentId, }); const doc = await this.service.getDocument(userId, documentId); if (!doc) { logger.warn('Document not found', { operation: 'documents.get.not_found', user_id: userId, document_id: documentId, }); return reply.code(404).send({ error: 'Not Found' }); } logger.info('Document retrieved', { operation: 'documents.get.success', user_id: userId, document_id: documentId, vehicle_id: doc.vehicle_id, document_type: doc.document_type, }); return reply.code(200).send(doc); } async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) { const userId = (request as any).user?.sub as string; logger.info('Document create requested', { operation: 'documents.create', user_id: userId, vehicle_id: request.body.vehicle_id, document_type: request.body.document_type, title: request.body.title, }); const created = await this.service.createDocument(userId, request.body); logger.info('Document created', { operation: 'documents.create.success', user_id: userId, document_id: created.id, vehicle_id: created.vehicle_id, document_type: created.document_type, 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 documentId = request.params.id; logger.info('Document update requested', { operation: 'documents.update', user_id: userId, document_id: documentId, update_fields: Object.keys(request.body), }); const updated = await this.service.updateDocument(userId, documentId, request.body); if (!updated) { logger.warn('Document not found for update', { operation: 'documents.update.not_found', user_id: userId, document_id: documentId, }); return reply.code(404).send({ error: 'Not Found' }); } logger.info('Document updated', { operation: 'documents.update.success', user_id: userId, document_id: documentId, vehicle_id: updated.vehicle_id, 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', user_id: userId, document_id: documentId, }); // If object exists, delete it from storage first const existing = await this.service.getDocument(userId, documentId); if (existing && existing.storage_bucket && existing.storage_key) { const storage = getStorageService(); try { await storage.deleteObject(existing.storage_bucket, existing.storage_key); logger.info('Document file deleted from storage', { operation: 'documents.delete.storage_cleanup', user_id: userId, document_id: documentId, storage_key: existing.storage_key, }); } catch (e) { logger.warn('Failed to delete document file from storage', { operation: 'documents.delete.storage_cleanup_failed', user_id: userId, document_id: documentId, storage_key: existing.storage_key, 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', user_id: userId, document_id: documentId, vehicle_id: existing?.vehicle_id, had_file: !!(existing?.storage_key), }); 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', user_id: userId, document_id: documentId, }); const doc = await this.service.getDocument(userId, documentId); if (!doc) { logger.warn('Document not found for upload', { operation: 'documents.upload.not_found', user_id: userId, document_id: 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', user_id: userId, document_id: documentId, }); return reply.code(400).send({ error: 'Bad Request', message: 'No file provided' }); } const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']); const contentType = mp.mimetype as string | undefined; if (!contentType || !allowed.has(contentType)) { logger.warn('Unsupported file type for upload', { operation: 'documents.upload.unsupported_type', user_id: userId, document_id: documentId, content_type: contentType, file_name: mp.filename, }); return reply.code(415).send({ error: 'Unsupported Media Type' }); } 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(); mp.file.pipe(counter); const storage = getStorageService(); const bucket = 'documents'; // Filesystem storage ignores bucket, but keep for interface compatibility const version = 'v1'; const unique = cryptoRandom(); const key = `documents/${userId}/${doc.vehicle_id}/${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, { storage_bucket: bucket, storage_key: key, file_name: originalName, content_type: contentType, file_size: counter.bytes, file_hash: null, }); logger.info('Document upload completed', { operation: 'documents.upload.success', user_id: userId, document_id: documentId, vehicle_id: doc.vehicle_id, file_name: originalName, content_type: contentType, file_size: counter.bytes, storage_key: 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', user_id: userId, document_id: documentId, }); const doc = await this.service.getDocument(userId, documentId); if (!doc || !doc.storage_bucket || !doc.storage_key) { logger.warn('Document or file not found for download', { operation: 'documents.download.not_found', user_id: userId, document_id: documentId, has_document: !!doc, has_storage_info: !!(doc?.storage_bucket && doc?.storage_key), }); return reply.code(404).send({ error: 'Not Found' }); } const storage = getStorageService(); let head: Partial = {}; try { head = await storage.headObject(doc.storage_bucket, doc.storage_key); } catch { /* ignore */ } const contentType = head.contentType || doc.content_type || 'application/octet-stream'; const filename = doc.file_name || path.basename(doc.storage_key); 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', user_id: userId, document_id: documentId, vehicle_id: doc.vehicle_id, file_name: filename, content_type: contentType, disposition: disposition, file_size: head.size || doc.file_size, }); const stream = await storage.getObjectStream(doc.storage_bucket, doc.storage_key); return reply.send(stream); } } function cryptoRandom(): string { // Safe unique suffix for object keys return Math.random().toString(36).slice(2) + Date.now().toString(36); }