import { BadRequestException, Injectable, NotFoundException, } from "@nestjs/common"; import { randomUUID } from "crypto"; import { Readable } from "stream"; import { MinioService } from "../minio/minio.service"; import { FilesRepository } from "./files.repository"; import { FileRecord, FileReviewStatus } from "./entities/file.entity"; export interface CreateFileInput { resourceId: string; resource: string; code: string; file: Express.Multer.File; /** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */ title?: string | null; visibleToCustomer?: boolean; uploadedByUserId?: string | null; uploadedByName?: string | null; } /** * Make a filename safe to use as a MinIO object-key segment: collapse runs of * spaces/unsafe characters to a single underscore while keeping the dot before * the extension. Prevents percent-encoding mismatches between the stored URL * and the actual object key. */ function sanitizeObjectName(name: string): string { return name .normalize("NFKD") .replace(/[^\w.\-]+/g, "_") .replace(/_{2,}/g, "_") .replace(/^_+|_+$/g, ""); } @Injectable() export class FilesService { // Defense-in-depth for ANY caller of upload() (not just the driver-docs // route). This is deliberately BROADER than the driver controller's strict // images+pdf Multer filter, because the same method also stores generated // PDFs, PNG signatures, and customer/customs booking documents (scans, office // docs). It rejects the actual attack surface (executables/scripts/HTML) while // permitting every business-document type these flows legitimately upload. // No file-upload-settings row governs raw byte size, so the cap is a sane, // generous default that won't reject large scanned documents. private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024; private static readonly ALLOWED_UPLOAD_MIME = new Set([ "image/jpeg", "image/png", "image/webp", "image/gif", "image/heic", "image/tiff", "application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "text/csv", "text/plain", ]); constructor( private readonly filesRepository: FilesRepository, private readonly minioService: MinioService, ) {} async upload(input: CreateFileInput): Promise { const { resourceId, resource, code, file } = input; if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) { throw new BadRequestException(`Unsupported file type: ${file.mimetype}`); } if (file.size > FilesService.MAX_UPLOAD_BYTES) { throw new BadRequestException( `File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`, ); } // Keep the object key URL-safe so it survives the round-trip through the // stored URL (spaces/unicode in the original name would otherwise be // percent-encoded in the URL and no longer match the MinIO key). The // human-readable name is preserved separately on the record below. const safeName = sanitizeObjectName(file.originalname); // The random segment is load-bearing, not decoration. `Date.now()` alone is // NOT unique across a batch: callers upload with Promise.all, every callback // runs to its first await in the same tick, so they all read the same // millisecond. Two files with one name in one batch — e.g. pasting two // screenshots, which browsers both call "image.png" — would build identical // keys, and the second putObject would overwrite the first while both rows // persisted pointing at the same object. const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`; const url = await this.minioService.uploadFile( objectName, file.buffer, file.mimetype, ); return this.filesRepository.create({ resourceId, resource, code, name: file.originalname, url, size: file.size, mimeType: file.mimetype, title: input.title ?? null, visibleToCustomer: input.visibleToCustomer ?? false, uploadedByUserId: input.uploadedByUserId ?? null, uploadedByName: input.uploadedByName ?? null, }); } /** * Edit the uploader-authored metadata of a stored file (title, customer * visibility). Bytes are untouched — callers replacing content upload a new * record instead. */ async updateMeta( id: string, patch: { title?: string; visibleToCustomer?: boolean }, ): Promise { const updated = await this.filesRepository.update(id, patch); if (!updated) throw new NotFoundException(`File ${id} not found`); return updated; } /** * Replace the file stored under a resource + code (e.g. contract PDF). The * previous version is retired, not destroyed — pass `replacedBy` to record who * swapped it and why, which is what the version history shows. */ async upsertByCode( input: CreateFileInput, replacedBy?: { userId?: string | null; reason?: string | null }, ): Promise { const { resourceId, resource, code } = input; await this.filesRepository.deleteByCode( resourceId, resource, code, replacedBy, ); return this.upload(input); } /** * Every stored version of one document, newest first. `isCurrent` marks the * live row; the rest are superseded uploads kept for audit. */ async versionHistory( resourceId: string, resource: string, code: string, ): Promise< Array<{ id: string; name: string; url: string; size: number; mimeType: string; uploadedAt: string; isCurrent: boolean; replacedAt: string | null; replacedByUserId: string | null; replaceReason: string | null; }> > { const rows = await this.filesRepository.findVersionHistory( resourceId, resource, code, ); return rows.map((row) => ({ id: row.id, name: row.name, url: row.url, size: row.size, mimeType: row.mimeType, uploadedAt: row.createdAt.toISOString(), isCurrent: row.deletedAt == null, replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null, replacedByUserId: row.replacedByUserId, replaceReason: row.replaceReason, })); } async deleteByCode( resourceId: string, resource: string, code: string, ): Promise { await this.filesRepository.deleteByCode(resourceId, resource, code); } async uploadMany( resourceId: string, resource: string, files: Express.Multer.File[], ): Promise { return Promise.all( files.map((file) => this.upload({ resourceId, resource, code: file.fieldname, file }), ), ); } /** * Attach already-stored files (e.g. a company profile's onboarding documents) * to a resource by reference — creates FileRecord rows pointing at the existing * object-storage URLs, without re-uploading bytes. The snapshot is fixed at call * time, so later changes to the source documents never alter what was attached. */ async attachExistingFiles( resourceId: string, resource: string, files: Array<{ code: string; name: string; url: string; size: number; mimeType?: string; }>, ): Promise { return Promise.all( files.map((f) => this.filesRepository.create({ resourceId, resource, code: f.code, name: f.name, url: f.url, size: f.size, mimeType: f.mimeType ?? "application/octet-stream", }), ), ); } async findById(id: string): Promise { const record = await this.filesRepository.findById(id); if (!record) throw new NotFoundException(`File ${id} not found`); return record; } /** * Record a reviewer verdict on one document. `change_requested` keeps the note * (the customer sees it verbatim); any other verdict clears it, so a stale * reason can never outlive the request it explained. */ async setReviewStatus( id: string, status: FileReviewStatus, note: string | null, reviewerId?: string, ): Promise { const record = await this.findById(id); const updated = await this.filesRepository.update(record.id, { reviewStatus: status, reviewNote: status === "change_requested" ? (note ?? null) : null, reviewedBy: reviewerId ?? null, reviewedAt: new Date(), }); if (!updated) throw new NotFoundException(`File ${id} not found`); return updated; } /** * Drop any reviewer verdict from a document, returning it to "not reviewed". * Called when a customer re-uploads: the new bytes have not been looked at, so * carrying the old `change_requested` forward would keep them blocked forever. */ async clearReview(id: string): Promise { await this.filesRepository.update(id, { reviewStatus: null, reviewNote: null, reviewedBy: null, reviewedAt: null, }); } /** Documents across these resources still awaiting a customer correction. */ findWithOpenChangeRequest( resourceIds: string[], resource: string, ): Promise { return this.filesRepository.findWithOpenChangeRequest( resourceIds, resource, ); } /** Soft-delete a stored file row by id (object bytes are left in MinIO). */ async remove(id: string): Promise { await this.filesRepository.softDelete(id); } /** * Re-slot a stored file under a new `code` (e.g. promote a staged * `business_license_pending` file to the live `business_license` code once a * change request is approved). Bytes and URL are untouched. */ async setCode(id: string, code: string): Promise { await this.filesRepository.update(id, { code }); } findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } /** * Files for many resources of one kind, grouped by resource id. Resources with * no files are absent from the map (callers should default to `[]`). */ async findByResourceIdsGrouped( resourceIds: string[], resource: string, ): Promise> { const records = await this.filesRepository.findByResourceIds( resourceIds, resource, ); const grouped = new Map(); for (const record of records) { const bucket = grouped.get(record.resourceId); if (bucket) bucket.push(record); else grouped.set(record.resourceId, [record]); } return grouped; } /** * Short-lived signed URL for a stored file's raw MinIO URL. The persisted * `url` is an un-signed object path that a browser cannot fetch directly; * callers that expose files for preview/download must sign them first. */ async signUrl(rawUrl: string, expirySeconds = 300): Promise { const objectName = this.minioService.getObjectNameFromUrl(rawUrl); return this.minioService.getSignedUrl(objectName, expirySeconds); } async findByCode( resourceId: string, resource: string, code: string, ): Promise { const record = await this.filesRepository.findByCode( resourceId, resource, code, ); if (!record) throw new NotFoundException( `File with code "${code}" not found for ${resource} ${resourceId}`, ); return record; } async streamById( id: string, ): Promise<{ stream: Readable; record: FileRecord }> { const record = await this.findById(id); const objectName = this.minioService.getObjectNameFromUrl(record.url); const stream = await this.minioService.getFileStream(objectName); return { stream, record }; } }