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
@@ -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.
|
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.
|
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.
|
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
|
## 7. Kubernetes
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ JWT_REFRESH_EXPIRES_IN="7d"
|
|||||||
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
FRONTEND_ORIGIN="http://localhost:3000"
|
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"
|
||||||
@@ -14,3 +14,7 @@ prisma/seed.d.ts
|
|||||||
|
|
||||||
# Local dev/test PostgreSQL data dir (not for production use)
|
# Local dev/test PostgreSQL data dir (not for production use)
|
||||||
.devdb/
|
.devdb/
|
||||||
|
|
||||||
|
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
|
||||||
|
/uploads/
|
||||||
|
/test-uploads/
|
||||||
Generated
+11
@@ -40,6 +40,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
@@ -3543,6 +3544,16 @@
|
|||||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/multer": "^2.2.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"@types/pdfkit": "^0.17.6",
|
"@types/pdfkit": "^0.17.6",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -53,6 +53,7 @@ model User {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
webhookSubscriptions WebhookSubscription[]
|
webhookSubscriptions WebhookSubscription[]
|
||||||
|
documents Document[]
|
||||||
|
|
||||||
@@index([role])
|
@@index([role])
|
||||||
}
|
}
|
||||||
@@ -773,3 +774,30 @@ model WebhookSubscription {
|
|||||||
|
|
||||||
@@index([active])
|
@@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])
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { CalendarModule } from './calendar/calendar.module';
|
|||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { StatisticsModule } from './statistics/statistics.module';
|
import { StatisticsModule } from './statistics/statistics.module';
|
||||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||||
|
import { DocumentsModule } from './documents/documents.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/guards/roles.guard';
|
import { RolesGuard } from './auth/guards/roles.guard';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
StatisticsModule,
|
StatisticsModule,
|
||||||
WebhooksModule,
|
WebhooksModule,
|
||||||
|
DocumentsModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
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;
|
||||||
|
}
|
||||||
@@ -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<App>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,3 +7,4 @@ data:
|
|||||||
JWT_ACCESS_EXPIRES_IN: "15m"
|
JWT_ACCESS_EXPIRES_IN: "15m"
|
||||||
JWT_REFRESH_EXPIRES_IN: "7d"
|
JWT_REFRESH_EXPIRES_IN: "7d"
|
||||||
FRONTEND_ORIGIN: "https://apicore.example.com"
|
FRONTEND_ORIGIN: "https://apicore.example.com"
|
||||||
|
DOCUMENTS_STORAGE_PATH: "/data/documents"
|
||||||
Reference in new issue
Block a user