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:
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user