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 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-08 22:30:19 +02:00
1 parent 20981ec1ce
commit 961a237335
2 files changed
+25

No files matched your search

@@ -12,6 +12,7 @@ describe('ApiariesService', () => {
create: jest.Mock; create: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
colony: { count: jest.Mock };
}; };
let auditLog: { record: jest.Mock }; let auditLog: { record: jest.Mock };
@@ -30,6 +31,7 @@ describe('ApiariesService', () => {
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
}, },
colony: { count: jest.fn().mockResolvedValue(0) },
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService( 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 () => { it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary); prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect( await expect(
@@ -76,6 +76,19 @@ export class ApiariesService {
async deactivate(id: string, actingUserId: string): Promise<Apiary> { async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id); 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({ const apiary = await this.prisma.apiary.update({
where: { id }, where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() }, data: { status: 'INACTIVE', deactivatedAt: new Date() },