From b18d61640d19a271506c0c2cb5a4a3ca1dccc582 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 21 Aug 2026 18:26:55 +0200 Subject: [PATCH] feat(backend): Koeniginnen-Modul (Modul 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueensModule: CRUD + assign-colony + history, entsprechend Lastenheft §4.5 (Koeniginnenverwaltung). assign-colony schliesst die offene QueenColonyHistory der bisherigen Koenigin am Zielvolk (falls vorhanden) sowie eine evtl. noch offene History dieser Koenigin an einem anderen Volk, bevor Colony.currentQueenId gesetzt und ein neuer History-Eintrag angelegt wird - damit bleibt jeder Koeniginnenwechsel nachvollziehbar, wie gefordert. Rollen wie bei Apiaries/Colonies: Lesen offen, Schreiben auf ADMIN/BEEKEEPER beschraenkt. 5 Unit-Tests + 2 e2e-Tests, Lint, Typecheck und Build gruen. --- ApiCore-Backend/src/app.module.ts | 2 + .../src/queens/dto/assign-colony.dto.ts | 13 ++ .../src/queens/dto/create-queen.dto.ts | 68 ++++++++++ .../src/queens/dto/update-queen.dto.ts | 11 ++ .../src/queens/queens.controller.ts | 58 +++++++++ ApiCore-Backend/src/queens/queens.module.ts | 10 ++ .../src/queens/queens.service.spec.ts | 96 ++++++++++++++ ApiCore-Backend/src/queens/queens.service.ts | 122 ++++++++++++++++++ ApiCore-Backend/test/queens.e2e-spec.ts | 105 +++++++++++++++ TASKS.md | 2 +- 10 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/queens/dto/assign-colony.dto.ts create mode 100644 ApiCore-Backend/src/queens/dto/create-queen.dto.ts create mode 100644 ApiCore-Backend/src/queens/dto/update-queen.dto.ts create mode 100644 ApiCore-Backend/src/queens/queens.controller.ts create mode 100644 ApiCore-Backend/src/queens/queens.module.ts create mode 100644 ApiCore-Backend/src/queens/queens.service.spec.ts create mode 100644 ApiCore-Backend/src/queens/queens.service.ts create mode 100644 ApiCore-Backend/test/queens.e2e-spec.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 5dfda78..4884c0c 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -8,6 +8,7 @@ import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { ApiariesModule } from './apiaries/apiaries.module'; import { ColoniesModule } from './colonies/colonies.module'; +import { QueensModule } from './queens/queens.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -21,6 +22,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; AuthModule, ApiariesModule, ColoniesModule, + QueensModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/queens/dto/assign-colony.dto.ts b/ApiCore-Backend/src/queens/dto/assign-colony.dto.ts new file mode 100644 index 0000000..09f74e8 --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/assign-colony.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class AssignColonyDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/queens/dto/create-queen.dto.ts b/ApiCore-Backend/src/queens/dto/create-queen.dto.ts new file mode 100644 index 0000000..742054d --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/create-queen.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString } from 'class-validator'; + +export class CreateQueenDto { + @ApiProperty() + @IsString() + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + hatchYear?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + breed?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + origin?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + breeder?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + markingColor?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + markingNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + matingType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + matingStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inseminationData?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + motherLine?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fatherLine?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/queens/dto/update-queen.dto.ts b/ApiCore-Backend/src/queens/dto/update-queen.dto.ts new file mode 100644 index 0000000..72bf224 --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/update-queen.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { QueenStatus } from '../../../generated/prisma'; +import { CreateQueenDto } from './create-queen.dto'; + +export class UpdateQueenDto extends PartialType(CreateQueenDto) { + @ApiPropertyOptional({ enum: QueenStatus }) + @IsOptional() + @IsEnum(QueenStatus) + status?: QueenStatus; +} diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts new file mode 100644 index 0000000..3b3f2b2 --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Get, Param, Patch, 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 { CreateQueenDto } from './dto/create-queen.dto'; +import { UpdateQueenDto } from './dto/update-queen.dto'; +import { AssignColonyDto } from './dto/assign-colony.dto'; +import { QueensService } from './queens.service'; + +@ApiTags('queens') +@ApiBearerAuth() +@Controller('queens') +export class QueensController { + constructor(private readonly queensService: QueensService) {} + + @Get() + findAll() { + return this.queensService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.queensService.findById(id); + } + + @Get(':id/history') + getHistory(@Param('id') id: string) { + return this.queensService.getHistory(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) { + return this.queensService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateQueenDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.queensService.update(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/assign-colony') + assignColony( + @Param('id') id: string, + @Body() dto: AssignColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.queensService.assignColony(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/queens/queens.module.ts b/ApiCore-Backend/src/queens/queens.module.ts new file mode 100644 index 0000000..1dc8b83 --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { QueensController } from './queens.controller'; +import { QueensService } from './queens.service'; + +@Module({ + controllers: [QueensController], + providers: [QueensService], + exports: [QueensService], +}) +export class QueensModule {} diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts new file mode 100644 index 0000000..d91639d --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -0,0 +1,96 @@ +import { NotFoundException } from '@nestjs/common'; +import { QueensService } from './queens.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('QueensService', () => { + let service: QueensService; + let tx: { + queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock }; + colony: { update: jest.Mock }; + }; + let prisma: { + queen: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + colony: { findUnique: jest.Mock }; + queenColonyHistory: { findMany: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + + const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' }; + const colony = { id: 'colony-1', currentQueenId: null }; + + beforeEach(() => { + tx = { + queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() }, + colony: { update: jest.fn() }, + }; + prisma = { + queen: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + colony: { findUnique: jest.fn() }, + queenColonyHistory: { findMany: jest.fn() }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new QueensService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.queen.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('assignColony throws NotFoundException when the colony is missing', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + prisma.colony.findUnique.mockResolvedValue(null); + await expect( + service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + prisma.colony.findUnique.mockResolvedValue({ + ...colony, + currentQueenId: 'queen-old', + }); + tx.colony.update.mockResolvedValue({}); + tx.queenColonyHistory.create.mockResolvedValue({}); + + await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'); + + const [[closeArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ + [{ where: { queenId: string; colonyId: string } }], + ]; + expect(closeArg.where.queenId).toBe('queen-old'); + expect(closeArg.where.colonyId).toBe('colony-1'); + expect(tx.colony.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'colony-1' }, + data: { currentQueenId: 'queen-1' }, + }), + ); + const [[historyArg]] = tx.queenColonyHistory.create.mock.calls as [ + [{ data: { queenId: string; colonyId: string } }], + ]; + expect(historyArg.data.queenId).toBe('queen-1'); + expect(historyArg.data.colonyId).toBe('colony-1'); + }); +}); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts new file mode 100644 index 0000000..e74b615 --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -0,0 +1,122 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Queen } from '../../generated/prisma'; +import { CreateQueenDto } from './dto/create-queen.dto'; +import { UpdateQueenDto } from './dto/update-queen.dto'; +import { AssignColonyDto } from './dto/assign-colony.dto'; + +@Injectable() +export class QueensService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.queen.findMany({ orderBy: { code: 'asc' } }); + } + + async findById(id: string): Promise { + const queen = await this.prisma.queen.findUnique({ where: { id } }); + if (!queen) { + throw new NotFoundException('Queen not found'); + } + return queen; + } + + async create(dto: CreateQueenDto, actingUserId: string): Promise { + const queen = await this.prisma.queen.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'Queen', + entityId: queen.id, + action: 'CREATE', + userId: actingUserId, + afterData: queen, + }); + return queen; + } + + async update( + id: string, + dto: UpdateQueenDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const queen = await this.prisma.queen.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'Queen', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: queen, + }); + return queen; + } + + async assignColony( + id: string, + dto: AssignColonyDto, + actingUserId: string, + ): Promise { + const queen = await this.findById(id); + const colony = await this.prisma.colony.findUnique({ + where: { id: dto.colonyId }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + + await this.prisma.$transaction(async (tx) => { + if (colony.currentQueenId && colony.currentQueenId !== queen.id) { + await tx.queenColonyHistory.updateMany({ + where: { + queenId: colony.currentQueenId, + colonyId: colony.id, + endDate: null, + }, + data: { endDate: new Date() }, + }); + } + await tx.queenColonyHistory.updateMany({ + where: { queenId: queen.id, endDate: null }, + data: { endDate: new Date() }, + }); + await tx.colony.update({ + where: { id: colony.id }, + data: { currentQueenId: queen.id }, + }); + await tx.queenColonyHistory.create({ + data: { + queenId: queen.id, + colonyId: colony.id, + startDate: new Date(), + notes: dto.notes, + }, + }); + }); + + await this.auditLog.record({ + entityType: 'Queen', + entityId: queen.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: queen, + afterData: { ...queen, assignedColonyId: colony.id }, + }); + + return this.findById(id); + } + + async getHistory(id: string) { + await this.findById(id); + return this.prisma.queenColonyHistory.findMany({ + where: { queenId: id }, + orderBy: { startDate: 'desc' }, + }); + } +} diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts new file mode 100644 index 0000000..2e0baef --- /dev/null +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -0,0 +1,105 @@ +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 QueenBody { + id: string; + code: string; +} + +describe('Queens (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `queens-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryId: string; + let colonyId: string; + let queenId: 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; + beekeeperToken = (await login(app, beekeeperEmail, 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.queenColonyHistory.deleteMany({ where: { colonyId } }); + if (colonyId) { + await prisma.colony.update({ + where: { id: colonyId }, + data: { currentQueenId: null }, + }); + } + if (queenId) { + await prisma.queen.delete({ where: { id: queenId } }); + } + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); + await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); + await prisma.user.deleteMany({ where: { id: beekeeperId } }); + await app.close(); + }); + + it('lets a BEEKEEPER create a queen', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ code: `Q-${Date.now()}`, breed: 'Carnica' }) + .expect(201); + queenId = (response.body as QueenBody).id; + expect(queenId).toEqual(expect.any(String)); + }); + + it('assigns the queen to a colony and records history', async () => { + await request(app.getHttpServer()) + .post(`/api/v1/queens/${queenId}/assign-colony`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ colonyId }) + .expect(201); + + const colony = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect((colony.body as { currentQueenId: string }).currentQueenId).toBe( + queenId, + ); + + const history = await request(app.getHttpServer()) + .get(`/api/v1/queens/${queenId}/history`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect(history.body).toHaveLength(1); + }); +}); diff --git a/TASKS.md b/TASKS.md index 011ea9f..47508a1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -42,7 +42,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `QueensModule` (inkl. `QueenColonyHistory`) - Endpunkte: `GET/POST /api/v1/queens`, `GET/PATCH /api/v1/queens/:id`, `POST /api/v1/queens/:id/assign-colony`, `GET /api/v1/queens/:id/history` - Frontend: `/queens`, `/queens/[id]`, `/queens/new` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 5. Durchsichten