From f363bffed18cb89a70d741c750efb51bcbc05178 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 21 Aug 2026 23:56:06 +0200 Subject: [PATCH] feat(backend): Varroamessungen-Modul (Modul 7) VarroaModule mit Erfassung und Colony-Historie-Endpunkt fuer Varroamessungen. --- ApiCore-Backend/src/app.module.ts | 2 + .../colony-varroa-measurements.controller.ts | 15 +++ .../dto/create-varroa-measurement.dto.ts | 49 ++++++++ .../src/varroa/varroa.controller.ts | 34 ++++++ ApiCore-Backend/src/varroa/varroa.module.ts | 11 ++ .../src/varroa/varroa.service.spec.ts | 71 +++++++++++ ApiCore-Backend/src/varroa/varroa.service.ts | 53 ++++++++ ApiCore-Backend/test/varroa.e2e-spec.ts | 115 ++++++++++++++++++ TASKS.md | 2 +- 9 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts create mode 100644 ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts create mode 100644 ApiCore-Backend/src/varroa/varroa.controller.ts create mode 100644 ApiCore-Backend/src/varroa/varroa.module.ts create mode 100644 ApiCore-Backend/src/varroa/varroa.service.spec.ts create mode 100644 ApiCore-Backend/src/varroa/varroa.service.ts create mode 100644 ApiCore-Backend/test/varroa.e2e-spec.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 4c2c17d..f08d1c9 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -11,6 +11,7 @@ import { ColoniesModule } from './colonies/colonies.module'; import { QueensModule } from './queens/queens.module'; import { InspectionsModule } from './inspections/inspections.module'; import { TasksModule } from './tasks/tasks.module'; +import { VarroaModule } from './varroa/varroa.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -27,6 +28,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; QueensModule, InspectionsModule, TasksModule, + VarroaModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts b/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts new file mode 100644 index 0000000..96b7797 --- /dev/null +++ b/ApiCore-Backend/src/varroa/colony-varroa-measurements.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { VarroaService } from './varroa.service'; + +@ApiTags('varroa-measurements') +@ApiBearerAuth() +@Controller('colonies') +export class ColonyVarroaMeasurementsController { + constructor(private readonly varroaService: VarroaService) {} + + @Get(':id/varroa-measurements') + findByColony(@Param('id') id: string) { + return this.varroaService.findByColony(id); + } +} diff --git a/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts b/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts new file mode 100644 index 0000000..571eb39 --- /dev/null +++ b/ApiCore-Backend/src/varroa/dto/create-varroa-measurement.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { VarroaMethod } from '../../../generated/prisma'; + +export class CreateVarroaMeasurementDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiProperty({ enum: VarroaMethod }) + @IsEnum(VarroaMethod) + method!: VarroaMethod; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + durationMinutes?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + miteCount?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + calculatedValue?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assessment?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/varroa/varroa.controller.ts b/ApiCore-Backend/src/varroa/varroa.controller.ts new file mode 100644 index 0000000..5483908 --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { 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 { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; +import { VarroaService } from './varroa.service'; + +@ApiTags('varroa-measurements') +@ApiBearerAuth() +@Controller('varroa-measurements') +export class VarroaController { + constructor(private readonly varroaService: VarroaService) {} + + @Get() + findAll() { + return this.varroaService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.varroaService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateVarroaMeasurementDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.varroaService.create(dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/varroa/varroa.module.ts b/ApiCore-Backend/src/varroa/varroa.module.ts new file mode 100644 index 0000000..db34ab6 --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { VarroaController } from './varroa.controller'; +import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller'; +import { VarroaService } from './varroa.service'; + +@Module({ + controllers: [VarroaController, ColonyVarroaMeasurementsController], + providers: [VarroaService], + exports: [VarroaService], +}) +export class VarroaModule {} diff --git a/ApiCore-Backend/src/varroa/varroa.service.spec.ts b/ApiCore-Backend/src/varroa/varroa.service.spec.ts new file mode 100644 index 0000000..855ec12 --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa.service.spec.ts @@ -0,0 +1,71 @@ +import { NotFoundException } from '@nestjs/common'; +import { VarroaService } from './varroa.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('VarroaService', () => { + let service: VarroaService; + let prisma: { + varroaMeasurement: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const measurement = { + id: 'varroa-1', + colonyId: 'colony-1', + date: new Date(), + }; + + beforeEach(() => { + prisma = { + varroaMeasurement: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + }; + auditLog = { record: jest.fn() }; + service = new VarroaService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.varroaMeasurement.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create stores the acting user as the measurer', async () => { + prisma.varroaMeasurement.create.mockResolvedValue(measurement); + + await service.create( + { colonyId: 'colony-1', date: '2026-01-01', method: 'NATURAL_MITE_FALL' }, + 'user-1', + ); + + const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [ + [{ data: { colonyId: string; userId: string } }], + ]; + expect(createArg.data.colonyId).toBe('colony-1'); + expect(createArg.data.userId).toBe('user-1'); + }); + + it('findByColony orders results chronologically', async () => { + prisma.varroaMeasurement.findMany.mockResolvedValue([measurement]); + + await service.findByColony('colony-1'); + + const [[queryArg]] = prisma.varroaMeasurement.findMany.mock.calls as [ + [{ where: { colonyId: string }; orderBy: { date: string } }], + ]; + expect(queryArg.where.colonyId).toBe('colony-1'); + expect(queryArg.orderBy.date).toBe('asc'); + }); +}); diff --git a/ApiCore-Backend/src/varroa/varroa.service.ts b/ApiCore-Backend/src/varroa/varroa.service.ts new file mode 100644 index 0000000..09f1bc4 --- /dev/null +++ b/ApiCore-Backend/src/varroa/varroa.service.ts @@ -0,0 +1,53 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { VarroaMeasurement } from '../../generated/prisma'; +import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto'; + +@Injectable() +export class VarroaService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.varroaMeasurement.findMany({ + orderBy: { date: 'desc' }, + }); + } + + async findById(id: string): Promise { + const measurement = await this.prisma.varroaMeasurement.findUnique({ + where: { id }, + }); + if (!measurement) { + throw new NotFoundException('Varroa measurement not found'); + } + return measurement; + } + + async findByColony(colonyId: string): Promise { + return this.prisma.varroaMeasurement.findMany({ + where: { colonyId }, + orderBy: { date: 'asc' }, + }); + } + + async create( + dto: CreateVarroaMeasurementDto, + actingUserId: string, + ): Promise { + const measurement = await this.prisma.varroaMeasurement.create({ + data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + }); + await this.auditLog.record({ + entityType: 'VarroaMeasurement', + entityId: measurement.id, + action: 'CREATE', + userId: actingUserId, + afterData: measurement, + }); + return measurement; + } +} diff --git a/ApiCore-Backend/test/varroa.e2e-spec.ts b/ApiCore-Backend/test/varroa.e2e-spec.ts new file mode 100644 index 0000000..28a1dc3 --- /dev/null +++ b/ApiCore-Backend/test/varroa.e2e-spec.ts @@ -0,0 +1,115 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +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 VarroaMeasurementBody { + id: string; + colonyId: string; +} + +describe('Varroa measurements (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `varroa-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `varroa-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let apiaryId: string; + let colonyId: string; + let measurementId: 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: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + const staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + }); + + afterAll(async () => { + await prisma.varroaMeasurement.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, staffId] } }, + }); + await app.close(); + }); + + it('rejects create for a STAFF user', () => { + return request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + method: 'NATURAL_MITE_FALL', + }) + .expect(403); + }); + + it('lets a BEEKEEPER user record a measurement', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/varroa-measurements') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + method: 'NATURAL_MITE_FALL', + miteCount: 12, + durationMinutes: 4320, + }) + .expect(201); + measurementId = (response.body as VarroaMeasurementBody).id; + expect(measurementId).toEqual(expect.any(String)); + }); + + it('lists measurements for the colony', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/varroa-measurements`) + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + const measurements = response.body as VarroaMeasurementBody[]; + expect(measurements.some((entry) => entry.id === measurementId)).toBe(true); + }); +}); diff --git a/TASKS.md b/TASKS.md index aa0747d..29ef97e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -66,7 +66,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `VarroaModule` - Endpunkte: `GET/POST /api/v1/varroa-measurements`, `GET /api/v1/colonies/:id/varroa-measurements` - Frontend: `/varroa/new`, `/colonies/[id]` Tab "Varroa" mit Verlaufsgrafik -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 8. Behandlungen