From 961a237335fc11e8fd0d367e0bd587d18b6a1173 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 8 Sep 2026 22:30:19 +0200 Subject: [PATCH] fix: block deactivating an apiary while it still has active colonies Colony.apiaryId is a plain FK, never checked against the apiary's own status. Without a guard, deactivating an apiary silently orphaned any colonies still living there: they kept showing as ACTIVE, but their apiary had disappeared from every "active apiaries" list, dashboard count, and colony-creation picker - a confusing half-state with no warning at all. ApiariesService.deactivate() now throws ConflictException when active colonies remain, so the beekeeper has to move or dissolve them first - matching the same assertActive-style guard pattern already used elsewhere (colonies, e.g. move()/merge()/split()). Co-Authored-By: Claude Sonnet 5 --- .../src/apiaries/apiaries.service.spec.ts | 12 ++++++++++++ ApiCore-Backend/src/apiaries/apiaries.service.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts index 9467667..5afa1a8 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts @@ -12,6 +12,7 @@ describe('ApiariesService', () => { create: jest.Mock; update: jest.Mock; }; + colony: { count: jest.Mock }; }; let auditLog: { record: jest.Mock }; @@ -30,6 +31,7 @@ describe('ApiariesService', () => { create: jest.fn(), update: jest.fn(), }, + colony: { count: jest.fn().mockResolvedValue(0) }, }; auditLog = { record: jest.fn().mockResolvedValue(undefined) }; service = new ApiariesService( @@ -76,6 +78,16 @@ describe('ApiariesService', () => { ); }); + it('deactivate throws ConflictException when active colonies remain', async () => { + prisma.apiary.findUnique.mockResolvedValue(existingApiary); + prisma.colony.count.mockResolvedValue(2); + + await expect( + service.deactivate('apiary-1', 'user-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(prisma.apiary.update).not.toHaveBeenCalled(); + }); + it('reactivate throws ConflictException when already active', async () => { prisma.apiary.findUnique.mockResolvedValue(existingApiary); await expect( diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.ts b/ApiCore-Backend/src/apiaries/apiaries.service.ts index 8a957fa..cdd4300 100644 --- a/ApiCore-Backend/src/apiaries/apiaries.service.ts +++ b/ApiCore-Backend/src/apiaries/apiaries.service.ts @@ -76,6 +76,19 @@ export class ApiariesService { async deactivate(id: string, actingUserId: string): Promise { const before = await this.findById(id); + // Colony.apiaryId is a plain FK, not enforced against apiary status - + // without this guard a colony can silently keep pointing at an + // inactive apiary (still shows ACTIVE, still countable, but its + // "location" has vanished from the active apiary list). Move the + // colonies elsewhere or dissolve them first instead. + const activeColonyCount = await this.prisma.colony.count({ + where: { apiaryId: id, status: 'ACTIVE' }, + }); + if (activeColonyCount > 0) { + throw new ConflictException( + `Apiary ${id} still has ${activeColonyCount} active colonies - move or dissolve them first`, + ); + } const apiary = await this.prisma.apiary.update({ where: { id }, data: { status: 'INACTIVE', deactivatedAt: new Date() },