feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -14,6 +14,7 @@ describe('ColoniesService', () => {
colonyApiaryHistory: { create: jest.Mock };
colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
suggestion: { updateMany: jest.Mock };
};
let prisma: {
colony: {
@@ -46,6 +47,7 @@ describe('ColoniesService', () => {
colonyApiaryHistory: { create: jest.fn() },
colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
suggestion: { updateMany: jest.fn() },
};
prisma = {
colony: {
@@ -255,17 +257,15 @@ describe('ColoniesService', () => {
'user-1',
);
const [[updateArg]] = tx.colony.update.mock.calls as [
[
{
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 updates = tx.colony.update.mock.calls as [
{ where: { id: string }; data: Record<string, unknown> },
][];
expect(updates[0][0]).toEqual({
where: { id: 'colony-3' },
data: { currentQueenId: null },
});
expect(updates[1][0].where.id).toBe('colony-3');
expect(updates[1][0].data.status).toBe('MERGED');
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[
@@ -368,22 +368,16 @@ describe('ColoniesService', () => {
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 updates = tx.colony.update.mock.calls as [
{ where: { id: string }; data: Record<string, unknown> },
][];
expect(updates[0][0]).toEqual({
where: { id: 'colony-1' },
data: { currentQueenId: null },
});
expect(updates[1][0].where).toEqual({ id: 'colony-1' });
expect(updates[1][0].data.status).toBe('DISSOLVED');
expect(updates[1][0].data.deactivatedAt).toBeInstanceOf(Date);
const [[historyCloseArg]] = tx.queenColonyHistory.updateMany.mock.calls as [
[
@@ -460,6 +454,38 @@ describe('ColoniesService', () => {
expect(transitionTypes()).toEqual(['ColonyStatusChanged']);
});
it('reactivate turns the open CLEANUP suggestions of the colony OBSOLETE after locking it', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'SOLD',
});
tx.colony.update.mockResolvedValue({ ...parentColony, status: 'ACTIVE' });
await service.reactivate('colony-1', 'user-1');
expect(tx.suggestion.updateMany).toHaveBeenCalledWith({
where: { colonyId: 'colony-1', intent: 'CLEANUP', status: 'OPEN' },
data: { status: 'OBSOLETE', decidedAt: expect.any(Date) as Date },
});
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.suggestion.updateMany.mock.invocationCallOrder[0],
);
});
it('an edit between two ended statuses releases no queen (nothing living ended)', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
status: 'DISSOLVED',
healthStatus: null,
currentQueenId: 'q1',
});
tx.colony.update.mockResolvedValue({ ...parentColony, status: 'SOLD' });
await service.update('colony-1', { status: 'SOLD' }, 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
@@ -579,7 +605,12 @@ describe('ColoniesService', () => {
expect.objectContaining({
type: 'ColonyEnded',
aggregateId: 'colony-1',
payload: { status: 'DISSOLVED' },
payload: {
status: 'DISSOLVED',
endedOn: berlinToday(),
releasedQueenId: null,
via: 'DISSOLVE',
},
}),
]);
});
@@ -622,6 +653,10 @@ describe('ColoniesService', () => {
'MERGED',
'MERGED',
]);
expect(ended.map((event) => event.payload)).toEqual([
expect.objectContaining({ via: 'MERGE', releasedQueenId: null }),
expect.objectContaining({ via: 'MERGE', releasedQueenId: null }),
]);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }),
tx,
@@ -737,4 +772,56 @@ describe('ColoniesService', () => {
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
});
describe('a colony that ends through an edit', () => {
const living = {
...parentColony,
healthStatus: null,
currentQueenId: 'q1',
};
it.each(['SOLD', 'DECEASED'] as const)(
'update to %s releases the queen with the day of the change and records ColonyEnded via EDIT',
async (status) => {
prisma.colony.findUnique.mockResolvedValue(living);
tx.colony.update.mockResolvedValue({
...living,
status,
currentQueenId: null,
});
await service.update('colony-1', { status }, 'user-1');
const today = berlinToday();
expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({
where: { queenId: 'q1', colonyId: 'colony-1', endDate: null },
data: { endDate: new Date(`${today}T00:00:00.000Z`) },
});
expect(tx.colony.update.mock.calls[0]).toEqual([
{ where: { id: 'colony-1' }, data: { currentQueenId: null } },
]);
expect(events.recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'ColonyStatusChanged' }),
expect.objectContaining({
type: 'ColonyEnded',
payload: {
status,
endedOn: today,
releasedQueenId: 'q1',
via: 'EDIT',
},
}),
]);
},
);
it('releases no queen for an edit that keeps the colony alive', async () => {
prisma.colony.findUnique.mockResolvedValue(living);
tx.colony.update.mockResolvedValue({ ...living, status: 'WEAK' });
await service.update('colony-1', { status: 'WEAK' }, 'user-1');
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
});
});
@@ -14,7 +14,11 @@ import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto';
import { SplitColonyDto } from './dto/split-colony.dto';
import { MergeColonyDto } from './dto/merge-colony.dto';
import { isLivingColonyStatus } from './colony-status';
import { releaseQueenIn } from './colony-lifecycle';
import {
isLivingColonyStatus,
TERMINAL_COLONY_STATUSES,
} from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
@@ -129,6 +133,16 @@ export class ColoniesService {
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
// An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony =
dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
isLivingColonyStatus(before.status);
const releasedQueenId = endsColony
? await releaseQueenIn(tx, id, dayToDate(today))
: null;
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
@@ -148,7 +162,11 @@ export class ColoniesService {
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'EDIT',
}),
);
await this.auditLog.record(
{
@@ -279,6 +297,7 @@ export class ColoniesService {
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
);
}
const today = berlinToday();
await this.prisma.$transaction(async (tx) => {
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
@@ -299,28 +318,23 @@ export class ColoniesService {
// 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).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
dayToDate(today),
);
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
data: {
status: 'MERGED',
deactivatedAt: new Date(),
currentQueenId: null,
},
data: { status: 'MERGED', deactivatedAt: new Date() },
});
await this.events.recordAll(
tx,
colonyTransitionEvents(merged, mergedAway, actingUserId),
colonyTransitionEvents(merged, mergedAway, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'MERGE',
}),
);
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',
@@ -353,27 +367,19 @@ export class ColoniesService {
// Re-dissolving would overwrite deactivatedAt and the terminal status
// (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended.
this.assertActive(before);
const today = berlinToday();
const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today));
const updated = await tx.colony.update({
where: { id },
data: {
status: 'DISSOLVED',
deactivatedAt: new Date(),
currentQueenId: null,
},
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
});
if (before.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: before.currentQueenId,
colonyId: id,
endDate: null,
},
data: { endDate: new Date() },
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
colonyTransitionEvents(before, updated, actingUserId, {
endedOn: today,
releasedQueenId,
via: 'DISSOLVE',
}),
);
await this.auditLog.record(
{
@@ -404,9 +410,15 @@ export class ColoniesService {
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
// The clean-up of the end is moot now (colony row locked first, then
// the suggestions: the order every writer uses).
await tx.suggestion.updateMany({
where: { colonyId: id, intent: 'CLEANUP', status: 'OPEN' },
data: { status: 'OBSOLETE', decidedAt: new Date() },
});
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
colonyTransitionEvents(before, updated, actingUserId, null),
);
await this.auditLog.record(
{
@@ -0,0 +1,108 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import ts from 'typescript';
const SRC = join(__dirname, '..');
const TERMINAL = new Set(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED']);
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
return sourceFiles(path);
}
return path.endsWith('.ts') && !path.endsWith('.spec.ts') ? [path] : [];
});
}
interface Writer {
where: string;
releaseBefore: boolean;
}
/**
* Every function-like node that writes an ended colony status into a `data`
* object (`data: { status: 'SOLD' }`) or passes the DTO whole (`data: {
* ...dto }` in the colonies service, whose status may be ended), with
* whether a `releaseQueenIn(` call stands earlier in the same method.
*/
function writersIn(file: string): Writer[] {
const text = readFileSync(file, 'utf8');
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const isColoniesService = file.endsWith('colonies.service.ts');
const writers: Writer[] = [];
const writesEndedStatus = (
data: ts.ObjectLiteralExpression,
method: ts.Node | undefined,
): boolean =>
data.properties.some(
(property) =>
(ts.isPropertyAssignment(property) &&
property.name.getText() === 'status' &&
ts.isStringLiteral(property.initializer) &&
TERMINAL.has(property.initializer.text)) ||
(isColoniesService &&
method !== undefined &&
ts.isMethodDeclaration(method) &&
method.name.getText() === 'updateIn' &&
ts.isSpreadAssignment(property) &&
property.expression.getText() === 'dto'),
);
const enclosingMethod = (node: ts.Node): ts.Node | undefined => {
for (let up = node.parent; up; up = up.parent) {
if (ts.isMethodDeclaration(up) || ts.isFunctionDeclaration(up)) {
return up;
}
}
return undefined;
};
const visit = (node: ts.Node): void => {
if (
ts.isPropertyAssignment(node) &&
node.name.getText() === 'data' &&
ts.isObjectLiteralExpression(node.initializer) &&
writesEndedStatus(node.initializer, enclosingMethod(node))
) {
const method = enclosingMethod(node);
const body = method?.getText() ?? '';
const release = body.indexOf('releaseQueenIn(');
writers.push({
where: `${file}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}`,
releaseBefore:
method !== undefined &&
release !== -1 &&
method.getStart() + release < node.getStart(),
});
}
ts.forEachChild(node, visit);
};
visit(source);
return writers;
}
/**
* Guard: whoever lets a colony end must end the queen assignment in the
* same transaction. A new way to set DISSOLVED/SOLD/DECEASED/MERGED without
* releaseQueenIn leaves a dead colony holding the queen's unique pointer.
* Checked per method: the release comes before the write in the same method.
*/
describe('a colony that ends releases its queen', () => {
const writers = sourceFiles(SRC).flatMap(writersIn);
it('every write of an ended colony status is preceded by releaseQueenIn in the same method', () => {
expect(writers.filter((writer) => !writer.releaseBefore)).toEqual([]);
});
it('finds the four known ways (dissolve, merge, edit, winter check)', () => {
const places = writers.map((writer) => writer.where);
expect(
places.filter((p) => p.includes('colonies.service.ts')),
).toHaveLength(3);
expect(
places.filter((p) => p.includes('winter-checks.service.ts')),
).toHaveLength(1);
});
});
@@ -13,7 +13,7 @@ function colony(overrides: Partial<Colony> = {}): Colony {
describe('colonyTransitionEvents', () => {
it('records nothing when neither status nor healthStatus changed', () => {
expect(
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'),
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1', null),
).toEqual([]);
});
@@ -22,6 +22,7 @@ describe('colonyTransitionEvents', () => {
colony(),
colony({ healthStatus: 'CRITICAL' }),
'u1',
null,
);
expect(events).toEqual([
{
@@ -38,18 +39,24 @@ describe('colonyTransitionEvents', () => {
});
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)(
'adds ColonyEnded { status: %s } on the transition from living to ended',
'ColonyEnded carries status %s, endedOn, releasedQueenId and via',
(status) => {
const events = colonyTransitionEvents(
colony({ status: 'WEAK' }),
colony({ status }),
null,
{ endedOn: '2027-03-12', releasedQueenId: 'q1', via: 'WINTER_CHECK' },
);
expect(events.map((event) => event.type)).toEqual([
'ColonyStatusChanged',
'ColonyEnded',
]);
expect(events[1].payload).toEqual({ status });
expect(events[1].payload).toEqual({
status,
endedOn: '2027-03-12',
releasedQueenId: 'q1',
via: 'WINTER_CHECK',
});
},
);
@@ -59,6 +66,7 @@ describe('colonyTransitionEvents', () => {
colony({ status: 'DISSOLVED' }),
colony({ status: 'DECEASED' }),
'u1',
null,
).map((event) => event.type),
).toEqual(['ColonyStatusChanged']);
expect(
@@ -66,7 +74,19 @@ describe('colonyTransitionEvents', () => {
colony({ status: 'DISSOLVED' }),
colony({ status: 'ACTIVE' }),
'u1',
null,
).map((event) => event.type),
).toEqual(['ColonyStatusChanged']);
});
it('refuses to build ColonyEnded without the context of the end', () => {
expect(() =>
colonyTransitionEvents(
colony({ status: 'ACTIVE' }),
colony({ status: 'SOLD' }),
'u1',
null,
),
).toThrow('ColonyEnded needs');
});
});
+14 -2
View File
@@ -5,6 +5,7 @@ import {
AnyNewDomainEvent,
ColonyStateSnapshot,
} from '../domain-events/domain-event-types';
import type { ColonyEnd } from './colony-lifecycle';
import { isLivingColonyStatus } from './colony-status';
type ColonyState = Pick<Colony, 'status' | 'healthStatus'>;
@@ -18,12 +19,15 @@ function snapshotOf(colony: ColonyState): ColonyStateSnapshot {
* ColonyStatusChanged as soon as status or healthStatus differs, plus
* ColonyEnded when a living colony became an ended one. Empty when nothing
* the events care about changed. The caller records them in the same
* transaction as the colony update.
* transaction as the colony update. `ended` is the context of the end
* (day, released queen, way) and is required exactly when a living colony
* became an ended one - the caller calls releaseQueenIn first.
*/
export function colonyTransitionEvents(
before: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
actorUserId: string | null,
ended: ColonyEnd | null,
): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] {
if (
before.status === after.status &&
@@ -44,12 +48,20 @@ export function colonyTransitionEvents(
isLivingColonyStatus(before.status) &&
!isLivingColonyStatus(after.status)
) {
if (ended === null) {
throw new Error('ColonyEnded needs the context of the end');
}
events.push({
type: 'ColonyEnded',
aggregateType: 'Colony',
aggregateId: after.id,
actorUserId,
payload: { status: after.status },
payload: {
status: after.status,
endedOn: ended.endedOn,
releasedQueenId: ended.releasedQueenId,
via: ended.via,
},
});
}
return events;
@@ -0,0 +1,49 @@
import type { Prisma } from '../../generated/prisma';
import { releaseQueenIn } from './colony-lifecycle';
describe('releaseQueenIn', () => {
let tx: {
colony: { findUnique: jest.Mock; update: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
};
const asTx = () => tx as unknown as Prisma.TransactionClient;
const endDate = new Date('2027-03-12T00:00:00.000Z');
beforeEach(() => {
tx = {
colony: { findUnique: jest.fn(), update: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
};
});
it('ends the open history with the end date and clears currentQueenId', async () => {
tx.colony.findUnique.mockResolvedValue({ currentQueenId: 'queen-1' });
const released = await releaseQueenIn(asTx(), 'col-1', endDate);
expect(released).toBe('queen-1');
expect(tx.queenColonyHistory.updateMany).toHaveBeenCalledWith({
where: { queenId: 'queen-1', colonyId: 'col-1', endDate: null },
data: { endDate },
});
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'col-1' },
data: { currentQueenId: null },
});
});
it('returns null for a colony without a queen and writes nothing', async () => {
tx.colony.findUnique.mockResolvedValue({ currentQueenId: null });
await expect(releaseQueenIn(asTx(), 'col-1', endDate)).resolves.toBeNull();
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('returns null for an unknown colony', async () => {
tx.colony.findUnique.mockResolvedValue(null);
await expect(releaseQueenIn(asTx(), 'gone', endDate)).resolves.toBeNull();
});
});
@@ -0,0 +1,48 @@
import type { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from '../prisma/db-client';
/** The ways a colony can end; carried by ColonyEnded. */
export type ColonyEndVia = 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK';
/** What ColonyEnded and its handlers need to know about the end. */
export interface ColonyEnd {
/** YYYY-MM-DD, the business day of the end - never read from the clock later. */
endedOn: string;
releasedQueenId: string | null;
via: ColonyEndVia;
}
/**
* Ends the queen assignment of a colony that ends: closes the open
* QueenColonyHistory with `endDate` and clears currentQueenId (a unique
* pointer, so a dead colony must not keep it). The queen's own status does
* not change. Resolves to the queen's id, or null when the colony had none.
*
* Every way a colony ends calls this in the transaction of the end, with
* the colony row locked: currentQueenId is read here, under that lock, and
* the callers build ColonyEnded from the result.
*/
export async function releaseQueenIn(
tx: Prisma.TransactionClient,
colonyId: string,
endDate: Date,
): Promise<string | null> {
assertTransactionClient(tx, 'releaseQueenIn');
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { currentQueenId: true },
});
const queenId = colony?.currentQueenId ?? null;
if (queenId === null) {
return null;
}
await tx.queenColonyHistory.updateMany({
where: { queenId, colonyId, endDate: null },
data: { endDate },
});
await tx.colony.update({
where: { id: colonyId },
data: { currentQueenId: null },
});
return queenId;
}