From e458a7cf07715b18682c827f8fc7f894012232b9 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 25 Aug 2026 05:13:45 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20K=C3=B6niginnen-Abstammungsbaum=20(Mutt?= =?UTF-8?q?erlinie)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Funktion jenseits der Lastenheft-Checkliste, explizit im Text als spätere Erweiterung angedacht (§4.4: "ApiCore SOLL später Abstammungsbäume darstellen können"). Königin.motherQueenId ist eine neue optionale Selbstreferenz (additive Migration), zusätzlich zu den bestehenden freien Textfeldern motherLine/fatherLine – letztere bleiben für Fälle, in denen die Mutter nicht selbst in ApiCore erfasst ist. Die Vaterlinie bleibt bewusst Freitext, da eine Königin mit vielen Drohnen aus unterschiedlichen Völkern begattet wird und es keine einzelne "Vater-Königin" gibt, die referenzierbar wäre. Backend: GET /queens/:id/lineage baut die mütterliche Ahnenkette (rekursiv über motherQueenId, Tiefe 8) sowie den Töchterbaum (rekursiv über die neue daughterQueens-Relation) in einer Antwort; create/update lehnen eine nicht existierende oder auf sich selbst zeigende motherQueenId ab. Frontend: neue "Abstammungsbaum"-Karte auf der Königin-Detailseite zeigt die mütterliche Linie als Liste sowie die Töchter als eingerückten Baum, beide mit Links zu den jeweiligen Königinnen. Anlage- und Bearbeiten-Formular bekommen ein "Mutter"-Auswahlfeld (Bearbeiten schließt die Königin selbst aus der Liste aus). Live in Chrome verifiziert: dreistufige Kette (Großmutter → Mutter → Tochter) korrekt in beide Richtungen dargestellt; dabei einen echten Anzeigefehler gefunden und behoben – das Mutter-Auswahlfeld zeigte nach dem Speichern die rohe Datenbank-ID statt des Königin-Codes an (derselbe SelectValue-ohne-children-Fehler wie zuvor bei der Import-Entitätsauswahl). --- .../migration.sql | 8 ++ ApiCore-Backend/prisma/schema.prisma | 10 +- .../src/queens/dto/create-queen.dto.ts | 5 + .../src/queens/queens.controller.ts | 5 + .../src/queens/queens.service.spec.ts | 87 ++++++++++++++- ApiCore-Backend/src/queens/queens.service.ts | 104 +++++++++++++++++- ApiCore-Backend/test/queens.e2e-spec.ts | 53 +++++++++ .../src/app/(app)/queens/[id]/page.tsx | 2 + .../app/(app)/queens/[id]/queen-edit-card.tsx | 37 ++++++- .../app/(app)/queens/[id]/queen-lineage.tsx | 95 ++++++++++++++++ .../src/app/(app)/queens/new/page.tsx | 49 ++++++++- .../src/app/(app)/queens/page.spec.tsx | 1 + ApiCore-Frontend/src/lib/queens/api.ts | 5 + ApiCore-Frontend/src/lib/queens/types.ts | 24 ++++ 14 files changed, 475 insertions(+), 10 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260825025552_add_queen_motherline/migration.sql create mode 100644 ApiCore-Frontend/src/app/(app)/queens/[id]/queen-lineage.tsx diff --git a/ApiCore-Backend/prisma/migrations/20260825025552_add_queen_motherline/migration.sql b/ApiCore-Backend/prisma/migrations/20260825025552_add_queen_motherline/migration.sql new file mode 100644 index 0000000..a32760b --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260825025552_add_queen_motherline/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Queen" ADD COLUMN "motherQueenId" TEXT; + +-- CreateIndex +CREATE INDEX "Queen_motherQueenId_idx" ON "Queen"("motherQueenId"); + +-- AddForeignKey +ALTER TABLE "Queen" ADD CONSTRAINT "Queen_motherQueenId_fkey" FOREIGN KEY ("motherQueenId") REFERENCES "Queen"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 5a7230f..1426041 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -249,17 +249,21 @@ model Queen { inseminationData String? motherLine String? fatherLine String? + motherQueenId String? status QueenStatus @default(ACTIVE) notes String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deactivatedAt DateTime? - colony Colony? @relation("ColonyCurrentQueen") - colonyHistory QueenColonyHistory[] - evaluations QueenEvaluation[] + motherQueen Queen? @relation("QueenMotherline", fields: [motherQueenId], references: [id]) + daughterQueens Queen[] @relation("QueenMotherline") + colony Colony? @relation("ColonyCurrentQueen") + colonyHistory QueenColonyHistory[] + evaluations QueenEvaluation[] @@index([status]) + @@index([motherQueenId]) } // --------------------------------------------------------------------------- diff --git a/ApiCore-Backend/src/queens/dto/create-queen.dto.ts b/ApiCore-Backend/src/queens/dto/create-queen.dto.ts index 742054d..6b12129 100644 --- a/ApiCore-Backend/src/queens/dto/create-queen.dto.ts +++ b/ApiCore-Backend/src/queens/dto/create-queen.dto.ts @@ -61,6 +61,11 @@ export class CreateQueenDto { @IsString() fatherLine?: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + motherQueenId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts index d183a00..0783acc 100644 --- a/ApiCore-Backend/src/queens/queens.controller.ts +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -39,6 +39,11 @@ export class QueensController { return this.queensService.getHistory(id); } + @Get(':id/lineage') + getLineage(@Param('id') id: string) { + return this.queensService.getLineage(id); + } + @Roles(Role.ADMIN, Role.BEEKEEPER) @Post() create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) { diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts index 278ca84..8fe870f 100644 --- a/ApiCore-Backend/src/queens/queens.service.spec.ts +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { QueensService } from './queens.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; @@ -133,4 +133,89 @@ describe('QueensService', () => { NotFoundException, ); }); + + it('create rejects a motherQueenId that does not exist', async () => { + prisma.queen.findUnique.mockResolvedValueOnce(null); + await expect( + service.create({ code: 'Q-002', motherQueenId: 'missing' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.queen.create).not.toHaveBeenCalled(); + }); + + it('update rejects a queen being set as her own mother', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + await expect( + service.update('queen-1', { motherQueenId: 'queen-1' }, 'user-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.queen.update).not.toHaveBeenCalled(); + }); + + it('update rejects a motherQueenId that does not exist', async () => { + prisma.queen.findUnique + .mockResolvedValueOnce(queen) + .mockResolvedValueOnce(null); + await expect( + service.update('queen-1', { motherQueenId: 'missing' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.queen.update).not.toHaveBeenCalled(); + }); + + it('getLineage builds the ancestor chain and the descendant tree', async () => { + const grandmother = { + id: 'queen-gm', + code: 'Q-GM', + hatchYear: 2022, + status: 'ACTIVE', + motherQueenId: null, + }; + const mother = { + id: 'queen-m', + code: 'Q-M', + hatchYear: 2023, + status: 'ACTIVE', + motherQueenId: 'queen-gm', + }; + const self = { + id: 'queen-1', + code: 'Q-001', + hatchYear: 2024, + status: 'ACTIVE', + motherQueenId: 'queen-m', + }; + const daughter = { + id: 'queen-d', + code: 'Q-D', + hatchYear: 2025, + status: 'ACTIVE', + motherQueenId: 'queen-1', + }; + + prisma.queen.findUnique.mockImplementation( + ({ where }: { where: { id: string } }) => { + const byId: Record = { + 'queen-1': self, + 'queen-m': mother, + 'queen-gm': grandmother, + }; + return Promise.resolve(byId[where.id] ?? null); + }, + ); + prisma.queen.findMany.mockImplementation( + ({ where }: { where: { motherQueenId: string } }) => { + if (where.motherQueenId === 'queen-1') + return Promise.resolve([daughter]); + return Promise.resolve([]); + }, + ); + + const lineage = await service.getLineage('queen-1'); + + expect(lineage.queen.code).toBe('Q-001'); + expect(lineage.queen.mother?.code).toBe('Q-M'); + expect(lineage.queen.mother?.mother?.code).toBe('Q-GM'); + expect(lineage.queen.mother?.mother?.mother).toBeNull(); + expect(lineage.descendants).toHaveLength(1); + expect(lineage.descendants[0].code).toBe('Q-D'); + expect(lineage.descendants[0].daughters).toEqual([]); + }); }); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts index 8ea901c..3c47385 100644 --- a/ApiCore-Backend/src/queens/queens.service.ts +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { WebhooksService } from '../webhooks/webhooks.service'; @@ -12,6 +16,29 @@ export interface QueenFilter { status?: QueenStatus; } +export interface QueenLineageAncestor { + id: string; + code: string; + hatchYear: number | null; + status: QueenStatus; + mother: QueenLineageAncestor | null; +} + +export interface QueenLineageDescendant { + id: string; + code: string; + hatchYear: number | null; + status: QueenStatus; + daughters: QueenLineageDescendant[]; +} + +export interface QueenLineage { + queen: QueenLineageAncestor; + descendants: QueenLineageDescendant[]; +} + +const MAX_LINEAGE_DEPTH = 8; + @Injectable() export class QueensService { constructor( @@ -36,6 +63,9 @@ export class QueensService { } async create(dto: CreateQueenDto, actingUserId: string): Promise { + if (dto.motherQueenId) { + await this.assertMotherQueenExists(dto.motherQueenId); + } const queen = await this.prisma.queen.create({ data: { ...dto } }); await this.auditLog.record({ entityType: 'Queen', @@ -54,6 +84,12 @@ export class QueensService { actingUserId: string, ): Promise { const before = await this.findById(id); + if (dto.motherQueenId) { + if (dto.motherQueenId === id) { + throw new BadRequestException('A queen cannot be her own mother'); + } + await this.assertMotherQueenExists(dto.motherQueenId); + } const queen = await this.prisma.queen.update({ where: { id }, data: { ...dto }, @@ -171,4 +207,70 @@ export class QueensService { }); return evaluation; } + + async getLineage(id: string): Promise { + const queen = await this.findById(id); + const ancestorChain = await this.buildAncestorChain( + queen, + MAX_LINEAGE_DEPTH, + ); + const descendants = await this.buildDescendantTree(id, MAX_LINEAGE_DEPTH); + return { queen: ancestorChain, descendants }; + } + + private async assertMotherQueenExists(motherQueenId: string): Promise { + const mother = await this.prisma.queen.findUnique({ + where: { id: motherQueenId }, + }); + if (!mother) { + throw new NotFoundException('Mother queen not found'); + } + } + + private async buildAncestorChain( + queen: Queen, + depthRemaining: number, + ): Promise { + const node: QueenLineageAncestor = { + id: queen.id, + code: queen.code, + hatchYear: queen.hatchYear, + status: queen.status, + mother: null, + }; + if (queen.motherQueenId && depthRemaining > 0) { + const mother = await this.prisma.queen.findUnique({ + where: { id: queen.motherQueenId }, + }); + if (mother) { + node.mother = await this.buildAncestorChain(mother, depthRemaining - 1); + } + } + return node; + } + + private async buildDescendantTree( + queenId: string, + depthRemaining: number, + ): Promise { + if (depthRemaining <= 0) { + return []; + } + const daughters = await this.prisma.queen.findMany({ + where: { motherQueenId: queenId }, + orderBy: { code: 'asc' }, + }); + return Promise.all( + daughters.map(async (daughter) => ({ + id: daughter.id, + code: daughter.code, + hatchYear: daughter.hatchYear, + status: daughter.status, + daughters: await this.buildDescendantTree( + daughter.id, + depthRemaining - 1, + ), + })), + ); + } } diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts index 725756d..9118a45 100644 --- a/ApiCore-Backend/test/queens.e2e-spec.ts +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -21,6 +21,7 @@ describe('Queens (e2e)', () => { let apiaryId: string; let colonyId: string; let queenId: string; + let daughterQueenId: string; beforeAll(async () => { app = await createTestApp(); @@ -63,6 +64,9 @@ describe('Queens (e2e)', () => { data: { currentQueenId: null }, }); } + if (daughterQueenId) { + await prisma.queen.delete({ where: { id: daughterQueenId } }); + } if (queenId) { await prisma.queen.delete({ where: { id: queenId } }); } @@ -130,4 +134,53 @@ describe('Queens (e2e)', () => { expect(evaluations).toHaveLength(1); expect(evaluations[0]?.colonyStrength).toBe(4); }); + + it('rejects creating a queen with a motherQueenId that does not exist', () => { + return request(app.getHttpServer()) + .post('/api/v1/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ code: `Q-orphan-${Date.now()}`, motherQueenId: 'does-not-exist' }) + .expect(404); + }); + + it('rejects a queen set as her own mother', () => { + return request(app.getHttpServer()) + .patch(`/api/v1/queens/${queenId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ motherQueenId: queenId }) + .expect(400); + }); + + it('creates a daughter queen and returns the ancestor/descendant lineage', async () => { + const daughterResponse = await request(app.getHttpServer()) + .post('/api/v1/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ code: `Q-daughter-${Date.now()}`, motherQueenId: queenId }) + .expect(201); + daughterQueenId = (daughterResponse.body as QueenBody).id; + + const motherLineage = await request(app.getHttpServer()) + .get(`/api/v1/queens/${queenId}/lineage`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const motherBody = motherLineage.body as { + queen: { id: string; mother: unknown }; + descendants: { id: string }[]; + }; + expect(motherBody.queen.id).toBe(queenId); + expect(motherBody.queen.mother).toBeNull(); + expect(motherBody.descendants).toHaveLength(1); + expect(motherBody.descendants[0]?.id).toBe(daughterQueenId); + + const daughterLineage = await request(app.getHttpServer()) + .get(`/api/v1/queens/${daughterQueenId}/lineage`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const daughterBody = daughterLineage.body as { + queen: { id: string; mother: { id: string } | null }; + descendants: unknown[]; + }; + expect(daughterBody.queen.mother?.id).toBe(queenId); + expect(daughterBody.descendants).toHaveLength(0); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx index 0867fca..b3fb067 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx @@ -9,6 +9,7 @@ import { QueenEditCard } from "./queen-edit-card"; import { AssignColonyDialog } from "./assign-colony-dialog"; import { QueenHistoryCard } from "./queen-history"; import { QueenEvaluationsCard } from "./queen-evaluations"; +import { QueenLineageCard } from "./queen-lineage"; export default function QueenDetailPage() { const params = useParams<{ id: string }>(); @@ -36,6 +37,7 @@ export default function QueenDetailPage() { + diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx index 7a1f9b7..402fdf9 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/queen-edit-card.tsx @@ -2,8 +2,8 @@ import { FormEvent, useState } from "react"; import { Crown } from "lucide-react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { updateQueen } from "@/lib/queens/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchQueens, updateQueen } from "@/lib/queens/api"; import { Queen, QueenStatus } from "@/lib/queens/types"; import { statusTone } from "@/lib/status-tone"; import { Button } from "@/components/ui/button"; @@ -40,14 +40,22 @@ export function QueenEditCard({ queen }: { queen: Queen }) { const queryClient = useQueryClient(); const [breed, setBreed] = useState(queen.breed ?? ""); const [origin, setOrigin] = useState(queen.origin ?? ""); + const [motherQueenId, setMotherQueenId] = useState(queen.motherQueenId ?? ""); const [notes, setNotes] = useState(queen.notes ?? ""); const [status, setStatus] = useState(queen.status); + const { data: queens } = useQuery({ + queryKey: ["queens"], + queryFn: () => fetchQueens(), + }); + const motherOptions = queens?.filter((option) => option.id !== queen.id) ?? []; + const mutation = useMutation({ mutationFn: () => updateQueen(queen.id, { breed: breed || undefined, origin: origin || undefined, + motherQueenId: motherQueenId || undefined, notes: notes || undefined, status, }), @@ -94,6 +102,31 @@ export function QueenEditCard({ queen }: { queen: Queen }) { onChange={(event) => setOrigin(event.target.value)} /> +
+ + +
+ setMotherQueenId(value === "NONE" ? "" : (value ?? "")) + } + > + + + {motherQueenId + ? queens?.find((option) => option.id === motherQueenId)?.code + : "Keine Mutter hinterlegt"} + + + + Keine Mutter hinterlegt + {queens?.map((option) => ( + + {option.code} + + ))} + + +
{error ? (

{error} diff --git a/ApiCore-Frontend/src/app/(app)/queens/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/queens/page.spec.tsx index 44347af..4aa8483 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/page.spec.tsx @@ -35,6 +35,7 @@ describe("QueensPage", () => { inseminationData: null, motherLine: null, fatherLine: null, + motherQueenId: null, status: "ACTIVE", notes: null, createdAt: "", diff --git a/ApiCore-Frontend/src/lib/queens/api.ts b/ApiCore-Frontend/src/lib/queens/api.ts index 61aea59..f209986 100644 --- a/ApiCore-Frontend/src/lib/queens/api.ts +++ b/ApiCore-Frontend/src/lib/queens/api.ts @@ -6,6 +6,7 @@ import { Queen, QueenColonyHistoryEntry, QueenEvaluation, + QueenLineage, QueenStatus, UpdateQueenInput, } from "./types"; @@ -29,6 +30,10 @@ export function fetchQueenHistory(id: string): Promise(`/queens/${id}/history`); } +export function fetchQueenLineage(id: string): Promise { + return apiFetch(`/queens/${id}/lineage`); +} + export function createQueen(input: CreateQueenInput): Promise { return apiFetch("/queens", { method: "POST", diff --git a/ApiCore-Frontend/src/lib/queens/types.ts b/ApiCore-Frontend/src/lib/queens/types.ts index 9d863fd..1b48f6c 100644 --- a/ApiCore-Frontend/src/lib/queens/types.ts +++ b/ApiCore-Frontend/src/lib/queens/types.ts @@ -22,6 +22,7 @@ export interface Queen { inseminationData: string | null; motherLine: string | null; fatherLine: string | null; + motherQueenId: string | null; status: QueenStatus; notes: string | null; createdAt: string; @@ -33,16 +34,39 @@ export interface CreateQueenInput { code: string; breed?: string; origin?: string; + motherQueenId?: string; notes?: string; } export interface UpdateQueenInput { breed?: string; origin?: string; + motherQueenId?: string; notes?: string; status?: QueenStatus; } +export interface QueenLineageAncestor { + id: string; + code: string; + hatchYear: number | null; + status: QueenStatus; + mother: QueenLineageAncestor | null; +} + +export interface QueenLineageDescendant { + id: string; + code: string; + hatchYear: number | null; + status: QueenStatus; + daughters: QueenLineageDescendant[]; +} + +export interface QueenLineage { + queen: QueenLineageAncestor; + descendants: QueenLineageDescendant[]; +} + export interface AssignColonyInput { colonyId: string; notes?: string;