import { Injectable, NotFoundException } from "@nestjs/common"; import { Readable } from "stream"; import { MinioService } from "../minio/minio.service"; import { FilesRepository } from "./files.repository"; import { FileRecord } from "./entities/file.entity"; export interface CreateFileInput { resourceId: string; resource: string; code: string; file: Express.Multer.File; } /** * 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 { constructor( private readonly filesRepository: FilesRepository, private readonly minioService: MinioService, ) {} async upload(input: CreateFileInput): Promise { const { resourceId, resource, code, file } = input; // 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); const objectName = `${resource}/${resourceId}/${Date.now()}_${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, }); } /** Replace existing file row for the same resource + code (e.g. contract PDF). */ async upsertByCode(input: CreateFileInput): Promise { const { resourceId, resource, code } = input; await this.filesRepository.deleteByCode(resourceId, resource, code); return this.upload(input); } 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; } findByResource(resourceId: string, resource: string): Promise { return this.filesRepository.findByResource(resourceId, resource); } 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 }; } }