Files
motovaultpro/backend/src/features/documents/api/documents.controller.ts
Eric Gullickson 046c66fc7d Redesign
2025-11-01 21:27:42 -05:00

325 lines
11 KiB
TypeScript

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<import('../../../core/storage/storage.service').HeadObjectResult> = {};
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);
}