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,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);
}
}