import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { matchesFileSignature } from '../common/file-signature'; import { Document, DocumentEntityType } from '../../generated/prisma'; import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types'; import { UpdateDocumentDto } from './dto/update-document.dto'; export type DocumentSummary = Omit; function storageRoot(): string { return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads'; } function toSummary(document: Document): DocumentSummary { return { id: document.id, entityType: document.entityType, entityId: document.entityId, fileName: document.fileName, mimeType: document.mimeType, sizeBytes: document.sizeBytes, uploadedByUserId: document.uploadedByUserId, createdAt: document.createdAt, }; } @Injectable() export class DocumentsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, ) {} async findAll( entityType: DocumentEntityType, entityId: string, ): Promise { const documents = await this.prisma.document.findMany({ where: { entityType, entityId }, orderBy: { createdAt: 'desc' }, }); return documents.map(toSummary); } private async assertEntityExists( entityType: DocumentEntityType, entityId: string, ): Promise { const exists = await (() => { switch (entityType) { case 'APIARY': return this.prisma.apiary.findUnique({ where: { id: entityId } }); case 'COLONY': return this.prisma.colony.findUnique({ where: { id: entityId } }); case 'QUEEN': return this.prisma.queen.findUnique({ where: { id: entityId } }); case 'INSPECTION': return this.prisma.inspection.findUnique({ where: { id: entityId }, }); case 'HONEY_BATCH': return this.prisma.honeyBatch.findUnique({ where: { id: entityId }, }); default: return Promise.resolve(null); } })(); if (!exists) { throw new NotFoundException(`${entityType} ${entityId} not found`); } } async create( file: Express.Multer.File, entityType: DocumentEntityType, entityId: string, actingUserId: string, ): Promise { if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) { throw new BadRequestException('Nicht unterstützter Dateityp'); } if (!matchesFileSignature(file.buffer, file.mimetype)) { throw new BadRequestException( 'Datei entspricht nicht dem angegebenen Format', ); } await this.assertEntityExists(entityType, entityId); const extension = ALLOWED_DOCUMENT_MIME_TYPES[file.mimetype]; const storageFileName = `${crypto.randomUUID()}${extension}`; const directory = path.join(storageRoot(), entityType.toLowerCase()); await fs.mkdir(directory, { recursive: true }); const storagePath = path.join(directory, storageFileName); await fs.writeFile(storagePath, file.buffer); const document = await this.prisma.document.create({ data: { entityType, entityId, fileName: file.originalname, mimeType: file.mimetype, sizeBytes: file.size, storagePath, uploadedByUserId: actingUserId, }, }); await this.auditLog.record({ entityType: 'Document', entityId: document.id, action: 'CREATE', userId: actingUserId, afterData: toSummary(document), }); return toSummary(document); } async readFile( id: string, ): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> { const document = await this.prisma.document.findUnique({ where: { id }, }); if (!document) { throw new NotFoundException('Document not found'); } const buffer = await fs.readFile(document.storagePath); return { buffer, fileName: document.fileName, mimeType: document.mimeType, }; } /** * Re-links a document to a different record or corrects its display name. * The stored file is untouched - replacing the content means uploading a * new document. */ async update( id: string, dto: UpdateDocumentDto, actingUserId: string, ): Promise { const before = await this.prisma.document.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('Document not found'); } const entityType = dto.entityType ?? before.entityType; const entityId = dto.entityId ?? before.entityId; if (entityType !== before.entityType || entityId !== before.entityId) { await this.assertEntityExists(entityType, entityId); } const document = await this.prisma.document.update({ where: { id }, data: { entityType, entityId, fileName: dto.fileName }, }); await this.auditLog.record({ entityType: 'Document', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: toSummary(before), afterData: toSummary(document), }); return toSummary(document); } async delete(id: string, actingUserId: string): Promise { const document = await this.prisma.document.findUnique({ where: { id }, }); if (!document) { throw new NotFoundException('Document not found'); } await this.prisma.document.delete({ where: { id } }); await fs.unlink(document.storagePath).catch(() => undefined); await this.auditLog.record({ entityType: 'Document', entityId: id, action: 'DELETE', userId: actingUserId, beforeData: toSummary(document), }); } }