feat(backend): Dokumente und Bilder mit lokalem Datei-Upload (Modul 29)

DocumentsModule (Document: entityType/entityId, Dateiname, MIME-Typ,
Groesse, Speicherpfad, Hochgeladen-von) fuer Bienenstand/Volk/Koenigin/
Durchsicht/Honigcharge. Upload als multipart/form-data (Multer, 10 MB
Limit, MIME-Allowlist jpeg/png/webp/pdf), Speicherung unter
DOCUMENTS_STORAGE_PATH (austauschbar). ARCHITECTURE.md Annahme 7
dokumentiert die Multi-Replica-Einschraenkung ohne geteiltes Volume.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:29:22 +02:00
1 parent e4d5af5fa7
commit 4a9fdd5c71
16 files changed
+728

No files matched your search

@@ -0,0 +1,153 @@
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 { Document, DocumentEntityType } from '../../generated/prisma';
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
export type DocumentSummary = Omit<Document, 'storagePath'>;
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<DocumentSummary[]> {
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<void> {
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<DocumentSummary> {
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
}
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,
};
}
async delete(id: string, actingUserId: string): Promise<void> {
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),
});
}
}