fix: mark a colony QUEENLESS when its queen is lost, dies, or is replaced

A very common real-world event - a queen dying over winter, absconding,
being superseded, sold, or replaced - had no side effect on the colony
record at all. Colony.currentQueenId kept pointing at a queen who was
no longer really there, colony.status stayed whatever it was, and the
QUEENLESS dashboard alert could never fire from this path.

QueensService.update() now detects a transition into
LOST/DECEASED/REPLACED/SOLD and, inside the same transaction, clears
currentQueenId on whichever colony currently points at that queen,
flips its status to QUEENLESS (unless the colony is already in a
terminal lifecycle status like DISSOLVED, which is left alone), and
closes the open QueenColonyHistory row - mirroring the bookkeeping
assignColony() already does when a queen changes colonies.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-07 13:30:18 +02:00
1 parent 32f11e5b39
commit cc46fa4d9e
2 files changed
+109 -5

No files matched your search

@@ -8,8 +8,9 @@ import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
describe('QueensService', () => {
let service: QueensService;
let tx: {
queen: { update: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock };
colony: { update: jest.Mock };
colony: { findUnique: jest.Mock; update: jest.Mock };
};
let prisma: {
queen: {
@@ -30,8 +31,12 @@ describe('QueensService', () => {
beforeEach(() => {
tx = {
queen: { update: jest.fn().mockResolvedValue(queen) },
queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() },
colony: { update: jest.fn() },
colony: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
};
prisma = {
queen: {
@@ -163,6 +168,64 @@ describe('QueensService', () => {
expect(prisma.queen.update).not.toHaveBeenCalled();
});
describe('when a queen becomes unavailable', () => {
it('clears currentQueenId and marks the colony QUEENLESS', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue({
id: 'colony-1',
status: 'ACTIVE',
});
await service.update('queen-1', { status: 'DECEASED' }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { currentQueenId: null, status: 'QUEENLESS' },
});
const [[closeArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[{ where: { queenId: string; colonyId: string; endDate: null } }],
];
expect(closeArg.where).toEqual({
queenId: 'queen-1',
colonyId: 'colony-1',
endDate: null,
});
});
it('clears currentQueenId but leaves an already-terminal colony status alone', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue({
id: 'colony-1',
status: 'DISSOLVED',
});
await service.update('queen-1', { status: 'LOST' }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { currentQueenId: null, status: undefined },
});
});
it('does nothing when the queen is not currently assigned to a colony', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
tx.colony.findUnique.mockResolvedValue(null);
await service.update('queen-1', { status: 'SOLD' }, 'user-1');
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('does not touch the colony for a status change that keeps the queen available', async () => {
prisma.queen.findUnique.mockResolvedValue(queen);
await service.update('queen-1', { status: 'RESERVE' }, 'user-1');
expect(tx.colony.findUnique).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
});
});
it('getLineage builds the ancestor chain and the descendant tree', async () => {
const grandmother = {
id: 'queen-gm',
+44 -3
View File
@@ -40,6 +40,19 @@ export interface QueenLineage {
const MAX_LINEAGE_DEPTH = 8;
// Once a queen reaches one of these statuses she is no longer really "in"
// whatever colony currently points at her - a colony's currentQueenId and
// status would otherwise silently go stale (still showing a dead/lost
// queen as current, never flagged QUEENLESS) until someone happens to
// notice and fix it by hand.
const QUEEN_NO_LONGER_AVAILABLE_STATUSES: QueenStatus[] = [
'LOST',
'DECEASED',
'REPLACED',
'SOLD',
];
const TERMINAL_COLONY_STATUSES = ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'];
@Injectable()
export class QueensService {
constructor(
@@ -92,9 +105,37 @@ export class QueensService {
}
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.update({
where: { id },
data: { ...dto },
const becameUnavailable =
dto.status !== undefined &&
dto.status !== before.status &&
QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(dto.status);
const queen = await this.prisma.$transaction(async (tx) => {
const updated = await tx.queen.update({
where: { id },
data: { ...dto },
});
if (becameUnavailable) {
const colony = await tx.colony.findUnique({
where: { currentQueenId: id },
});
if (colony) {
await tx.colony.update({
where: { id: colony.id },
data: {
currentQueenId: null,
status: TERMINAL_COLONY_STATUSES.includes(colony.status)
? undefined
: 'QUEENLESS',
},
});
await tx.queenColonyHistory.updateMany({
where: { queenId: id, colonyId: colony.id, endDate: null },
data: { endDate: new Date() },
});
}
}
return updated;
});
await this.auditLog.record({
entityType: 'Queen',