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

+2
View File
@@ -29,6 +29,7 @@ import { CalendarModule } from './calendar/calendar.module';
import { NotificationsModule } from './notifications/notifications.module';
import { StatisticsModule } from './statistics/statistics.module';
import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -63,6 +64,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
NotificationsModule,
StatisticsModule,
WebhooksModule,
DocumentsModule,
],
controllers: [],
providers: [
@@ -0,0 +1,10 @@
export const ALLOWED_DOCUMENT_MIME_TYPES: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf',
};
export function isAllowedDocumentMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_DOCUMENT_MIME_TYPES;
}
@@ -0,0 +1,111 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { DocumentEntityType, Role } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { isAllowedDocumentMimeType } from './document-mime-types';
import { CreateDocumentDto } from './dto/create-document.dto';
import { DocumentsService } from './documents.service';
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
function sanitizeFilenameHeader(fileName: string): string {
return fileName.replace(/[\r\n"]/g, '_');
}
@ApiTags('documents')
@ApiBearerAuth()
@Controller('documents')
export class DocumentsController {
constructor(private readonly documentsService: DocumentsService) {}
@Get()
findAll(
@Query('entityType') entityType: string,
@Query('entityId') entityId: string,
) {
if (!entityType || !entityId) {
throw new BadRequestException('entityType and entityId are required');
}
if (
!Object.values(DocumentEntityType).includes(
entityType as DocumentEntityType,
)
) {
throw new BadRequestException('Invalid entityType');
}
return this.documentsService.findAll(
entityType as DocumentEntityType,
entityId,
);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post()
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE_BYTES },
fileFilter: (_req, file, callback) => {
if (!isAllowedDocumentMimeType(file.mimetype)) {
callback(
new BadRequestException('Nicht unterstützter Dateityp'),
false,
);
return;
}
callback(null, true);
},
}),
)
create(
@UploadedFile() file: Express.Multer.File | undefined,
@Body() dto: CreateDocumentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.documentsService.create(
file,
dto.entityType,
dto.entityId,
actor.id,
);
}
@Get(':id/download')
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { buffer, fileName, mimeType } =
await this.documentsService.readFile(id);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${sanitizeFilenameHeader(fileName)}"`,
);
res.send(buffer);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Delete(':id')
@HttpCode(204)
delete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.documentsService.delete(id, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DocumentsController } from './documents.controller';
import { DocumentsService } from './documents.service';
@Module({
controllers: [DocumentsController],
providers: [DocumentsService],
})
export class DocumentsModule {}
@@ -0,0 +1,170 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import * as fs from 'node:fs/promises';
import { DocumentsService } from './documents.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
jest.mock('node:fs/promises');
describe('DocumentsService', () => {
let service: DocumentsService;
let prisma: {
document: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
delete: jest.Mock;
};
apiary: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock };
queen: { findUnique: jest.Mock };
inspection: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
};
let auditLog: { record: jest.Mock };
const document = {
id: 'doc-1',
entityType: 'COLONY',
entityId: 'colony-1',
fileName: 'bild.jpg',
mimeType: 'image/jpeg',
sizeBytes: 1234,
storagePath: '/tmp/uploads/colony/abc.jpg',
uploadedByUserId: 'user-1',
createdAt: new Date('2026-01-01'),
};
beforeEach(() => {
prisma = {
document: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
apiary: { findUnique: jest.fn() },
colony: { findUnique: jest.fn() },
queen: { findUnique: jest.fn() },
inspection: { findUnique: jest.fn() },
honeyBatch: { findUnique: jest.fn() },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new DocumentsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.readFile as jest.Mock).mockResolvedValue(Buffer.from('data'));
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
it('findAll strips storagePath from results', async () => {
prisma.document.findMany.mockResolvedValueOnce([document]);
const result = await service.findAll('COLONY', 'colony-1');
expect(result).toEqual([
{
id: 'doc-1',
entityType: 'COLONY',
entityId: 'colony-1',
fileName: 'bild.jpg',
mimeType: 'image/jpeg',
sizeBytes: 1234,
uploadedByUserId: 'user-1',
createdAt: document.createdAt,
},
]);
});
describe('create', () => {
const file = {
buffer: Buffer.from('binary'),
originalname: 'bild.jpg',
mimetype: 'image/jpeg',
size: 1234,
} as Express.Multer.File;
it('rejects an unsupported mime type', async () => {
const badFile = {
...file,
mimetype: 'application/zip',
};
await expect(
service.create(badFile, 'COLONY', 'colony-1', 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('throws NotFoundException when the referenced entity does not exist', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
service.create(file, 'COLONY', 'missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('writes the file to disk and persists a document row', async () => {
prisma.colony.findUnique.mockResolvedValue({ id: 'colony-1' });
prisma.document.create.mockResolvedValue(document);
const result = await service.create(file, 'COLONY', 'colony-1', 'user-1');
expect(fs.mkdir).toHaveBeenCalled();
expect(fs.writeFile).toHaveBeenCalled();
const [[createArg]] = prisma.document.create.mock.calls as [
[
{
data: {
entityType: string;
entityId: string;
fileName: string;
uploadedByUserId: string;
};
},
],
];
expect(createArg.data.entityType).toBe('COLONY');
expect(createArg.data.entityId).toBe('colony-1');
expect(createArg.data.fileName).toBe('bild.jpg');
expect(createArg.data.uploadedByUserId).toBe('user-1');
expect(result).not.toHaveProperty('storagePath');
});
});
describe('readFile', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('reads the stored file from disk', async () => {
prisma.document.findUnique.mockResolvedValue(document);
const result = await service.readFile('doc-1');
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
expect(result.fileName).toBe('bild.jpg');
expect(result.mimeType).toBe('image/jpeg');
});
});
describe('delete', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.delete('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('deletes the db row and unlinks the file', async () => {
prisma.document.findUnique.mockResolvedValue(document);
await service.delete('doc-1', 'user-1');
expect(prisma.document.delete).toHaveBeenCalledWith({
where: { id: 'doc-1' },
});
expect(fs.unlink).toHaveBeenCalledWith(document.storagePath);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Document', action: 'DELETE' }),
);
});
});
});
@@ -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),
});
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { DocumentEntityType } from '../../../generated/prisma';
export class CreateDocumentDto {
@ApiProperty({ enum: DocumentEntityType })
@IsEnum(DocumentEntityType)
entityType!: DocumentEntityType;
@ApiProperty()
@IsString()
@IsNotEmpty()
entityId!: string;
}