From 20981ec1ce84b7f885ce6dc5ff375984e537ebef Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 8 Sep 2026 20:58:53 +0200 Subject: [PATCH] fix: release a colony's queen when it is dissolved or merged away Colony.currentQueenId is unique across colonies. dissolve() and merge() set status to DISSOLVED/MERGED but never cleared currentQueenId, so a colony that still had a queen when it was dissolved or merged kept permanently "owning" her - any later attempt to assign that queen to a different colony (e.g. requeening another colony with her) would fail with a unique-constraint conflict, since the dead colony's stale reference was still occupying that slot. Both operations now clear currentQueenId and close the open QueenColonyHistory row for that queen, mirroring the bookkeeping assignColony() and the queen-status-transition fix already do elsewhere. The merge survivor keeps its own queen; a merged-away colony's queen is released, not auto-transferred, since which queen "wins" a merge is a beekeeper decision. Co-Authored-By: Claude Sonnet 5 --- .../src/colonies/colonies.service.spec.ts | 91 ++++++++++++++++++- .../src/colonies/colonies.service.ts | 45 ++++++++- 2 files changed, 129 insertions(+), 7 deletions(-) diff --git a/ApiCore-Backend/src/colonies/colonies.service.spec.ts b/ApiCore-Backend/src/colonies/colonies.service.spec.ts index 5fe154a..09c74a3 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.spec.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.spec.ts @@ -11,6 +11,7 @@ describe('ColoniesService', () => { colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock }; colonyApiaryHistory: { create: jest.Mock }; colonyLineage: { create: jest.Mock }; + queenColonyHistory: { updateMany: jest.Mock }; }; let prisma: { colony: { @@ -39,6 +40,7 @@ describe('ColoniesService', () => { colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() }, colonyApiaryHistory: { create: jest.fn() }, colonyLineage: { create: jest.fn() }, + queenColonyHistory: { updateMany: jest.fn() }, }; prisma = { colony: { @@ -117,7 +119,7 @@ describe('ColoniesService', () => { expect(lineageArg.data.toColonyId).toBe('colony-2'); }); - it('merge marks the merged colony as MERGED and records a MERGE lineage entry', async () => { + it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => { prisma.colony.findUnique .mockResolvedValueOnce(parentColony) .mockResolvedValueOnce({ @@ -127,6 +129,7 @@ describe('ColoniesService', () => { tx.colony.findUnique.mockResolvedValue({ id: 'colony-3', status: 'ACTIVE', + currentQueenId: 'queen-2', }); tx.colony.update.mockResolvedValue({}); tx.colonyLineage.create.mockResolvedValue({}); @@ -138,10 +141,31 @@ describe('ColoniesService', () => { ); const [[updateArg]] = tx.colony.update.mock.calls as [ - [{ where: { id: string }; data: { status: string } }], + [ + { + where: { id: string }; + data: { status: string; currentQueenId: string | null }; + }, + ], ]; expect(updateArg.where.id).toBe('colony-3'); expect(updateArg.data.status).toBe('MERGED'); + expect(updateArg.data.currentQueenId).toBeNull(); + + const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ + [ + { + where: { queenId: string; colonyId: string; endDate: null }; + data: { endDate: Date }; + }, + ], + ]; + expect(historyCloseArg.where).toEqual({ + queenId: 'queen-2', + colonyId: 'colony-3', + endDate: null, + }); + expect(historyCloseArg.data.endDate).toBeInstanceOf(Date); const [[lineageArg]] = tx.colonyLineage.create.mock.calls as [ [{ data: { type: string; fromColonyId: string; toColonyId: string } }], @@ -163,7 +187,7 @@ describe('ColoniesService', () => { it('dissolve sets status DISSOLVED and records DEACTIVATE', async () => { prisma.colony.findUnique.mockResolvedValue(parentColony); - prisma.colony.update.mockResolvedValue({ + tx.colony.update.mockResolvedValue({ ...parentColony, status: 'DISSOLVED', }); @@ -176,6 +200,67 @@ describe('ColoniesService', () => { ); }); + it("dissolve releases the colony's queen so she can be reassigned later", async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + currentQueenId: 'queen-1', + }); + tx.colony.update.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + currentQueenId: null, + }); + + await service.dissolve('colony-1', 'user-1'); + + const [[dissolveUpdateArg]] = tx.colony.update.mock.calls as [ + [ + { + where: { id: string }; + data: { + status: string; + deactivatedAt: Date; + currentQueenId: string | null; + }; + }, + ], + ]; + expect(dissolveUpdateArg.where).toEqual({ id: 'colony-1' }); + expect(dissolveUpdateArg.data.status).toBe('DISSOLVED'); + expect(dissolveUpdateArg.data.currentQueenId).toBeNull(); + expect(dissolveUpdateArg.data.deactivatedAt).toBeInstanceOf(Date); + + const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ + [ + { + where: { queenId: string; colonyId: string; endDate: null }; + data: { endDate: Date }; + }, + ], + ]; + expect(historyCloseArg.where).toEqual({ + queenId: 'queen-1', + colonyId: 'colony-1', + endDate: null, + }); + expect(historyCloseArg.data.endDate).toBeInstanceOf(Date); + }); + + it('dissolve does not touch queen history for a colony with no queen', async () => { + prisma.colony.findUnique.mockResolvedValue({ + ...parentColony, + currentQueenId: null, + }); + tx.colony.update.mockResolvedValue({ + ...parentColony, + status: 'DISSOLVED', + }); + + await service.dissolve('colony-1', 'user-1'); + + expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled(); + }); + it('reactivate throws ConflictException when already active', async () => { prisma.colony.findUnique.mockResolvedValue(parentColony); await expect( diff --git a/ApiCore-Backend/src/colonies/colonies.service.ts b/ApiCore-Backend/src/colonies/colonies.service.ts index 77538c3..2d8fe8c 100644 --- a/ApiCore-Backend/src/colonies/colonies.service.ts +++ b/ApiCore-Backend/src/colonies/colonies.service.ts @@ -172,10 +172,30 @@ export class ColoniesService { throw new NotFoundException(`Colony ${mergedColonyId} not found`); } this.assertActive(merged); + // currentQueenId is unique across colonies - if it stayed set on + // this now-dead colony, any later attempt to assign her queen to a + // different colony would fail with a unique-constraint conflict. + // The survivor keeps its own queen; merged-away queens are simply + // released, not auto-transferred (which colony's queen "wins" a + // merge is a beekeeper decision, not one to guess here). await tx.colony.update({ where: { id: mergedColonyId }, - data: { status: 'MERGED', deactivatedAt: new Date() }, + data: { + status: 'MERGED', + deactivatedAt: new Date(), + currentQueenId: null, + }, }); + if (merged.currentQueenId) { + await tx.queenColonyHistory.updateMany({ + where: { + queenId: merged.currentQueenId, + colonyId: mergedColonyId, + endDate: null, + }, + data: { endDate: new Date() }, + }); + } await tx.colonyLineage.create({ data: { type: 'MERGE', @@ -200,9 +220,26 @@ export class ColoniesService { async dissolve(id: string, actingUserId: string): Promise { const before = await this.findById(id); - const colony = await this.prisma.colony.update({ - where: { id }, - data: { status: 'DISSOLVED', deactivatedAt: new Date() }, + const colony = await this.prisma.$transaction(async (tx) => { + const updated = await tx.colony.update({ + where: { id }, + data: { + status: 'DISSOLVED', + deactivatedAt: new Date(), + currentQueenId: null, + }, + }); + if (before.currentQueenId) { + await tx.queenColonyHistory.updateMany({ + where: { + queenId: before.currentQueenId, + colonyId: id, + endDate: null, + }, + data: { endDate: new Date() }, + }); + } + return updated; }); await this.auditLog.record({ entityType: 'Colony',