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:
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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user