Files
motovaultpro/backend/src/features/documents/api/documents.controller.ts
egullickson ec8e6ee5d2
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m43s
Deploy to Staging / Deploy to Staging (push) Successful in 38s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Merge pull request 'feat: Document feature enhancements (#31)' (#32) from issue-31-document-enhancements into main
Reviewed-on: #32
2026-01-15 02:35:55 +00:00

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