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;