feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms

- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

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 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

@@ -1,15 +1,11 @@
import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import {
ACTIVE_SEASON_MONTHS,
CATCH_UP_DAYS,
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyEvent,
inCatchUpWindow,
INSPECTION_INTERVAL_DAYS,
latestCycleOccurrence,
QUEEN_AGED_MIN_YEARS,
VARROA_MEASUREMENT_INTERVAL_DAYS,
withdrawalEndDay,
} from './daily-evaluation.rules';
@@ -17,8 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
/** Midnight UTC of a 'YYYY-MM-DD' day. */
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
/** Every day from `from` to `to`, both included. */
function daysBetween(from: string, to: string): string[] {
@@ -38,23 +32,25 @@ interface FakeRows {
withdrawalPeriodDays: number | null;
}[];
tasks?: { id: string; dueDate: Date | null }[];
queens?: {
id: string;
hatchYear: number | null;
colony: { id: string } | null;
}[];
/** Living colonies, all in one apiary. */
colonies?: { id: string; createdAt: Date }[];
/** Living colonies with their current queen (rows the queen query returns). */
queenedColonies?: {
id: string;
currentQueen: { id: string; hatchYear: number | null } | null;
}[];
/** The latest varroa measurement per colony. */
varroa?: { colonyId: string; date: Date }[];
varroa?: { id: string; colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
/** Stored rule configs by trigger type; others read as the built-in setting. */
rules?: Record<string, object>;
}
interface FakeDb {
db: PrismaService;
queryRaw: jest.Mock;
taskFindMany: jest.Mock;
queenFindMany: jest.Mock;
colonyFindMany: jest.Mock;
}
@@ -63,32 +59,60 @@ interface FakeDb {
* day themselves, the database filters only narrow the candidates.
*/
function fakeDb(rows: FakeRows): FakeDb {
const latest = (records: { colonyId: string; date: Date }[] = []) =>
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
const colonyFindMany = jest.fn(
({ select }: { select: Record<string, unknown> }) =>
Promise.resolve(
'currentQueen' in select
? (rows.queenedColonies ?? [])
: (rows.colonies ?? []).map((colony) => ({
...colony,
apiaryId: 'apiary-1',
})),
),
);
const db = {
$queryRaw: queryRaw,
task: { findMany: taskFindMany },
queen: { findMany: queenFindMany },
colony: { findMany: colonyFindMany },
varroaMeasurement: {
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
},
inspection: {
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
groupBy: jest.fn().mockResolvedValue(
(rows.inspections ?? []).map(({ colonyId, date }) => ({
colonyId,
_max: { date },
})),
),
},
automationRule: {
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
const config = rows.rules?.[where.triggerType];
return Promise.resolve(
config === undefined
? null
: { id: 'rule-1', active: true, actionTemplate: null, config },
);
}),
},
} as unknown as PrismaService;
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
return { db, queryRaw, taskFindMany, colonyFindMany };
}
const seasons = new SeasonService();
/** One job run on `day`: every rule's events in the catch-up window. */
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
const events: DailyEvent[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
events.push(
...catchUpEvents(
await rule.occurrences(db, d(day), { seasons, day }),
d(day),
),
);
}
return events;
}
@@ -113,13 +137,12 @@ async function runDaily(
return store;
}
const keysOf = (store: Map<string, DailyEvent>, type: string): string[] =>
[...store.keys()].filter((key) => key.startsWith(`${type}:`));
describe('daily evaluation rules', () => {
it('keeps the values of the backbone plan', () => {
it('keeps the catch-up window of the backbone plan', () => {
expect(CATCH_UP_DAYS).toBe(7);
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
});
it('runs one rule per time-based event type', () => {
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
).toBeNull());
});
describe('latestCycleOccurrence', () => {
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
'2026-06-12',
);
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
'2026-05-22',
);
});
it('is null before the first full interval and for a base in the future', () => {
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
).toBeNull();
});
it('counts whole UTC days, not hours', () =>
expect(
latestCycleOccurrence(
new Date('2026-05-01T20:00:00.000Z'),
21,
at('2026-05-22'),
),
).toBe('2026-05-22'));
it('refuses an interval that is not a positive whole number of days', () => {
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
).toThrow(RangeError);
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
).toThrow(RangeError);
});
});
describe('inCatchUpWindow', () => {
it('spans today and the 7 days before it', () => {
const today = at('2026-06-10');
const today = d('2026-06-10');
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
@@ -222,8 +204,8 @@ describe('daily evaluation rules', () => {
});
it('works across a year boundary', () => {
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false);
});
});
@@ -245,10 +227,11 @@ describe('daily evaluation rules', () => {
// Never measured or inspected: they count from its creation.
{ id: 'colony-2', createdAt: d('2026-04-12') },
],
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-05-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
});
// 2026-05-29 is SCHWARMZEIT (inspection every 7 days).
expect(await eventsOn(db, '2026-05-29')).toEqual([
{
type: 'WithdrawalEnded',
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
payload: {
dueOn: '2026-05-29',
lastMeasuredOn: '2026-05-01',
kind: 'INTERVAL',
anchor: 'm-1',
},
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
},
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
// colony-1: 2026-05-20 + 7 = 2026-05-27 is due, overdue the day after.
// colony-2: 2026-04-12 + 7, overdue on 2026-04-20, outside the window.
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-2',
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
aggregateId: 'colony-1',
payload: {
dueOn: '2026-05-27',
lastInspectedOn: '2026-05-20',
firstOfYear: false,
},
dedupeKey: 'InspectionOverdue:colony-1:2026-05-28',
},
]);
});
@@ -334,82 +327,300 @@ describe('daily evaluation rules', () => {
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
});
it('emits no inspection or varroa event outside March–October', async () => {
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
describe('QueenAged', () => {
const queen = (hatchYear: number | null) => ({
id: 'queen-1',
hatchYear,
});
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
const dueDays = (type: string) =>
[...store.values()]
.filter((event) => event.type === type)
.map((event) => event.dedupeKey.split(':')[2]);
it('fires on the job day, once per queen and year under the January 1 key', async () => {
const { db } = fakeDb({
queenedColonies: [
{ id: 'colony-1', currentQueen: queen(2024) },
{ id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } },
{ id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } },
],
});
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
expect(dueDays('VarroaMeasurementDue')).toEqual([
'2026-03-07',
'2026-04-04',
'2026-05-02',
'2026-05-30',
'2026-06-27',
'2026-07-25',
'2026-08-22',
'2026-09-19',
'2026-10-17',
]);
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
expect(dueDays('InspectionOverdue')).toEqual([
'2026-03-14',
'2026-04-04',
'2026-04-25',
'2026-05-16',
'2026-06-06',
'2026-06-27',
'2026-07-18',
'2026-08-08',
'2026-08-29',
'2026-09-19',
'2026-10-10',
'2026-10-31',
]);
expect(await eventsOn(db, '2026-09-09')).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-1',
payload: { hatchYear: 2024, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-1:2026-01-01',
},
]);
});
it('QueenAged is not limited to January 1–8', async () => {
const { db } = fakeDb({
queenedColonies: [{ id: 'colony-1', currentQueen: queen(2024) }],
});
for (const day of ['2026-01-01', '2026-03-15', '2026-11-30']) {
expect((await eventsOn(db, day)).map((e) => e.dedupeKey)).toEqual([
'QueenAged:queen-1:2026-01-01',
]);
}
});
it('QueenAged uses queenMaxAgeYears', async () => {
const queened = [{ id: 'colony-1', currentQueen: queen(2025) }];
const { db: byDefault } = fakeDb({ queenedColonies: queened });
const { db: stricter, colonyFindMany } = fakeDb({
queenedColonies: queened,
rules: { SEASON_AUTOPILOT: { queenMaxAgeYears: 1 } },
});
expect(await eventsOn(byDefault, '2026-06-10')).toEqual([]);
expect(await eventsOn(stricter, '2026-06-10')).toHaveLength(1);
expect(colonyFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
currentQueen: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}) as object,
}),
);
});
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
const { db, colonyFindMany } = fakeDb({});
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
let assigned = false;
colonyFindMany.mockImplementation(
({ select }: { select: Record<string, unknown> }) =>
Promise.resolve(
'currentQueen' in select && assigned
? [{ id: 'colony-1', currentQueen: queen(2024) }]
: [],
),
);
const store = await runDaily(db, daysBetween('2026-06-01', '2026-06-14'));
expect([...store.keys()]).toEqual([]);
assigned = true;
expect(
(await eventsOn(db, '2026-06-15')).map((event) => event.dedupeKey),
).toEqual(['QueenAged:queen-1:2026-01-01']);
await runDaily(db, daysBetween('2026-06-15', '2026-12-31'), store);
expect([...store.keys()]).toEqual(['QueenAged:queen-1:2026-01-01']);
// The next year is a new anchor.
await runDaily(db, ['2027-01-02'], store);
expect([...store.keys()]).toEqual([
'QueenAged:queen-1:2026-01-01',
'QueenAged:queen-1:2027-01-01',
]);
});
});
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
const { db } = fakeDb({
queens: [
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
{ id: 'queen-2027', hatchYear: 2027, colony: null },
{ id: 'queen-unknown', hatchYear: null, colony: null },
],
describe('InspectionOverdue', () => {
it('emits InspectionOverdue on the day after inspectionDueOn by the segment interval', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
// SCHWARMZEIT since 2026-04-15: 2026-04-28 + 7 = 2026-05-05.
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
expect(
(await eventsOn(db, '2026-05-05')).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([]);
expect(
(await eventsOn(db, '2026-05-06')).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: {
dueOn: '2026-05-05',
lastInspectedOn: '2026-04-28',
firstOfYear: false,
},
dedupeKey: 'InspectionOverdue:colony-1:2026-05-06',
},
]);
});
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', async () => {
// Last inspected in autumn: the next one is due in the winter.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2025-10-20') }],
});
expect([...store.values()]).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2026',
payload: { hatchYear: 2026, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
},
expect.objectContaining({
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
}) as DailyEvent,
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2027',
payload: { hatchYear: 2027, colonyId: null },
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
},
]);
const winter = await runDaily(
db,
daysBetween('2026-01-01', '2026-03-19'),
);
expect([...winter.keys()]).toEqual([]);
// FRUEHJAHR begins 2026-03-20: due that day, overdue the day after.
const spring = await runDaily(
db,
daysBetween('2026-03-20', '2026-04-10'),
);
expect(keysOf(spring, 'InspectionOverdue')).toEqual([
'InspectionOverdue:colony-1:2026-03-21',
]);
expect(spring.get('InspectionOverdue:colony-1:2026-03-21')).toMatchObject(
{
payload: {
dueOn: '2026-03-20',
lastInspectedOn: '2025-10-20',
firstOfYear: true,
},
},
);
});
it('emits nothing for a due date more than a week back (one event per due date)', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
const inspections = async (day: string) =>
(await eventsOn(db, day)).filter((e) => e.type === 'InspectionOverdue');
// Due 2026-05-05, overdue 2026-05-06, last caught up on 2026-05-13.
expect(await inspections('2026-05-13')).toHaveLength(1);
// Due + 9 days.
expect(await inspections('2026-05-14')).toEqual([]);
});
it('keeps the same dedupe key on consecutive days while nothing changes', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
const keys = async (day: string) =>
(await eventsOn(db, day)).map((event) => event.dedupeKey);
expect(await keys('2026-05-08')).toEqual(
expect.arrayContaining(['InspectionOverdue:colony-1:2026-05-06']),
);
expect(await keys('2026-05-09')).toEqual(await keys('2026-05-08'));
});
it('a segment interval change gives a second event with another key', async () => {
// Inspected 2026-06-05: SCHWARMZEIT (7 days) is overdue on 06-13; from
// SOMMER on 2026-06-20 (14 days) the due date is 06-19, overdue 06-20.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-06-05') }],
});
const store = await runDaily(db, daysBetween('2026-06-06', '2026-06-25'));
expect(keysOf(store, 'InspectionOverdue')).toEqual([
'InspectionOverdue:colony-1:2026-06-13',
'InspectionOverdue:colony-1:2026-06-20',
]);
});
it('a HERBST due date inside the winter gives no event on WINTER and a first-of-year one in spring', async () => {
// HERBST: 21 days. 2026-10-24 + 21 = 2026-11-14, overdue on 11-15 -
// but 11-15 is WINTERRUHE, so nothing.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-10-24') }],
});
expect(
keysOf(
await runDaily(db, daysBetween('2026-11-10', '2027-03-19')),
'InspectionOverdue',
),
).toEqual([]);
const spring = await runDaily(
db,
daysBetween('2027-03-20', '2027-03-25'),
);
expect(spring.get('InspectionOverdue:colony-1:2027-03-21')).toMatchObject(
{ payload: { dueOn: '2027-03-20', firstOfYear: true } },
);
});
it('emits no inspection event inside WINTERRUHE although the due date passes', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-11-01') }],
});
const winter = await runDaily(
db,
daysBetween('2026-11-15', '2027-03-19'),
);
expect(keysOf(winter, 'InspectionOverdue')).toEqual([]);
});
});
describe('VarroaMeasurementDue', () => {
it('is due on the winter window start without a measurement since 11-01 and not in the rest of WINTERRUHE', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-09-20') }],
});
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2026-11-15',
]);
});
it('is not due in the winter window after a measurement since 11-01', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
varroa: [{ id: 'm-2', colonyId: 'colony-1', date: d('2026-11-03') }],
});
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([]);
});
it('takes up a due date inside the winter at the start of FRUEHJAHR', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
// Measured 2026-11-20 (inside the winter window of the year): the
// next interval day, 12-18, lies in WINTERRUHE.
varroa: [{ id: 'm-9', colonyId: 'colony-1', date: d('2026-11-20') }],
});
const store = await runDaily(db, daysBetween('2026-11-21', '2027-03-25'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2027-03-20',
]);
expect(
store.get('VarroaMeasurementDue:colony-1:2027-03-20'),
).toMatchObject({ payload: { kind: 'INTERVAL', anchor: 'm-9' } });
});
it('follows varroaMeasureIntervalDays outside WINTERRUHE', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-04-20') }],
rules: { SEASON_AUTOPILOT: { varroaMeasureIntervalDays: 14 } },
});
const store = await runDaily(db, daysBetween('2026-05-01', '2026-05-10'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2026-05-04',
]);
});
});
it('asks the database only for the candidates of the catch-up window', async () => {
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
fakeDb({});
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
await eventsOn(db, '2026-06-10');
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
expect(values).toEqual(['2026-06-03', '2026-06-10']);
// QueenAged only looks on January 1-8.
expect(queenFindMany).not.toHaveBeenCalled();
await eventsOn(db, '2027-01-08');
expect(queenFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}),
);
});
});