feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

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 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

@@ -0,0 +1,90 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState(
'api-1',
'2026-08-20',
ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
);
function tx(treatments: object[], varroaCount = 0) {
return {
colony: {
findUnique: jest.fn().mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-04-01'),
feedStatus: null,
currentQueen: null,
}),
},
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
varroaMeasurement: {
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(varroaCount),
},
treatment: {
findMany: jest.fn().mockResolvedValue(treatments),
count: jest.fn().mockResolvedValue(0),
},
feeding: { count: jest.fn().mockResolvedValue(0) },
winterCheck: { findMany: jest.fn().mockResolvedValue([]) },
};
}
describe('loadAutopilotInputIn', () => {
it('takes the treatment with the latest end day, not the latest date', async () => {
const db = tx([
{
id: 'old',
date: d('2026-07-01'),
startDate: null,
endDate: d('2026-08-10'),
},
{
id: 'backdated',
date: d('2026-07-20'),
startDate: null,
endDate: null,
},
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]);
const input = await loadAutopilotInputIn(db as never, 'c1', season);
expect(input?.lastTreatment).toEqual({
id: 'old',
startDay: '2026-07-01',
endDay: '2026-08-10',
});
});
it('counts only measurements after the end day', async () => {
const db = tx([
{
id: 't',
date: d('2026-08-01'),
startDate: null,
endDate: d('2026-08-10'),
},
]);
await loadAutopilotInputIn(db as never, 'c1', season);
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
});
});
it('has no last treatment without treatments', async () => {
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
expect(input?.lastTreatment).toBeNull();
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
});
});
@@ -0,0 +1,139 @@
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import { utcDay } from '../domain-events/domain-event-types';
import type { AutopilotInput } from './autopilot.types';
/**
* Until Task 27 passes the real start of the late summer, the feedings and
* treatments of A4-A6 are counted from this day of the year (the built-in
* calendar day of TRACHTENDE).
*/
const LATE_SUMMER_FALLBACK_START = '08-01';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
function lateSummerStart(season: SeasonState): string | null {
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
: null;
}
/**
* What the autopilot rules need of one colony, in a fixed sequence of
* queries (no query per rule, none per record). Resolves to null for a
* colony that does not exist. The caller holds the colony row lock, so the
* picture stays consistent while the rules run.
*/
export async function loadAutopilotInputIn(
tx: Prisma.TransactionClient,
colonyId: string,
season: SeasonState,
): Promise<AutopilotInput | null> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: {
id: true,
code: true,
status: true,
colonyType: true,
apiaryId: true,
createdAt: true,
feedStatus: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
});
if (colony === null) {
return null;
}
const inspection = await tx.inspection.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: {
id: true,
date: true,
colonyStrength: true,
feedStatus: true,
queenCellsPresent: true,
},
});
const varroa = await tx.varroaMeasurement.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: { id: true, date: true },
});
// The latest END day wins, not the latest entry: a backdated or
// overlapping treatment must not hide one that ends later.
const treatments = await tx.treatment.findMany({
where: { colonyId },
orderBy: [{ date: 'desc' }, { id: 'asc' }],
select: { id: true, date: true, startDate: true, endDate: true },
});
const lastTreatment = treatments
.map((treatment) => ({
id: treatment.id,
startDay: utcDay(treatment.startDate ?? treatment.date),
endDay: utcDay(treatment.endDate ?? treatment.date),
}))
.reduce<{ id: string; startDay: string; endDay: string } | null>(
(latest, candidate) =>
latest === null || candidate.endDay > latest.endDay
? candidate
: latest,
null,
);
const varroaSinceLastTreatmentEnd =
lastTreatment !== null &&
(await tx.varroaMeasurement.count({
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
})) > 0;
const feedingsSinceInspection = await tx.feeding.count({
where: {
colonyId,
...(inspection === null ? {} : { date: { gte: inspection.date } }),
},
});
const lateSummer = lateSummerStart(season);
const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } });
const treatmentsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.treatment.count({
where: { colonyId, date: sinceLateSummer },
});
const winterChecks = await tx.winterCheck.findMany({
where: { colonyId, winterYear: { gte: season.year - 1 } },
select: { winterYear: true, season: true },
orderBy: [{ winterYear: 'asc' }, { season: 'asc' }],
});
return {
colony: {
id: colony.id,
code: colony.code,
status: colony.status,
colonyType: colony.colonyType,
apiaryId: colony.apiaryId,
createdOn: berlinDayOf(colony.createdAt),
feedStatus: colony.feedStatus,
},
lastInspection:
inspection === null
? null
: { ...inspection, date: utcDay(inspection.date) },
lastVarroa:
varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) },
lastTreatment,
varroaSinceLastTreatmentEnd,
feedingsSinceInspection,
feedingsSinceLateSummer,
treatmentsSinceLateSummer,
queen: colony.currentQueen,
winterChecks,
season,
};
}
@@ -0,0 +1,372 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { evaluateAutopilot } from './autopilot-rules';
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
type RuleName = 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL' | 'VARROA_THRESHOLD';
function activeRule(trigger: RuleName, override?: object) {
return {
id: null,
active: true,
config: ruleConfigOf(trigger, {
...DEFAULT_RULES[trigger].config,
...override,
}),
actionTemplate: null,
};
}
function configs(autopilotOverride?: object): AutopilotConfigs {
return {
autopilot: activeRule('SEASON_AUTOPILOT', autopilotOverride),
interval: activeRule('INSPECTION_INTERVAL'),
varroa: activeRule('VARROA_THRESHOLD'),
phenology: ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
} as AutopilotConfigs;
}
function input(
today: string,
patch: Partial<AutopilotInput> = {},
): AutopilotInput {
return {
colony: {
id: 'col_1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api_1',
createdOn: '2025-04-01',
feedStatus: null,
},
lastInspection: null,
lastVarroa: { id: 'var_1', date: today },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api_1', today, configs().phenology),
...patch,
};
}
const inspectionOn = (date: string) => ({
id: 'insp_9',
date,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
});
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
describe('autopilot rules', () => {
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', () => {
const today = '2026-05-12';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-04') }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-11',
priority: 'HIGH',
anchor: 'insp_9',
requiredPermission: 'inspections:read',
});
expect(result[0].evidence).toMatchObject({
colonyId: 'col_1',
inspectionId: 'insp_9',
lastInspectedOn: '2026-05-04',
intervalDays: 7,
segment: 'SCHWARMZEIT',
});
});
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', () => {
const today = '2026-05-12';
const inTwo = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-07') }),
configs(),
today,
);
expect(inTwo[0]).toMatchObject({
dueDate: '2026-05-14',
priority: 'NORMAL',
});
const inThree = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-08') }),
configs(),
today,
);
expect(inThree).toEqual([]);
const winter = '2026-12-05';
expect(
evaluateAutopilot(
input(winter, {
lastInspection: inspectionOn('2026-09-01'),
lastVarroa: { id: 'var_1', date: '2026-12-01' },
}),
configs(),
winter,
),
).toEqual([]);
});
it('A1: due today is not overdue; no inspection ever uses the anchor none', () => {
const today = '2026-05-12';
const dueToday = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-05') }),
configs(),
today,
);
expect(dueToday[0].priority).toBe('NORMAL');
const base = input(today);
const never = evaluateAutopilot(
input(today, { colony: { ...base.colony, createdOn: '2026-05-01' } }),
configs(),
today,
);
expect(never[0]).toMatchObject({ anchor: 'none', dueDate: '2026-05-08' });
expect(never[0].evidence).toMatchObject({
inspectionId: null,
lastInspectedOn: null,
});
});
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', () => {
const today = '2026-04-01';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2025-09-20') }),
configs(),
today,
);
expect(result[0]).toMatchObject({
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
dueDate: '2026-03-20',
priority: 'HIGH',
anchor: 'insp_9',
});
});
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-06' },
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode: 'VARROA_MEASURE_DUE',
dueDate: '2026-06-03',
priority: 'NORMAL',
anchor: 'var_7',
requiredPermission: 'varroa:read',
});
});
it('A2: a measurement 27 days ago yields nothing', () => {
const today = '2026-06-10';
expect(
evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-14' },
}),
configs(),
today,
),
).toEqual([]);
});
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', () => {
const today = '2026-11-20';
const result = evaluateAutopilot(
input(today, { lastVarroa: { id: 'var_2', date: '2026-09-01' } }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
intent: 'VARROA_MEASURE',
reasonCode: 'VARROA_WINTER_MEASURE',
dueDate: '2026-11-15',
anchor: 'winter-2026',
});
});
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', () => {
const today = '2026-08-16';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT,
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A3',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
dueDate: '2026-08-16',
priority: 'NORMAL',
anchor: 'tre_1',
requiredPermission: 'varroa:read',
});
expect(result[0].evidence).toMatchObject({
treatmentId: 'tre_1',
endedOn: '2026-08-02',
});
});
it('A3: nothing 13 days after the end, nothing once a measurement exists since', () => {
const early = '2026-08-15';
expect(
evaluateAutopilot(
input(early, {
lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT,
}),
configs(),
early,
),
).toEqual([]);
const late = '2026-08-20';
expect(
evaluateAutopilot(
input(late, {
lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT,
varroaSinceLastTreatmentEnd: true,
}),
configs(),
late,
),
).toEqual([]);
});
it('A3: honours recheckAfterDays of the varroa rule', () => {
const today = '2026-08-09';
const cfg = configs();
cfg.varroa = activeRule('VARROA_THRESHOLD', {
recheckAfterDays: 7,
}) as AutopilotConfigs['varroa'];
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT,
}),
cfg,
today,
);
expect(result.map((c) => c.rule)).toEqual(['A3']);
expect(result[0].dueDate).toBe('2026-08-09');
});
it('returns the candidates in the order A1, A2', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2']);
});
it('A2 is not proposed while A3 applies: the recheck is the measurement', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
lastTreatment: {
id: 'tre_1',
startDay: '2026-05-01',
endDay: '2026-05-10',
},
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A3']);
});
it('A3 only looks at treatments that ended at most 60 days ago', () => {
const today = '2026-08-16';
const base = { lastInspection: inspectionOn('2026-08-10') };
const at = (endDay: string) =>
evaluateAutopilot(
input(today, {
...base,
lastTreatment: { id: 'tre_1', startDay: '2026-05-01', endDay },
}),
configs(),
today,
).filter((c) => c.rule === 'A3');
expect(at('2026-06-17')).toHaveLength(1); // 60 days ago
expect(at('2026-06-16')).toHaveLength(0); // 61 days ago
});
it('a rule switched off in config.rules yields nothing', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
expect(
evaluateAutopilot(base, configs(), today).map((c) => c.rule),
).toEqual(['A1']);
const a1Off = configs({
rules: { ...configs().autopilot.config.rules, A1: false },
});
expect(evaluateAutopilot(base, a1Off, today)).toEqual([]);
});
it('SEASON_AUTOPILOT switched off yields nothing; INSPECTION_INTERVAL off only silences A1', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
const off = configs();
off.autopilot = { ...off.autopilot, active: false };
expect(evaluateAutopilot(base, off, today)).toEqual([]);
const noInterval = configs();
noInterval.interval = { ...noInterval.interval, active: false };
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
});
});
@@ -0,0 +1,180 @@
import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due';
import { varroaMeasurementDue } from '../season/varroa-due';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotInput,
AutopilotRule,
} from './autopilot.types';
/**
* A3 only follows treatments that ended at most this many days ago, so old
* history without a later measurement does not produce suggestions.
*/
const RECHECK_MAX_AGE_DAYS = 60;
/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */
const INSPECTION_LEAD_DAYS = 2;
const a1Inspection: AutopilotRule = (input, configs, today) => {
if (!configs.interval.active) {
return null;
}
const due = inspectionDueOn(
{
createdOn: input.colony.createdOn,
lastInspectedOn: input.lastInspection?.date ?? null,
},
input.season,
configs.interval.config,
);
if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) {
return null;
}
return {
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE',
evidence: {
colonyId: input.colony.id,
inspectionId: input.lastInspection?.id ?? null,
lastInspectedOn: input.lastInspection?.date ?? null,
dueOn: due.dueOn,
intervalDays: due.intervalDays,
segment: input.season.segment,
},
dueDate: due.dueOn,
priority: due.dueOn < today ? 'HIGH' : 'NORMAL',
anchor: input.lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A2: the varroa measurement interval ran out, or the winter window is open. */
const a2VarroaMeasure: AutopilotRule = (input, configs, today) => {
const due = varroaMeasurementDue(
{
createdOn: input.colony.createdOn,
lastMeasuredOn: input.lastVarroa?.date ?? null,
lastMeasurementId: input.lastVarroa?.id ?? null,
},
input.season,
configs.autopilot.config,
today,
);
if (due === null || due.dueOn > today) {
return null;
}
return {
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode:
due.kind === 'WINTER_WINDOW'
? 'VARROA_WINTER_MEASURE'
: 'VARROA_MEASURE_DUE',
evidence: {
colonyId: input.colony.id,
measurementId: input.lastVarroa?.id ?? null,
lastMeasuredOn: input.lastVarroa?.date ?? null,
dueOn: due.dueOn,
intervalDays: configs.autopilot.config.varroaMeasureIntervalDays,
},
dueDate: due.dueOn,
priority: 'NORMAL',
anchor: due.anchor,
requiredPermission: 'varroa:read',
};
};
/** A3: a treatment ended long enough ago and nothing was measured since. */
const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
const treatment = input.lastTreatment;
if (
treatment === null ||
input.varroaSinceLastTreatmentEnd ||
daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS
) {
return null;
}
const recheckAfterDays = configs.varroa.config.recheckAfterDays;
const dueOn = addDays(treatment.endDay, recheckAfterDays);
if (dueOn > today) {
return null;
}
return {
rule: 'A3',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
evidence: {
colonyId: input.colony.id,
treatmentId: treatment.id,
endedOn: treatment.endDay,
recheckOn: dueOn,
},
dueDate: dueOn,
priority: 'NORMAL',
anchor: treatment.id,
// The action is a varroa measurement.
requiredPermission: 'varroa:read',
};
};
/** A4-A10 follow with Task 27. */
export const AUTOPILOT_RULES: Readonly<
Partial<Record<AutopilotRuleId, AutopilotRule>>
> = {
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
};
const RULE_ORDER: readonly AutopilotRuleId[] = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
];
/**
* The candidates for one colony, always in the order A1...A10 so concurrent
* syncs take the proposal locks in the same order. A switched-off autopilot
* or rule yields nothing.
*/
export function evaluateAutopilot(
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
): AutopilotCandidate[] {
if (!configs.autopilot.active) {
return [];
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id];
if (rule === undefined || !configs.autopilot.config.rules[id]) {
continue;
}
const candidate = rule(input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
}
// The recheck after a treatment is the measurement: A2 would ask twice.
return candidates.some((candidate) => candidate.rule === 'A3')
? candidates.filter((candidate) => candidate.rule !== 'A2')
: candidates;
}
@@ -0,0 +1,181 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { AutopilotEventHandlers } from './autopilot.handlers';
import type { AutopilotService } from './autopilot.service';
const EXPECTED_TYPES: DomainEventType[] = [
'InspectionRecorded',
'InspectionCorrected',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
];
function event(
type: DomainEventType,
aggregateId: string,
payload: object,
): AnyDomainEventEnvelope {
return {
id: 'evt-1',
type,
aggregateType: 'Colony',
aggregateId,
payload,
actorUserId: null,
correlationId: 'cor-1',
occurredAt: new Date(),
attempts: 0,
} as AnyDomainEventEnvelope;
}
describe('AutopilotEventHandlers', () => {
let autopilot: { syncColonyIn: jest.Mock };
let tx: { task: { findUnique: jest.Mock } };
let handlers: AutopilotEventHandlers;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService,
);
});
afterEach(() => {
jest.useRealTimers();
});
const run = (e: AnyDomainEventEnvelope) =>
handlers.colonySync(
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
const synced = () =>
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
it('is registered as autopilot.colony-sync for the listed events', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.colonySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
});
it.each([
['InspectionRecorded', { colonyId: 'c1' }],
['InspectionCorrected', { colonyId: 'c1' }],
['VarroaMeasured', { colonyId: 'c1' }],
['VarroaCorrected', { colonyId: 'c1' }],
['TreatmentRecorded', { colonyId: 'c1' }],
['TreatmentCorrected', { colonyId: 'c1' }],
['FeedingRecorded', { colonyId: 'c1' }],
['FeedingCorrected', { colonyId: 'c1' }],
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
] as [DomainEventType, object][])(
'%s syncs the colony of its payload for the Berlin day',
async (type, payload) => {
await run(event(type, 'aggregate-1', payload));
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
tx,
'c1',
'2026-05-12',
);
},
);
it.each([
'ColonyStatusChanged',
'InspectionOverdue',
'VarroaMeasurementDue',
] as const)('%s syncs the aggregate colony', async (type) => {
await run(event(type, 'c9', {}));
expect(synced()).toEqual(['c9']);
});
it('syncs every colony of a harvest, in id order', async () => {
await run(
event('HarvestRecorded', 'h1', {
apiaryId: 'a',
colonyIds: ['c3', 'c1', 'c2', 'c1'],
}),
);
expect(synced()).toEqual(['c1', 'c2', 'c3']);
});
it('syncs the new and the parent colony of a split', async () => {
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
expect(synced()).toEqual(['mother', 'new']);
});
it('syncs the colony and the previous colony of a queen assignment', async () => {
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
);
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
);
expect(synced()).toEqual(['c1', 'c2', 'c2']);
});
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
await run(
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
);
await run(
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
);
tx.task.findUnique
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
.mockResolvedValueOnce(null);
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
expect(synced()).toEqual(['c1', 'c4']);
});
it('does nothing for a queen event without a colony', async () => {
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
await expect(
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
).rejects.toThrow('db down');
});
});
@@ -0,0 +1,112 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { berlinToday } from '../common/dates';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { AutopilotService } from './autopilot.service';
const COLONY_SYNC_EVENTS = [
'InspectionRecorded',
'InspectionCorrected',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
] as const satisfies readonly DomainEventType[];
type ColonySyncEvent = AnyDomainEventEnvelope<
(typeof COLONY_SYNC_EVENTS)[number]
>;
/** The colony of a task, if it is about one. */
async function taskColonyIdIn(
tx: Prisma.TransactionClient,
taskId: string,
): Promise<string | null> {
const task = await tx.task.findUnique({
where: { id: taskId },
select: { relatedType: true, relatedId: true },
});
return task?.relatedType === 'COLONY' ? task.relatedId : null;
}
/** The colonies an event is about; none if it is about no colony. */
async function colonyIdsOf(
event: ColonySyncEvent,
tx: Prisma.TransactionClient,
): Promise<(string | null)[]> {
switch (event.type) {
case 'InspectionRecorded':
case 'InspectionCorrected':
case 'VarroaMeasured':
case 'VarroaCorrected':
case 'TreatmentRecorded':
case 'TreatmentCorrected':
case 'FeedingRecorded':
case 'FeedingCorrected':
case 'WithdrawalEnded':
case 'QueenAged':
return [event.payload.colonyId];
case 'HarvestRecorded':
case 'HarvestCorrected':
return event.payload.colonyIds;
case 'ColonyStatusChanged':
case 'InspectionOverdue':
case 'VarroaMeasurementDue':
return [event.aggregateId];
case 'ColonySplit':
// The aggregate is the new colony.
return [event.aggregateId, event.payload.parentColonyId];
case 'QueenAssigned':
return [event.payload.colonyId, event.payload.previousColonyId];
case 'TaskCompleted':
return [
event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null,
];
case 'TaskUpdated':
return [await taskColonyIdIn(tx, event.aggregateId)];
}
}
/**
* Reconciles the autopilot suggestions of the colonies an event touches.
* Pure DB writer through the delivery transaction; the colonies are synced
* in id order, so two deliveries never lock them the other way round.
*/
@Injectable()
export class AutopilotEventHandlers {
constructor(private readonly autopilot: AutopilotService) {}
@OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync')
async colonySync(
event: ColonySyncEvent,
tx: Prisma.TransactionClient,
): Promise<void> {
const colonyIds = [
...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)),
].sort();
if (colonyIds.length === 0) {
return;
}
const today = berlinToday();
for (const colonyId of colonyIds) {
await this.autopilot.syncColonyIn(tx, colonyId, today);
}
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { SeasonModule } from '../season/season.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutopilotEventHandlers } from './autopilot.handlers';
import { AutopilotService } from './autopilot.service';
import { AutopilotStep } from './autopilot.step';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
/**
* The season autopilot: the event handler and the daily steps that keep the
* autopilot suggestions of each colony in line with the rules. Imported by
* AppModule and WorkerModule (the handler) and by JobsModule (the steps).
*/
@Module({
imports: [SeasonModule, SuggestionsModule],
providers: [
AutopilotService,
AutopilotEventHandlers,
AutopilotStep,
SuggestionsExpiryStep,
],
exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep],
})
export class AutopilotModule {}
@@ -0,0 +1,333 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_RULES,
readRule,
ruleConfigOf,
} from '../automation/rule-config';
import { lockRowForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar';
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { loadAutopilotInputIn } from './autopilot-input';
import { AutopilotService } from './autopilot.service';
import type { AutopilotInput } from './autopilot.types';
jest.mock('../prisma/row-lock');
jest.mock('../automation/rule-config', () => ({
...jest.requireActual<object>('../automation/rule-config'),
readRule: jest.fn(),
}));
jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate);
const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn);
const TODAY = '2026-05-12';
const PHENOLOGY = ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config);
function rule(trigger: string, override: object = {}, active = true) {
return {
id: null,
active,
config: ruleConfigOf(trigger as never, {
...DEFAULT_RULES[trigger as 'SEASON_AUTOPILOT'].config,
...override,
}),
actionTemplate: null,
};
}
function input(lastInspectedOn: string | null): AutopilotInput {
return {
colony: {
id: 'col-1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdOn: '2026-04-20',
feedStatus: null,
},
lastInspection:
lastInspectedOn === null
? null
: {
id: 'insp-9',
date: lastInspectedOn,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
},
lastVarroa: { id: 'var-1', date: '2026-05-10' },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api-1', TODAY, PHENOLOGY),
};
}
describe('AutopilotService', () => {
let tx: {
$connect?: undefined;
colony: { findUnique: jest.Mock };
suggestion: { findMany: jest.Mock };
};
let suggestions: {
proposeIn: jest.Mock;
refreshIn: jest.Mock;
resolveIn: jest.Mock;
};
let seasons: { statesFor: jest.Mock };
let service: AutopilotService;
let rules: Record<string, ReturnType<typeof rule>>;
beforeEach(() => {
jest.resetAllMocks();
tx = {
colony: {
findUnique: jest
.fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
};
suggestions = {
proposeIn: jest.fn().mockResolvedValue({ id: 'new' }),
refreshIn: jest.fn().mockResolvedValue(undefined),
resolveIn: jest
.fn()
.mockImplementation((_tx, ids: string[]) => ids.length),
};
seasons = {
statesFor: jest
.fn()
.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
),
};
rules = {
SEASON_AUTOPILOT: rule('SEASON_AUTOPILOT'),
INSPECTION_INTERVAL: rule('INSPECTION_INTERVAL'),
VARROA_THRESHOLD: rule('VARROA_THRESHOLD'),
PHENOLOGY: rule('PHENOLOGY'),
};
readRuleMock.mockImplementation((_db, trigger) =>
Promise.resolve(rules[trigger] as never),
);
lockRow.mockResolvedValue(true);
loadInput.mockResolvedValue(input('2026-05-03'));
service = new AutopilotService(
suggestions as unknown as SuggestionsService,
seasons,
);
});
const sync = () => service.syncColonyIn(tx as never, 'col-1', TODAY);
it('locks the colony row first and does nothing for an ended colony', async () => {
tx.colony.findUnique.mockResolvedValue({
status: 'DECEASED',
apiaryId: 'api-1',
});
const result = await sync();
expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
expect(lockRow.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('does nothing when the colony row is gone', async () => {
lockRow.mockResolvedValue(false);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(tx.colony.findUnique).not.toHaveBeenCalled();
});
it('proposes a new candidate with the autopilot source, dedupe inputs and expiry', async () => {
const result = await sync();
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: 'col-1',
colonyId: 'col-1',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-10',
anchor: 'insp-9',
requiredPermission: 'inspections:read',
expiresAt: '2026-06-11',
}),
);
});
it('keeps an overdue suggestion alive for the full ttl from today', async () => {
loadInput.mockResolvedValue(input('2026-03-25'));
await sync();
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
dueDate: '2026-04-01',
expiresAt: '2026-06-11',
}),
);
});
it('refreshes an open suggestion with the same key instead of creating one', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-1',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9',
expiresAt: new Date('2026-06-01T00:00:00.000Z'),
},
]);
const result = await sync();
expect(result).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'sug-1', {
reasonCode: 'INSPECTION_DUE',
evidence: expect.objectContaining({ inspectionId: 'insp-9' }) as object,
dueDate: '2026-05-10',
priority: 'HIGH',
expiresAt: '2026-06-01',
});
});
it('drops a candidate when proposeIn finds an open task of the same intent', async () => {
suggestions.proposeIn.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
});
it('resolves open autopilot suggestions whose key vanished, before proposing, never rule or chain ones', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-old',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-1',
expiresAt: null,
},
]);
const result = await sync();
expect(tx.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { colonyId: 'col-1', source: 'AUTOPILOT', status: 'OPEN' },
}),
);
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['sug-old'],
'Bedingung entfällt',
);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 1 });
expect(suggestions.resolveIn.mock.invocationCallOrder[0]).toBeLessThan(
suggestions.proposeIn.mock.invocationCallOrder[0],
);
});
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', async () => {
rules.SEASON_AUTOPILOT = rule('SEASON_AUTOPILOT', {}, false);
tx.suggestion.findMany.mockResolvedValue([
{ id: 'a', dedupeKey: 'k1', expiresAt: null },
{ id: 'b', dedupeKey: 'k2', expiresAt: null },
]);
const result = await sync();
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['a', 'b'],
'Autopilot aus',
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 2 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('proposes the candidates in the order A1, A2', async () => {
loadInput.mockResolvedValue({
...input('2026-04-20'),
lastVarroa: { id: 'var-1', date: '2026-04-01' },
});
await sync();
expect(
suggestions.proposeIn.mock.calls.map(
(call: [unknown, { intent: string }]) => call[1].intent,
),
).toEqual(['INSPECT', 'VARROA_MEASURE']);
});
it('uses preloaded configs and seasons without reading rules or seasons again', async () => {
const season = calendarSeasonState('api-1', TODAY, PHENOLOGY);
const preload = {
configs: {
autopilot: rules.SEASON_AUTOPILOT,
interval: rules.INSPECTION_INTERVAL,
varroa: rules.VARROA_THRESHOLD,
phenology: PHENOLOGY,
},
seasons: new Map([['api-1', season]]),
};
readRuleMock.mockClear();
const result = await service.syncColonyIn(
tx as never,
'col-1',
TODAY,
preload,
);
expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
});
it('preloadFor reads the four rules and the seasons once', async () => {
const preload = await service.preloadFor(tx as never, ['api-1'], TODAY);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(preload.seasons.has('api-1')).toBe(true);
expect(preload.configs.phenology).toEqual(PHENOLOGY);
});
it('warns and does nothing when the apiary has no season state or the input is gone', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
seasons.statesFor.mockResolvedValue(new Map());
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('season'));
seasons.statesFor.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
);
loadInput.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
});
@@ -0,0 +1,184 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { addDays } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service';
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
import { loadAutopilotInputIn } from './autopilot-input';
import { evaluateAutopilot } from './autopilot-rules';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotSyncResult,
} from './autopilot.types';
/** Rules and seasons a daily step reads once for all its colonies. */
export interface AutopilotPreload {
configs: AutopilotConfigs;
seasons: ReadonlyMap<string, SeasonState>;
}
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
/**
* A suggestion lives suggestionTtlDays from its due day, from today if it is
* already overdue: a condition that has lasted longer than the ttl would
* otherwise expire the day it is proposed and never be seen.
*/
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
return addDays(dueDate > today ? dueDate : today, ttlDays);
}
/**
* Reconciles the open autopilot suggestions of one colony with what the
* rules say today. It writes suggestions only, never tasks or colony data,
* and leaves rule and chain suggestions alone.
*/
@Injectable()
export class AutopilotService {
private readonly logger = new Logger(AutopilotService.name);
constructor(
private readonly suggestions: SuggestionsService,
private readonly seasons: SeasonService,
) {}
/** The rules and the season of the given apiaries, read once for many colonies. */
async preloadFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<AutopilotPreload> {
return {
configs: await this.readConfigs(db),
seasons: await this.seasons.statesFor(db, apiaryIds, today),
};
}
/**
* Locks the colony row, then: ended colony - nothing; autopilot off - all
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
* gone are RESOLVED, those with the same key refreshed, new candidates
* proposed (in the order A1...A10, so concurrent syncs take the proposal
* locks in the same order). A candidate is dropped by `proposeIn` when an
* open suggestion or task of the same intent exists. A colony that has
* ended is skipped here; its open suggestions become OBSOLETE through the
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
* it limits the next-steps view, not what is proposed. `preload` spares a
* daily step the rule and season reads per colony.
*/
async syncColonyIn(
tx: Prisma.TransactionClient,
colonyId: string,
today: string,
preload?: AutopilotPreload,
): Promise<AutopilotSyncResult> {
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return NOTHING;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true, apiaryId: true },
});
if (colony === null || !isLivingColonyStatus(colony.status)) {
return NOTHING;
}
const configs = preload?.configs ?? (await this.readConfigs(tx));
const autopilot = configs.autopilot;
const open = await tx.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
select: { id: true, dedupeKey: true, expiresAt: true },
orderBy: { id: 'asc' },
});
if (!autopilot.active) {
const resolved = await this.suggestions.resolveIn(
tx,
open.map((suggestion) => suggestion.id),
'Autopilot aus',
);
return { ...NOTHING, resolved };
}
const season = (
preload?.seasons ??
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
).get(colony.apiaryId);
if (season === undefined) {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING;
}
const input = await loadAutopilotInputIn(tx, colonyId, season);
if (input === null) {
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
return NOTHING;
}
const candidates = evaluateAutopilot(input, configs, today);
const keyOf = (candidate: AutopilotCandidate): string =>
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
const currentKeys = new Set(candidates.map(keyOf));
const resolved = await this.suggestions.resolveIn(
tx,
open
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
.map((s) => s.id),
'Bedingung entfällt',
);
const openByKey = new Map(
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
);
const ttlDays = autopilot.config.suggestionTtlDays;
let opened = 0;
let refreshed = 0;
for (const candidate of candidates) {
const existing = openByKey.get(keyOf(candidate));
if (existing !== undefined) {
await this.suggestions.refreshIn(tx, existing.id, {
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
dueDate: candidate.dueDate,
priority: candidate.priority,
expiresAt:
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
});
refreshed += 1;
continue;
}
const created = await this.suggestions.proposeIn(tx, {
ruleKey: candidate.ruleKey,
source: 'AUTOPILOT',
mode: candidate.mode,
intent: candidate.intent,
priority: candidate.priority,
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
requiredPermission: candidate.requiredPermission,
dueDate: candidate.dueDate,
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
anchor: candidate.anchor,
notify: false,
});
if (created !== null) {
opened += 1;
}
}
return { opened, refreshed, resolved };
}
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
return {
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
interval: await readRule(db, 'INSPECTION_INTERVAL'),
varroa: await readRule(db, 'VARROA_THRESHOLD'),
phenology: (await readRule(db, 'PHENOLOGY')).config,
};
}
}
@@ -0,0 +1,95 @@
import { Logger } from '@nestjs/common';
import type { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotStep } from './autopilot.step';
import type { AutopilotService } from './autopilot.service';
describe('AutopilotStep', () => {
const tx = { tx: true };
let findMany: jest.Mock;
let transaction: jest.Mock;
let syncColonyIn: jest.Mock;
let preloadFor: jest.Mock;
const PRELOAD = { configs: {}, seasons: new Map() };
let step: AutopilotStep;
beforeEach(() => {
findMany = jest.fn().mockResolvedValue([
{ id: 'c1', apiaryId: 'a1' },
{ id: 'c2', apiaryId: 'a1' },
{ id: 'c3', apiaryId: 'a2' },
]);
transaction = jest.fn((work: (client: unknown) => unknown) => work(tx));
syncColonyIn = jest
.fn()
.mockResolvedValue({ opened: 1, refreshed: 0, resolved: 2 });
preloadFor = jest.fn().mockResolvedValue(PRELOAD);
step = new AutopilotStep(
{
colony: { findMany },
$transaction: transaction,
} as unknown as PrismaService,
{ syncColonyIn, preloadFor } as unknown as AutopilotService,
);
});
afterEach(() => jest.restoreAllMocks());
it('is the autopilot step', () => {
expect(step.name).toBe('autopilot');
});
it('syncs every living colony in a transaction of its own and sums up', async () => {
const result = await step.run('2026-05-12');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
orderBy: { id: 'asc' },
}),
);
expect(transaction).toHaveBeenCalledTimes(3);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c1', '2026-05-12', PRELOAD);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c3', '2026-05-12', PRELOAD);
expect(result).toEqual({
autopilotColonies: 3,
suggestionsOpened: 3,
suggestionsResolved: 6,
});
});
it('reads rules and seasons once for all colonies', async () => {
await step.run('2026-05-12');
expect(preloadFor).toHaveBeenCalledTimes(1);
expect(preloadFor).toHaveBeenCalledWith(
expect.anything(),
['a1', 'a2'],
'2026-05-12',
);
});
it('a failing colony does not stop the others; the step fails with the counters', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
syncColonyIn
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ opened: 1, refreshed: 0, resolved: 0 });
const failure = await step.run('2026-05-12').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
result: {
autopilotColonies: 2,
suggestionsOpened: 2,
suggestionsResolved: 2,
},
failedSteps: ['1 colony'],
});
});
});
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { loggableError } from '../common/loggable-error';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotService } from './autopilot.service';
/**
* Step "autopilot" of the daily evaluation: reconciles the suggestions of
* every living colony, one transaction (and one colony row lock) per colony.
* A failing colony is logged and does not stop the others; the step then
* fails with the counters of the colonies that went through, and the next
* run tries again.
*/
@Injectable()
export class AutopilotStep implements DailyEvaluationStep {
readonly name = 'autopilot';
private readonly logger = new Logger(AutopilotStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly autopilot: AutopilotService,
) {}
async run(today: string): Promise<JobResult> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true },
orderBy: { id: 'asc' },
});
// Rules and seasons are the same for every colony: read them once.
const preload = await this.autopilot.preloadFor(
this.prisma,
[...new Set(colonies.map((colony) => colony.apiaryId))],
today,
);
let synced = 0;
let failed = 0;
let opened = 0;
let resolved = 0;
for (const { id } of colonies) {
try {
const result = await this.prisma.$transaction((tx) =>
this.autopilot.syncColonyIn(tx, id, today, preload),
);
synced += 1;
opened += result.opened;
resolved += result.resolved;
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(
`Autopilot of colony ${id} failed: ${message}`,
stack,
);
failed += 1;
}
}
const counts: JobResult = {
autopilotColonies: synced,
suggestionsOpened: opened,
suggestionsResolved: resolved,
};
if (failed > 0) {
throw new JobPartialFailure(counts, [
`${failed} ${failed === 1 ? 'colony' : 'colonies'}`,
]);
}
return counts;
}
}
@@ -0,0 +1,81 @@
import type {
ColonyCondition,
ColonyStatus,
ColonyType,
SuggestionIntent,
SuggestionMode,
TaskPriority,
WinterCheckSeason,
} from '../../generated/prisma';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import type {
ActiveRule,
AutopilotRuleId,
PhenologyConfig,
} from '../automation/rule-config';
import type { SeasonState } from '../season/season-types';
import type { SuggestionEvidence } from '../suggestions/suggestion.types';
/** What the autopilot rules see of one living colony. Days are YYYY-MM-DD. */
export interface AutopilotInput {
colony: {
id: string;
code: string;
status: ColonyStatus;
colonyType: ColonyType;
apiaryId: string;
createdOn: string;
feedStatus: ColonyCondition | null;
};
lastInspection: {
id: string;
date: string;
colonyStrength: number | null;
feedStatus: ColonyCondition | null;
queenCellsPresent: boolean | null;
} | null;
lastVarroa: { id: string; date: string } | null;
lastTreatment: { id: string; startDay: string; endDay: string } | null;
/** A measurement after the end of the last treatment exists. */
varroaSinceLastTreatmentEnd: boolean;
// A4-A6 (Task 27).
feedingsSinceInspection: number;
feedingsSinceLateSummer: number;
treatmentsSinceLateSummer: number;
queen: { id: string; hatchYear: number | null } | null;
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
season: SeasonState;
}
export interface AutopilotConfigs {
autopilot: ActiveRule<'SEASON_AUTOPILOT'>;
interval: ActiveRule<'INSPECTION_INTERVAL'>;
/** Only `recheckAfterDays` is used here (A3). */
varroa: ActiveRule<'VARROA_THRESHOLD'>;
phenology: PhenologyConfig;
}
export interface AutopilotCandidate {
rule: AutopilotRuleId;
ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL';
intent: SuggestionIntent;
mode: SuggestionMode;
reasonCode: string;
evidence: SuggestionEvidence;
dueDate: string;
priority: TaskPriority;
anchor: string;
requiredPermission: PermissionKey;
}
export type AutopilotRule = (
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
) => AutopilotCandidate | null;
export interface AutopilotSyncResult {
opened: number;
refreshed: number;
resolved: number;
}
@@ -0,0 +1,29 @@
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
describe('SuggestionsExpiryStep', () => {
it('expires the open suggestions past their day and reports the count', async () => {
const expireDue = jest.fn().mockResolvedValue(3);
const step = new SuggestionsExpiryStep({
expireDue,
} as unknown as SuggestionsService);
expect(step.name).toBe('suggestions-expiry');
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 3 });
expect(expireDue).toHaveBeenCalledWith('2026-05-12');
});
it('is idempotent: a second run expires nothing more', async () => {
const expireDue = jest
.fn()
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(0);
const step = new SuggestionsExpiryStep({
expireDue,
} as unknown as SuggestionsService);
await step.run('2026-05-12');
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 0 });
});
});
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { SuggestionsService } from '../suggestions/suggestions.service';
/**
* Step "suggestions-expiry": OPEN suggestions past their expiry day become
* EXPIRED (one statement, which also frees their dedupe key). Running it
* twice for one day changes nothing the second time.
*/
@Injectable()
export class SuggestionsExpiryStep implements DailyEvaluationStep {
readonly name = 'suggestions-expiry';
constructor(private readonly suggestions: SuggestionsService) {}
async run(today: string): Promise<JobResult> {
return { suggestionsExpired: await this.suggestions.expireDue(today) };
}
}