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
Reviewed-on: #32
589 lines
19 KiB
TypeScript
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');
|
|
}
|