From cc0aaf0a31883c46bd82290ff530f37687cb712e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 00:14:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend):=20F=C3=BCtterungen-Modul=20(Modu?= =?UTF-8?q?l=209)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FeedingsModule mit Erfassung, Colony-Historie und Mengensummen-Endpunkt je Einheit. --- ApiCore-Backend/src/app.module.ts | 2 + .../feedings/colony-feedings.controller.ts | 20 +++ .../src/feedings/dto/create-feeding.dto.ts | 46 ++++++ .../src/feedings/feedings.controller.ts | 34 +++++ .../src/feedings/feedings.module.ts | 11 ++ .../src/feedings/feedings.service.spec.ts | 73 ++++++++++ .../src/feedings/feedings.service.ts | 63 +++++++++ ApiCore-Backend/test/feedings.e2e-spec.ts | 132 ++++++++++++++++++ TASKS.md | 2 +- 9 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/feedings/colony-feedings.controller.ts create mode 100644 ApiCore-Backend/src/feedings/dto/create-feeding.dto.ts create mode 100644 ApiCore-Backend/src/feedings/feedings.controller.ts create mode 100644 ApiCore-Backend/src/feedings/feedings.module.ts create mode 100644 ApiCore-Backend/src/feedings/feedings.service.spec.ts create mode 100644 ApiCore-Backend/src/feedings/feedings.service.ts create mode 100644 ApiCore-Backend/test/feedings.e2e-spec.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index eb2855a..97a09d5 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -13,6 +13,7 @@ import { InspectionsModule } from './inspections/inspections.module'; import { TasksModule } from './tasks/tasks.module'; import { VarroaModule } from './varroa/varroa.module'; import { TreatmentsModule } from './treatments/treatments.module'; +import { FeedingsModule } from './feedings/feedings.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -31,6 +32,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; TasksModule, VarroaModule, TreatmentsModule, + FeedingsModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/feedings/colony-feedings.controller.ts b/ApiCore-Backend/src/feedings/colony-feedings.controller.ts new file mode 100644 index 0000000..5074b8d --- /dev/null +++ b/ApiCore-Backend/src/feedings/colony-feedings.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { FeedingsService } from './feedings.service'; + +@ApiTags('feedings') +@ApiBearerAuth() +@Controller('colonies') +export class ColonyFeedingsController { + constructor(private readonly feedingsService: FeedingsService) {} + + @Get(':id/feedings') + findByColony(@Param('id') id: string) { + return this.feedingsService.findByColony(id); + } + + @Get(':id/feedings/summary') + summaryByColony(@Param('id') id: string) { + return this.feedingsService.summaryByColony(id); + } +} diff --git a/ApiCore-Backend/src/feedings/dto/create-feeding.dto.ts b/ApiCore-Backend/src/feedings/dto/create-feeding.dto.ts new file mode 100644 index 0000000..b1e6dd6 --- /dev/null +++ b/ApiCore-Backend/src/feedings/dto/create-feeding.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { FeedType } from '../../../generated/prisma'; + +export class CreateFeedingDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiProperty({ enum: FeedType }) + @IsEnum(FeedType) + feedType!: FeedType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + product?: string; + + @ApiProperty() + @IsNumber() + amount!: number; + + @ApiProperty() + @IsString() + unit!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + batchNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/feedings/feedings.controller.ts b/ApiCore-Backend/src/feedings/feedings.controller.ts new file mode 100644 index 0000000..743601e --- /dev/null +++ b/ApiCore-Backend/src/feedings/feedings.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 { CreateFeedingDto } from './dto/create-feeding.dto'; +import { FeedingsService } from './feedings.service'; + +@ApiTags('feedings') +@ApiBearerAuth() +@Controller('feedings') +export class FeedingsController { + constructor(private readonly feedingsService: FeedingsService) {} + + @Get() + findAll() { + return this.feedingsService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.feedingsService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF) + @Post() + create( + @Body() dto: CreateFeedingDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.feedingsService.create(dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/feedings/feedings.module.ts b/ApiCore-Backend/src/feedings/feedings.module.ts new file mode 100644 index 0000000..6239eb2 --- /dev/null +++ b/ApiCore-Backend/src/feedings/feedings.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { FeedingsController } from './feedings.controller'; +import { ColonyFeedingsController } from './colony-feedings.controller'; +import { FeedingsService } from './feedings.service'; + +@Module({ + controllers: [FeedingsController, ColonyFeedingsController], + providers: [FeedingsService], + exports: [FeedingsService], +}) +export class FeedingsModule {} diff --git a/ApiCore-Backend/src/feedings/feedings.service.spec.ts b/ApiCore-Backend/src/feedings/feedings.service.spec.ts new file mode 100644 index 0000000..f53a0f2 --- /dev/null +++ b/ApiCore-Backend/src/feedings/feedings.service.spec.ts @@ -0,0 +1,73 @@ +import { NotFoundException } from '@nestjs/common'; +import { FeedingsService } from './feedings.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('FeedingsService', () => { + let service: FeedingsService; + let prisma: { + feeding: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + groupBy: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const feeding = { id: 'feeding-1', colonyId: 'colony-1', date: new Date() }; + + beforeEach(() => { + prisma = { + feeding: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + groupBy: jest.fn(), + }, + }; + auditLog = { record: jest.fn() }; + service = new FeedingsService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.feeding.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create stores the acting user as the feeder', async () => { + prisma.feeding.create.mockResolvedValue(feeding); + + await service.create( + { + colonyId: 'colony-1', + date: '2026-01-01', + feedType: 'SUGAR_WATER', + amount: 2, + unit: 'l', + }, + 'user-1', + ); + + const [[createArg]] = prisma.feeding.create.mock.calls as [ + [{ data: { colonyId: string; userId: string } }], + ]; + expect(createArg.data.colonyId).toBe('colony-1'); + expect(createArg.data.userId).toBe('user-1'); + }); + + it('summaryByColony sums amounts per unit', async () => { + prisma.feeding.groupBy.mockResolvedValue([ + { unit: 'l', _sum: { amount: 5.5 } }, + ]); + + const summary = await service.summaryByColony('colony-1'); + + expect(summary).toEqual([{ unit: 'l', totalAmount: 5.5 }]); + }); +}); diff --git a/ApiCore-Backend/src/feedings/feedings.service.ts b/ApiCore-Backend/src/feedings/feedings.service.ts new file mode 100644 index 0000000..671dc88 --- /dev/null +++ b/ApiCore-Backend/src/feedings/feedings.service.ts @@ -0,0 +1,63 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Feeding } from '../../generated/prisma'; +import { CreateFeedingDto } from './dto/create-feeding.dto'; + +export interface FeedingSummaryEntry { + unit: string; + totalAmount: number; +} + +@Injectable() +export class FeedingsService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.feeding.findMany({ orderBy: { date: 'desc' } }); + } + + async findById(id: string): Promise { + const feeding = await this.prisma.feeding.findUnique({ where: { id } }); + if (!feeding) { + throw new NotFoundException('Feeding not found'); + } + return feeding; + } + + async findByColony(colonyId: string): Promise { + return this.prisma.feeding.findMany({ + where: { colonyId }, + orderBy: { date: 'asc' }, + }); + } + + async summaryByColony(colonyId: string): Promise { + const grouped = await this.prisma.feeding.groupBy({ + by: ['unit'], + where: { colonyId }, + _sum: { amount: true }, + }); + return grouped.map((entry) => ({ + unit: entry.unit, + totalAmount: entry._sum.amount ?? 0, + })); + } + + async create(dto: CreateFeedingDto, actingUserId: string): Promise { + const feeding = await this.prisma.feeding.create({ + data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + }); + await this.auditLog.record({ + entityType: 'Feeding', + entityId: feeding.id, + action: 'CREATE', + userId: actingUserId, + afterData: feeding, + }); + return feeding; + } +} diff --git a/ApiCore-Backend/test/feedings.e2e-spec.ts b/ApiCore-Backend/test/feedings.e2e-spec.ts new file mode 100644 index 0000000..56d6dfa --- /dev/null +++ b/ApiCore-Backend/test/feedings.e2e-spec.ts @@ -0,0 +1,132 @@ +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 FeedingBody { + id: string; + colonyId: string; +} + +interface FeedingSummaryEntry { + unit: string; + totalAmount: number; +} + +describe('Feedings (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const staffEmail = `feedings-e2e-staff-${Date.now()}@example.com`; + const readonlyEmail = `feedings-e2e-readonly-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let staffId: string; + let readonlyId: string; + let staffToken: string; + let readonlyToken: string; + let apiaryId: string; + let colonyId: string; + let feedingId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Readonly', + role: 'READONLY', + }, + }); + readonlyId = readonly.id; + staffToken = (await login(app, staffEmail, password)).accessToken; + readonlyToken = (await login(app, readonlyEmail, 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.feeding.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [staffId, readonlyId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [staffId, readonlyId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [staffId, readonlyId] } }, + }); + await app.close(); + }); + + it('rejects create for a READONLY user', () => { + return request(app.getHttpServer()) + .post('/api/v1/feedings') + .set('Authorization', `Bearer ${readonlyToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + feedType: 'SUGAR_WATER', + amount: 2, + unit: 'l', + }) + .expect(403); + }); + + it('lets a STAFF user record a feeding', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/feedings') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + feedType: 'SUGAR_WATER', + amount: 2.5, + unit: 'l', + }) + .expect(201); + feedingId = (response.body as FeedingBody).id; + expect(feedingId).toEqual(expect.any(String)); + }); + + it('lists feedings for the colony', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/feedings`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const feedings = response.body as FeedingBody[]; + expect(feedings.some((entry) => entry.id === feedingId)).toBe(true); + }); + + it('summarizes total fed amount per unit', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/feedings/summary`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const summary = response.body as FeedingSummaryEntry[]; + const litersEntry = summary.find((entry) => entry.unit === 'l'); + expect(litersEntry?.totalAmount).toBe(2.5); + }); +}); diff --git a/TASKS.md b/TASKS.md index 806afb8..e9a541b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -82,7 +82,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `FeedingsModule` - Endpunkte: `GET/POST /api/v1/feedings`, `GET /api/v1/colonies/:id/feedings`, `GET /api/v1/colonies/:id/feedings/summary` - Frontend: `/feedings/new`, `/colonies/[id]` Tab "Fütterung" -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 10. Ernten (inkl. Schleuderung als Fundament der Chargenkette)