diff --git a/ApiCore-Backend/prisma/migrations/20260822205152_queen_evaluations/migration.sql b/ApiCore-Backend/prisma/migrations/20260822205152_queen_evaluations/migration.sql new file mode 100644 index 0000000..899bb7c --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260822205152_queen_evaluations/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "QueenEvaluation" ( + "id" TEXT NOT NULL, + "queenId" TEXT NOT NULL, + "colonyId" TEXT, + "date" TIMESTAMP(3) NOT NULL, + "colonyStrength" INTEGER NOT NULL, + "gentleness" INTEGER NOT NULL, + "combBuild" INTEGER NOT NULL, + "swarmingTendency" INTEGER NOT NULL, + "broodPattern" INTEGER NOT NULL, + "honeyYield" INTEGER NOT NULL, + "notes" TEXT, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "QueenEvaluation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "QueenEvaluation_queenId_idx" ON "QueenEvaluation"("queenId"); + +-- CreateIndex +CREATE INDEX "QueenEvaluation_colonyId_idx" ON "QueenEvaluation"("colonyId"); + +-- AddForeignKey +ALTER TABLE "QueenEvaluation" ADD CONSTRAINT "QueenEvaluation_queenId_fkey" FOREIGN KEY ("queenId") REFERENCES "Queen"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QueenEvaluation" ADD CONSTRAINT "QueenEvaluation_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QueenEvaluation" ADD CONSTRAINT "QueenEvaluation_userId_fkey" FOREIGN KEY ("userId") 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 a326784..77c0f3c 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { auditLogs AuditLog[] bottlingBatches BottlingBatch[] notifications Notification[] + queenEvaluations QueenEvaluation[] @@index([role]) } @@ -172,6 +173,7 @@ model Colony { treatments Treatment[] feedings Feeding[] harvests HarvestColony[] + queenEvaluations QueenEvaluation[] @@index([apiaryId]) @@index([status]) @@ -252,10 +254,37 @@ model Queen { colony Colony? @relation("ColonyCurrentQueen") colonyHistory QueenColonyHistory[] + evaluations QueenEvaluation[] @@index([status]) } +// --------------------------------------------------------------------------- +// Königinnenbewertung (Abschnitt 4.7) – v1.x Modul 31 +// --------------------------------------------------------------------------- + +model QueenEvaluation { + id String @id @default(cuid()) + queenId String + queen Queen @relation(fields: [queenId], references: [id]) + colonyId String? + colony Colony? @relation(fields: [colonyId], references: [id]) + date DateTime + colonyStrength Int + gentleness Int + combBuild Int + swarmingTendency Int + broodPattern Int + honeyYield Int + notes String? + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + + @@index([queenId]) + @@index([colonyId]) +} + // Königinnenwechsel historisch nachvollziehbar (Abschnitt 4.5) model QueenColonyHistory { id String @id @default(cuid()) diff --git a/ApiCore-Backend/src/queens/dto/create-queen-evaluation.dto.ts b/ApiCore-Backend/src/queens/dto/create-queen-evaluation.dto.ts new file mode 100644 index 0000000..b9eb930 --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/create-queen-evaluation.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class CreateQueenEvaluationDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + colonyId?: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiProperty({ minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + colonyStrength!: number; + + @ApiProperty({ minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + gentleness!: number; + + @ApiProperty({ minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + combBuild!: number; + + @ApiProperty({ minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + swarmingTendency!: number; + + @ApiProperty({ minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + broodPattern!: number; + + @ApiProperty({ minimum: 1, maximum: 5 }) + @IsInt() + @Min(1) + @Max(5) + honeyYield!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts index b599bbf..d183a00 100644 --- a/ApiCore-Backend/src/queens/queens.controller.ts +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -15,6 +15,7 @@ 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 { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto'; import { QueensService } from './queens.service'; @ApiTags('queens') @@ -63,4 +64,19 @@ export class QueensController { ) { return this.queensService.assignColony(id, dto, actor.id); } + + @Get(':id/evaluations') + getEvaluations(@Param('id') id: string) { + return this.queensService.getEvaluations(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/evaluations') + addEvaluation( + @Param('id') id: string, + @Body() dto: CreateQueenEvaluationDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.queensService.addEvaluation(id, dto, actor.id); + } } diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index d91639d..bbf4191 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -18,6 +18,7 @@ describe('QueensService', () => { }; colony: { findUnique: jest.Mock }; queenColonyHistory: { findMany: jest.Mock }; + queenEvaluation: { findMany: jest.Mock; create: jest.Mock }; $transaction: jest.Mock; }; let auditLog: { record: jest.Mock }; @@ -39,6 +40,7 @@ describe('QueensService', () => { }, colony: { findUnique: jest.fn() }, queenColonyHistory: { findMany: jest.fn() }, + queenEvaluation: { findMany: jest.fn(), create: jest.fn() }, $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => callback(tx), ), @@ -93,4 +95,39 @@ describe('QueensService', () => { expect(historyArg.data.queenId).toBe('queen-1'); expect(historyArg.data.colonyId).toBe('colony-1'); }); + + it('addEvaluation creates a queen evaluation and records an audit log entry', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + const created = { id: 'eval-1', queenId: 'queen-1', colonyStrength: 4 }; + prisma.queenEvaluation.create.mockResolvedValueOnce(created); + + const result = await service.addEvaluation( + 'queen-1', + { + date: '2026-06-01', + colonyStrength: 4, + gentleness: 5, + combBuild: 3, + swarmingTendency: 2, + broodPattern: 4, + honeyYield: 5, + }, + 'user-1', + ); + + expect(result).toEqual(created); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'QueenEvaluation', + action: 'CREATE', + }), + ); + }); + + it('getEvaluations throws NotFoundException for an unknown queen', async () => { + prisma.queen.findUnique.mockResolvedValue(null); + await expect(service.getEvaluations('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); }); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index f7ae457..c8e84ea 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -5,6 +5,7 @@ import { Queen, QueenStatus } from '../../generated/prisma'; import { CreateQueenDto } from './dto/create-queen.dto'; import { UpdateQueenDto } from './dto/update-queen.dto'; import { AssignColonyDto } from './dto/assign-colony.dto'; +import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto'; export interface QueenFilter { status?: QueenStatus; @@ -126,4 +127,43 @@ export class QueensService { orderBy: { startDate: 'desc' }, }); } + + async getEvaluations(id: string) { + await this.findById(id); + return this.prisma.queenEvaluation.findMany({ + where: { queenId: id }, + orderBy: { date: 'desc' }, + }); + } + + async addEvaluation( + id: string, + dto: CreateQueenEvaluationDto, + actingUserId: string, + ) { + await this.findById(id); + const evaluation = await this.prisma.queenEvaluation.create({ + data: { + queenId: id, + colonyId: dto.colonyId, + date: new Date(dto.date), + colonyStrength: dto.colonyStrength, + gentleness: dto.gentleness, + combBuild: dto.combBuild, + swarmingTendency: dto.swarmingTendency, + broodPattern: dto.broodPattern, + honeyYield: dto.honeyYield, + notes: dto.notes, + userId: actingUserId, + }, + }); + await this.auditLog.record({ + entityType: 'QueenEvaluation', + entityId: evaluation.id, + action: 'CREATE', + userId: actingUserId, + afterData: evaluation, + }); + return evaluation; + } } diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts index 2e0baef..725756d 100644 --- a/ApiCore-Backend/test/queens.e2e-spec.ts +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -53,6 +53,9 @@ describe('Queens (e2e)', () => { }); afterAll(async () => { + if (queenId) { + await prisma.queenEvaluation.deleteMany({ where: { queenId } }); + } await prisma.queenColonyHistory.deleteMany({ where: { colonyId } }); if (colonyId) { await prisma.colony.update({ @@ -102,4 +105,29 @@ describe('Queens (e2e)', () => { .expect(200); expect(history.body).toHaveLength(1); }); + + it('adds a queen evaluation and lists it back', async () => { + await request(app.getHttpServer()) + .post(`/api/v1/queens/${queenId}/evaluations`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + colonyStrength: 4, + gentleness: 5, + combBuild: 3, + swarmingTendency: 2, + broodPattern: 4, + honeyYield: 5, + }) + .expect(201); + + const response = await request(app.getHttpServer()) + .get(`/api/v1/queens/${queenId}/evaluations`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const evaluations = response.body as { colonyStrength: number }[]; + expect(evaluations).toHaveLength(1); + expect(evaluations[0]?.colonyStrength).toBe(4); + }); }); diff --git a/TASKS.md b/TASKS.md index 4128de4..6e8e5d4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -251,7 +251,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `QueenEvaluation` (Volksstärke, Sanftmut, Wabensitz, Schwarmtrieb, Brutbild, Honigertrag je Skala 1–5, Datum, Notizen) verknüpft mit Königin - Endpunkte: `GET/POST /api/v1/queens/:id/evaluations` - Frontend: Bewertungsformular + Verlauf auf der Königin-Detailseite -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 32. Lagerbehälter-Umlagerung