From 4a9fdd5c71947522ae1f2851ee77ce5a61779ad8 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 23 Aug 2026 11:29:22 +0200 Subject: [PATCH] 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. --- ARCHITECTURE.md | 1 + ApiCore-Backend/.env.example | 5 + ApiCore-Backend/.gitignore | 4 + ApiCore-Backend/package-lock.json | 11 ++ ApiCore-Backend/package.json | 1 + .../20260823091941_documents/migration.sql | 23 +++ ApiCore-Backend/prisma/schema.prisma | 28 +++ ApiCore-Backend/src/app.module.ts | 2 + .../src/documents/document-mime-types.ts | 10 + .../src/documents/documents.controller.ts | 111 +++++++++++ .../src/documents/documents.module.ts | 9 + .../src/documents/documents.service.spec.ts | 170 ++++++++++++++++ .../src/documents/documents.service.ts | 153 +++++++++++++++ .../src/documents/dto/create-document.dto.ts | 14 ++ ApiCore-Backend/test/documents.e2e-spec.ts | 185 ++++++++++++++++++ deploy/k8s/backend/configmap.example.yaml | 1 + 16 files changed, 728 insertions(+) create mode 100644 ApiCore-Backend/prisma/migrations/20260823091941_documents/migration.sql create mode 100644 ApiCore-Backend/src/documents/document-mime-types.ts create mode 100644 ApiCore-Backend/src/documents/documents.controller.ts create mode 100644 ApiCore-Backend/src/documents/documents.module.ts create mode 100644 ApiCore-Backend/src/documents/documents.service.spec.ts create mode 100644 ApiCore-Backend/src/documents/documents.service.ts create mode 100644 ApiCore-Backend/src/documents/dto/create-document.dto.ts create mode 100644 ApiCore-Backend/test/documents.e2e-spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 363cc77..cf916f2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -95,6 +95,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige 4. **API-Client-Rolle im MVP**: kein vollwertiger OAuth2/Client-Credentials-Server. Ein API-Client ist ein `User` mit Rolle `API_CLIENT` und einem langlebigen Service-Refresh-Token; vollwertiges Scopes-/Client-Management ist SOLL/KANN. 5. **Backup-fähige Struktur**: kein eigenes Backup-Tooling im Code. Umgesetzt als: reproduzierbare Prisma-Migrationen (kein manuelles Schema-Drift), dokumentierte `pg_dump`/PITR-Strategie in diesem Dokument (Betrieb obliegt dem bestehenden Cluster/DBA), `/health`-Endpoint für Betriebsüberwachung. 6. **Deviations vom Leitentscheidungs-Rahmen**: bisher keine. Wird ergänzt, falls während der Umsetzung ein konkreter Grund für eine Abweichung von NestJS/Prisma/Next.js/Tailwind/shadcn/TanStack Query auftritt. +7. **Dokumentenspeicher als lokales Volume (Modul 29)**: `DocumentsModule` speichert Uploads unterhalb von `DOCUMENTS_STORAGE_PATH` auf dem lokalen Dateisystem des Backend-Pods, keine Objektspeicher-Anbindung (S3-kompatibel o.ä.) im MVP/v1.x. Der Pfad ist über die ConfigMap austauschbar, wie in Abschnitt 27 des Lastenhefts vorgesehen. Wichtig für den Betrieb: das aktuelle `deploy/k8s`-Deployment läuft mit `replicas: 2` ohne geteiltes Volume – für konsistente Uploads über beide Pods hinweg muss vor Produktivbetrieb mit Dokumenten-Feature ein `ReadWriteMany`-PVC (z.B. NFS- oder EFS-basiert) gemountet und `DOCUMENTS_STORAGE_PATH` darauf gesetzt werden, sonst sieht nur der Pod, der die Datei entgegengenommen hat, sie auch beim Download wieder. ## 7. Kubernetes diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index 887da42..5ec1133 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -9,3 +9,8 @@ JWT_REFRESH_EXPIRES_IN="7d" PORT=3001 FRONTEND_ORIGIN="http://localhost:3000" + +# Lokales Verzeichnis fuer hochgeladene Dokumente/Bilder (Modul 29). +# Bei horizontaler Skalierung (>1 Replica) muss dies auf ein geteiltes +# Volume (z.B. ReadWriteMany-PVC) zeigen, siehe ARCHITECTURE.md Annahme 7. +DOCUMENTS_STORAGE_PATH="./uploads" diff --git a/ApiCore-Backend/.gitignore b/ApiCore-Backend/.gitignore index 5663bbb..1d398db 100644 --- a/ApiCore-Backend/.gitignore +++ b/ApiCore-Backend/.gitignore @@ -14,3 +14,7 @@ prisma/seed.d.ts # Local dev/test PostgreSQL data dir (not for production use) .devdb/ + +# Locally uploaded documents/images (Modul 29) and their test-run counterpart +/uploads/ +/test-uploads/ diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index ddf6d5f..b1cac13 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -40,6 +40,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.2.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.6", @@ -3543,6 +3544,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index 3842f9a..bd08671 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -52,6 +52,7 @@ "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.2.0", "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/pdfkit": "^0.17.6", diff --git a/ApiCore-Backend/prisma/migrations/20260823091941_documents/migration.sql b/ApiCore-Backend/prisma/migrations/20260823091941_documents/migration.sql new file mode 100644 index 0000000..893197f --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260823091941_documents/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "DocumentEntityType" AS ENUM ('APIARY', 'COLONY', 'QUEEN', 'INSPECTION', 'HONEY_BATCH'); + +-- CreateTable +CREATE TABLE "Document" ( + "id" TEXT NOT NULL, + "entityType" "DocumentEntityType" NOT NULL, + "entityId" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "sizeBytes" INTEGER NOT NULL, + "storagePath" TEXT NOT NULL, + "uploadedByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Document_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Document_entityType_entityId_idx" ON "Document"("entityType", "entityId"); + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 4b11de7..f142f5c 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -53,6 +53,7 @@ model User { notifications Notification[] queenEvaluations QueenEvaluation[] webhookSubscriptions WebhookSubscription[] + documents Document[] @@index([role]) } @@ -773,3 +774,30 @@ model WebhookSubscription { @@index([active]) } + +// --------------------------------------------------------------------------- +// Dokumente und Bilder (Abschnitt 4.28) – v1.x Modul 29 +// --------------------------------------------------------------------------- + +enum DocumentEntityType { + APIARY + COLONY + QUEEN + INSPECTION + HONEY_BATCH +} + +model Document { + id String @id @default(cuid()) + entityType DocumentEntityType + entityId String + fileName String + mimeType String + sizeBytes Int + storagePath String + uploadedByUserId String? + uploadedByUser User? @relation(fields: [uploadedByUserId], references: [id]) + createdAt DateTime @default(now()) + + @@index([entityType, entityId]) +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index aa847e9..6ecd1db 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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: [ diff --git a/ApiCore-Backend/src/documents/document-mime-types.ts b/ApiCore-Backend/src/documents/document-mime-types.ts new file mode 100644 index 0000000..247417a --- /dev/null +++ b/ApiCore-Backend/src/documents/document-mime-types.ts @@ -0,0 +1,10 @@ +export const ALLOWED_DOCUMENT_MIME_TYPES: Record = { + '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; +} diff --git a/ApiCore-Backend/src/documents/documents.controller.ts b/ApiCore-Backend/src/documents/documents.controller.ts new file mode 100644 index 0000000..cca5d61 --- /dev/null +++ b/ApiCore-Backend/src/documents/documents.controller.ts @@ -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 { + 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); + } +} diff --git a/ApiCore-Backend/src/documents/documents.module.ts b/ApiCore-Backend/src/documents/documents.module.ts new file mode 100644 index 0000000..8849227 --- /dev/null +++ b/ApiCore-Backend/src/documents/documents.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/documents/documents.service.spec.ts b/ApiCore-Backend/src/documents/documents.service.spec.ts new file mode 100644 index 0000000..a64ff0a --- /dev/null +++ b/ApiCore-Backend/src/documents/documents.service.spec.ts @@ -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' }), + ); + }); + }); +}); diff --git a/ApiCore-Backend/src/documents/documents.service.ts b/ApiCore-Backend/src/documents/documents.service.ts new file mode 100644 index 0000000..bb71f55 --- /dev/null +++ b/ApiCore-Backend/src/documents/documents.service.ts @@ -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; + +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'); + } + 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 { + 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), + }); + } +} diff --git a/ApiCore-Backend/src/documents/dto/create-document.dto.ts b/ApiCore-Backend/src/documents/dto/create-document.dto.ts new file mode 100644 index 0000000..1e5e182 --- /dev/null +++ b/ApiCore-Backend/src/documents/dto/create-document.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/test/documents.e2e-spec.ts b/ApiCore-Backend/test/documents.e2e-spec.ts new file mode 100644 index 0000000..8059061 --- /dev/null +++ b/ApiCore-Backend/test/documents.e2e-spec.ts @@ -0,0 +1,185 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import * as fs from 'node:fs/promises'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface DocumentBody { + id: string; + entityType: string; + entityId: string; + fileName: string; + mimeType: string; + sizeBytes: number; + storagePath?: string; +} + +describe('Documents (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `documents-e2e-beekeeper-${Date.now()}@example.com`; + const readonlyEmail = `documents-e2e-readonly-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let readonlyId: string; + let beekeeperToken: string; + let readonlyToken: string; + let apiaryId: string; + let colonyId: string; + let documentId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Documents E2E Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Documents E2E Readonly', + role: 'READONLY', + }, + }); + readonlyId = readonly.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + readonlyToken = (await login(app, readonlyEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Dokumente-Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-DOC-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + }); + + afterAll(async () => { + await prisma.document.deleteMany({ + where: { entityType: 'COLONY', entityId: colonyId }, + }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, readonlyId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, readonlyId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, readonlyId] } }, + }); + await fs.rm('./test-uploads', { recursive: true, force: true }); + await app.close(); + }); + + it('rejects an upload from a role without write access', () => { + return request(app.getHttpServer()) + .post('/api/v1/documents') + .set('Authorization', `Bearer ${readonlyToken}`) + .field('entityType', 'COLONY') + .field('entityId', colonyId) + .attach('file', Buffer.from('fake image bytes'), { + filename: 'bild.png', + contentType: 'image/png', + }) + .expect(403); + }); + + it('rejects an unsupported mime type', () => { + return request(app.getHttpServer()) + .post('/api/v1/documents') + .set('Authorization', `Bearer ${beekeeperToken}`) + .field('entityType', 'COLONY') + .field('entityId', colonyId) + .attach('file', Buffer.from('not an image'), { + filename: 'script.exe', + contentType: 'application/octet-stream', + }) + .expect(400); + }); + + it('rejects an upload for a non-existent entity', () => { + return request(app.getHttpServer()) + .post('/api/v1/documents') + .set('Authorization', `Bearer ${beekeeperToken}`) + .field('entityType', 'COLONY') + .field('entityId', 'does-not-exist') + .attach('file', Buffer.from('fake image bytes'), { + filename: 'bild.png', + contentType: 'image/png', + }) + .expect(404); + }); + + it('lets a beekeeper upload a document and omits storagePath from the response', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/documents') + .set('Authorization', `Bearer ${beekeeperToken}`) + .field('entityType', 'COLONY') + .field('entityId', colonyId) + .attach('file', Buffer.from('fake image bytes'), { + filename: 'bild.png', + contentType: 'image/png', + }) + .expect(201); + const body = response.body as DocumentBody; + documentId = body.id; + expect(body.entityType).toBe('COLONY'); + expect(body.entityId).toBe(colonyId); + expect(body.fileName).toBe('bild.png'); + expect(body.mimeType).toBe('image/png'); + expect(body.storagePath).toBeUndefined(); + }); + + it('lists documents for the entity', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/documents?entityType=COLONY&entityId=${colonyId}`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const body = response.body as DocumentBody[]; + expect(body.some((entry) => entry.id === documentId)).toBe(true); + }); + + it('downloads the uploaded file with matching content and headers', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/documents/${documentId}/download`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + expect(response.headers['content-type']).toContain('image/png'); + expect(response.headers['content-disposition']).toContain('bild.png'); + const downloaded = response.body as Buffer; + expect(downloaded.toString('utf-8')).toBe('fake image bytes'); + }); + + it('rejects a delete from a role without write access', () => { + return request(app.getHttpServer()) + .delete(`/api/v1/documents/${documentId}`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(403); + }); + + it('lets a beekeeper delete the document', () => { + return request(app.getHttpServer()) + .delete(`/api/v1/documents/${documentId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(204); + }); +}); diff --git a/deploy/k8s/backend/configmap.example.yaml b/deploy/k8s/backend/configmap.example.yaml index bbf7cd3..cf0a3ef 100644 --- a/deploy/k8s/backend/configmap.example.yaml +++ b/deploy/k8s/backend/configmap.example.yaml @@ -7,3 +7,4 @@ data: JWT_ACCESS_EXPIRES_IN: "15m" JWT_REFRESH_EXPIRES_IN: "7d" FRONTEND_ORIGIN: "https://apicore.example.com" + DOCUMENTS_STORAGE_PATH: "/data/documents"