Added Documents Feature
This commit is contained in:
325
backend/src/features/documents/api/documents.controller.ts
Normal file
325
backend/src/features/documents/api/documents.controller.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
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 { appConfig } from '../../../core/config/config-loader';
|
||||
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 = (doc.storage_bucket || appConfig.getMinioConfig().bucket);
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user