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

@@ -0,0 +1,93 @@
import { Logger } from '@nestjs/common';
import { JobPartialFailure } from '../system/job-run.service';
import {
DAILY_EVALUATION_STEP_ORDER,
DailyEvaluationStepName,
runDailyEvaluationSteps,
} from './daily-evaluation-steps';
import type { JobResult } from './job';
function step(
name: DailyEvaluationStepName,
run: (today: string) => Promise<JobResult>,
): { name: DailyEvaluationStepName; run: jest.Mock } {
return { name, run: jest.fn(run) };
}
describe('runDailyEvaluationSteps', () => {
const logger = new Logger('test');
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('has the steps of the plan in this order', () =>
expect(DAILY_EVALUATION_STEP_ORDER).toEqual([
'phenology',
'time-events',
'autopilot',
'suggestions-expiry',
'digest',
]));
it('runs the steps in the fixed order with the day and merges their counters', async () => {
const order: string[] = [];
const digest = step('digest', () => {
order.push('digest');
return Promise.resolve({ digests: 3 });
});
const timeEvents = step('time-events', () => {
order.push('time-events');
return Promise.resolve({ TaskDue: 1 });
});
const { result, failed } = await runDailyEvaluationSteps(
[digest, timeEvents],
'2026-06-10',
logger,
);
expect(order).toEqual(['time-events', 'digest']);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ TaskDue: 1, digests: 3 });
expect(failed).toEqual([]);
});
it('a failing step leaves the others running and the run FAILED with all counters', async () => {
const phenology = step('phenology', () =>
Promise.resolve({ phenologyApiaries: 2 }),
);
const timeEvents = step('time-events', () =>
Promise.reject(
new JobPartialFailure({ WithdrawalEnded: 1, TaskDue: 0 }, ['TaskDue']),
),
);
const autopilot = step('autopilot', () =>
Promise.reject(new Error('connection reset')),
);
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
const { result, failed, failures } = await runDailyEvaluationSteps(
[phenology, timeEvents, autopilot, digest],
'2026-06-10',
logger,
);
expect(digest.run).toHaveBeenCalled();
expect(failed).toEqual(['time-events', 'autopilot']);
expect(failures).toEqual(['time-events: TaskDue', 'autopilot']);
expect(result).toEqual({
phenologyApiaries: 2,
WithdrawalEnded: 1,
TaskDue: 0,
digests: 4,
});
expect(errorSpy).toHaveBeenCalledWith(
'Step "autopilot" failed: connection reset',
expect.any(String),
);
});
});
@@ -0,0 +1,69 @@
import type { Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { JobPartialFailure } from '../system/job-run.service';
import type { JobResult } from './job';
/** The steps of the daily evaluation, in the order they run. */
export const DAILY_EVALUATION_STEP_ORDER = [
'phenology',
'time-events',
'autopilot',
'suggestions-expiry',
'digest',
] as const;
export type DailyEvaluationStepName =
(typeof DAILY_EVALUATION_STEP_ORDER)[number];
/**
* One step of the daily evaluation. `today` is the Berlin day of the run
* (YYYY-MM-DD). A step commits in transactions of its own; it reports its
* counters, and fails by throwing - a JobPartialFailure if it still has
* counters to report.
*/
export interface DailyEvaluationStep {
readonly name: DailyEvaluationStepName;
run(today: string): Promise<JobResult>;
}
/**
* Runs the steps in DAILY_EVALUATION_STEP_ORDER, whatever order they come
* in. A failing step is logged and does not stop the others; the counters of
* all steps - also the partial ones of a failed step - are merged into
* `result`, `failed` names the steps that failed and `failures` says why.
*/
export async function runDailyEvaluationSteps(
steps: readonly DailyEvaluationStep[],
today: string,
logger: Logger,
): Promise<{
result: JobResult;
failed: DailyEvaluationStepName[];
/** One entry per failed step, with the failed parts: "time-events: QueenAged". */
failures: string[];
}> {
const ordered = [...steps].sort(
(a, b) =>
DAILY_EVALUATION_STEP_ORDER.indexOf(a.name) -
DAILY_EVALUATION_STEP_ORDER.indexOf(b.name),
);
let result: JobResult = {};
const failed: DailyEvaluationStepName[] = [];
const failures: string[] = [];
for (const step of ordered) {
try {
result = { ...result, ...(await step.run(today)) };
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(`Step "${step.name}" failed: ${message}`, stack);
failed.push(step.name);
if (error instanceof JobPartialFailure) {
result = { ...result, ...error.result };
failures.push(`${step.name}: ${error.failedSteps.join(', ')}`);
} else {
failures.push(step.name);
}
}
}
return { result, failed, failures };
}
@@ -1,146 +1,64 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
type RawCall = [readonly string[], ...unknown[]];
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
/** The database clock: a day the JS clock of the test never shows. */
const dbNow = new Date('2026-06-10T02:30:00.000Z');
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
const COUNTS = {
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
let timeEvents: { name: 'time-events'; run: jest.Mock };
let job: DailyEvaluationJob;
beforeEach(() => {
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
Promise.resolve(
sql.includes('now()')
? [{ now: dbNow }]
: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
),
);
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: queryRaw,
$transaction: transaction,
task: { findMany: taskFindMany },
queen: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
} as unknown as PrismaService;
job = new DailyEvaluationJob(prisma, {
recordAll,
} as unknown as DomainEventsService);
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
timeEvents = { name: 'time-events', run: jest.fn() };
timeEvents.run.mockResolvedValue(COUNTS);
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
});
afterEach(() => jest.restoreAllMocks());
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('is the daily-evaluation job', () => {
expect(job.name).toBe('daily-evaluation');
});
it('evaluates the day of the database clock and reports new events per type', async () => {
const result = await job.run();
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10 - not in the one of the test's own clock.
expect(result).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual(COUNTS);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
it('takes the Berlin day, not the UTC day, around midnight', async () => {
jest.setSystemTime(new Date('2026-06-09T22:30:00.000Z'));
await job.run();
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
});
it('a second run on the same day records nothing new', async () => {
await job.run();
it('a failing step fails the run with the counters of all steps', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
timeEvents.run.mockRejectedValue(
new JobPartialFailure({ WithdrawalEnded: 1 }, ['TaskDue']),
);
expect(await job.run()).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
failedSteps: ['time-events'],
result: { WithdrawalEnded: 1 },
message: 'Daily evaluation failed for: time-events: TaskDue',
});
});
it('a failing rule does not stop the others, but fails the run', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
await expect(job.evaluate(dbNow)).rejects.toThrow(
'Daily evaluation failed for: TaskDue',
);
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
});
@@ -1,72 +1,39 @@
import { Injectable, Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { berlinToday } from '../common/dates';
import { JobPartialFailure } from '../system/job-run.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
} from './daily-evaluation.rules';
DailyEvaluationStep,
runDailyEvaluationSteps,
} from './daily-evaluation-steps';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
import { TimeEventsStep } from './steps/time-events.step';
/**
* Records the time-based domain events (CronJob 04:30): one rule per type,
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
* run is caught up and a repeated one adds nothing. The result counts the
* events that were new, per type.
* The daily evaluation (CronJob 04:30 Europe/Berlin): its steps run in the
* fixed order of DAILY_EVALUATION_STEP_ORDER, each in transactions of its
* own, and a failing step does not stop the others. The result holds the
* counters of all steps; if a step failed, the run fails with that result
* (JobPartialFailure), so it shows on the System page.
*/
@Injectable()
export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
) {}
constructor(private readonly timeEvents: TimeEventsStep) {}
/** Evaluates the current day of the database clock. */
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const [{ now }] = await this.prisma.$queryRaw<
{ now: Date }[]
>`SELECT now() AS "now"`;
return this.evaluate(now);
}
/**
* Runs every rule for `today`. A failing rule is logged and does not stop
* the others - their events are committed - but fails the run, so it
* shows on the System page; the next run catches up.
*/
async evaluate(today: Date): Promise<JobResult> {
const counts: [string, number][] = [];
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts.push([rule.type, await this.evaluateRule(rule, today)]);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
}
return Object.fromEntries(counts);
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today),
today,
const steps: DailyEvaluationStep[] = [this.timeEvents];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
berlinToday(),
this.logger,
);
if (events.length === 0) {
return 0;
if (failed.length > 0) {
throw new JobPartialFailure(result, failed, failures);
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
return result;
}
}
@@ -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 } },
}),
);
});
});
+180 -128
View File
@@ -1,22 +1,20 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { addDays, berlinDayOf } from '../common/dates';
import {
AnyNewDomainEvent,
dailyDedupeKey,
utcDay,
} from '../domain-events/domain-event-types';
import { PrismaService } from '../prisma/prisma.service';
import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age';
import type { SeasonService } from '../season/season.service';
import { varroaMeasurementDue } from '../season/varroa-due';
/** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7;
/** A queen counts as aged from this many years after her hatch year. */
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
/** March to October: varroa and inspection reminders only in these months. */
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
3, 4, 5, 6, 7, 8, 9, 10,
];
const DAY_MS = 24 * 60 * 60 * 1000;
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
@@ -37,18 +35,35 @@ export type DailyEvent<T extends DailyEventType = DailyEventType> =
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
day: string;
event: AnyNewDomainEvent<T>;
/**
* The day of the dedupe key when it is not `day`: an occurrence that
* repeats on every job day but must be recorded once (QueenAged: the
* January 1 of the year).
*/
anchorDay?: string;
}
/** What a rule needs besides the database: the seasons and the run's day. */
export interface DailyRuleContext {
seasons: SeasonService;
/** The Berlin day of the run, YYYY-MM-DD. */
day: string;
}
/**
* One row of the daily evaluation. `occurrences` reads the candidates and
* names the day of each; the job keeps those in the catch-up window
* names the day of each; the step keeps those in the catch-up window
* (catchUpEvents) and records them under their daily dedupe key. A rule
* may narrow its query to the window, but must not rely on that: the day
* it reports decides.
* it reports decides. `today` is dayToDate(ctx.day).
*/
export interface DailyRule<T extends DailyEventType = DailyEventType> {
readonly type: T;
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
occurrences(
db: PrismaService,
today: Date,
ctx: DailyRuleContext,
): Promise<DailyOccurrence<T>[]>;
}
/** Midnight UTC of the UTC day of `date`. */
@@ -73,39 +88,11 @@ export function withdrawalEndDay(
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
}
/**
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
* in whole UTC days; null if not even the first interval has passed.
*/
export function latestCycleOccurrence(
base: Date,
intervalDays: number,
today: Date,
): string | null {
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
throw new RangeError(
`Interval must be a positive whole number of days, got ${intervalDays}`,
);
}
const elapsedDays = Math.round(
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
);
const cycles = Math.floor(elapsedDays / intervalDays);
if (cycles < 1) {
return null;
}
return utcDay(dayAfter(base, cycles * intervalDays));
}
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
export function inCatchUpWindow(day: string, today: Date): boolean {
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
}
function inActiveSeason(day: string): boolean {
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
}
/**
* The occurrences in the catch-up window, as events with their daily dedupe
* key: every run within the window repeats the same key, so the unique
@@ -117,9 +104,13 @@ export function catchUpEvents(
): DailyEvent[] {
return occurrences
.filter(({ day }) => inCatchUpWindow(day, today))
.map(({ day, event }) => ({
.map(({ day, anchorDay, event }) => ({
...event,
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
dedupeKey: dailyDedupeKey(
event.type,
event.aggregateId,
anchorDay ?? day,
),
}));
}
@@ -197,34 +188,47 @@ const taskDue: DailyRule<'TaskDue'> = {
},
};
/** Active queen from her second year after hatching: once a year, on January 1. */
/**
* The current queen of a living colony once she is aged
* (SEASON_AUTOPILOT.queenMaxAgeYears): on the first job day she is, once per
* queen and year (the key is anchored on January 1), so a queen assigned
* later in the year, or a changed hatch year, is caught too.
*/
const queenAged: DailyRule<'QueenAged'> = {
type: 'QueenAged',
async occurrences(db, today) {
const year = today.getUTCFullYear();
const day = `${year}-01-01`;
if (!inCatchUpWindow(day, today)) {
return [];
}
const queens = await db.queen.findMany({
async occurrences(db, _today, { day }) {
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
const year = Number(day.slice(0, 4));
const colonies = await db.colony.findMany({
where: {
status: 'ACTIVE',
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
...LIVING_COLONY_WHERE,
currentQueen: {
status: 'ACTIVE',
hatchYear: { lte: year - config.queenMaxAgeYears },
},
},
select: {
id: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
});
return queens.flatMap(({ id, hatchYear, colony }) => {
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
return colonies.flatMap(({ id, currentQueen }) => {
if (
currentQueen === null ||
currentQueen.hatchYear === null ||
!isQueenAged(currentQueen.hatchYear, day, config.queenMaxAgeYears)
) {
return [];
}
return [
{
day,
anchorDay: `${year}-01-01`,
event: {
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: id,
payload: { hatchYear, colonyId: colony?.id ?? null },
aggregateId: currentQueen.id,
payload: { hatchYear: currentQueen.hatchYear, colonyId: id },
},
},
];
@@ -232,98 +236,146 @@ const queenAged: DailyRule<'QueenAged'> = {
},
};
interface CycleDue {
colonyId: string;
day: string;
/** The UTC day of the last record, null if the colony has none. */
lastOn: string | null;
interface SeasonalColony {
id: string;
apiaryId: string;
/** The Berlin day the colony was created on. */
createdOn: string;
}
async function livingColonies(db: PrismaService): Promise<SeasonalColony[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true, createdAt: true },
});
return colonies.map(({ id, apiaryId, createdAt }) => ({
id,
apiaryId,
createdOn: berlinDayOf(createdAt),
}));
}
/**
* Living colonies whose cycle comes due in the active season: counted from
* the last record, or from the colony's creation if it has none.
* Living colony: varroa measurement due, by the season of its apiary
* (varroaMeasurementDue): the interval after the last measurement, or the
* winter window. One event per due date, on the due day; the 7-day catch-up
* only covers job outages (the A2 suggestion carries the reminder after).
*/
async function colonyCyclesDue(
db: PrismaService,
lastRecordOn: ReadonlyMap<string, Date>,
intervalDays: number,
today: Date,
): Promise<CycleDue[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, createdAt: true },
});
return colonies.flatMap(({ id, createdAt }) => {
const last = lastRecordOn.get(id) ?? null;
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
if (day === null || !inActiveSeason(day)) {
return [];
}
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
});
}
function latestByColony(
rows: { colonyId: string; _max: { date: Date | null } }[],
): ReadonlyMap<string, Date> {
return new Map(
rows.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, _max.date] as const],
),
);
}
/** Living colony: varroa measurement due again in the active season. */
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
const varroaDue: DailyRule<'VarroaMeasurementDue'> = {
type: 'VarroaMeasurementDue',
async occurrences(db, today) {
const latest = await db.varroaMeasurement.groupBy({
by: ['colonyId'],
async occurrences(db, _today, { seasons, day }) {
const colonies = await livingColonies(db);
const latest = await db.varroaMeasurement.findMany({
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
distinct: ['colonyId'],
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }],
select: { id: true, colonyId: true, date: true },
});
const due = await colonyCyclesDue(
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
const states = await seasons.statesFor(
db,
latestByColony(latest),
VARROA_MEASUREMENT_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
colonies.map((colony) => colony.apiaryId),
day,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastMeasuredOn: lastOn },
},
}));
);
const lastByColony = new Map(latest.map((row) => [row.colonyId, row]));
return colonies.flatMap((colony) => {
const season = states.get(colony.apiaryId);
const last = lastByColony.get(colony.id);
const lastMeasuredOn = last === undefined ? null : utcDay(last.date);
const due =
season === undefined
? null
: varroaMeasurementDue(
{
createdOn: colony.createdOn,
lastMeasuredOn,
lastMeasurementId: last?.id ?? null,
},
season,
config,
day,
);
if (due === null) {
return [];
}
return [
{
day: due.dueOn,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colony.id,
payload: {
dueOn: due.dueOn,
lastMeasuredOn,
kind: due.kind,
anchor: due.anchor,
},
},
},
];
});
},
};
/** Living colony: inspection overdue in the active season. */
/**
* Living colony: inspection overdue the day after inspectionDueOn - by the
* interval of the season segment of its apiary, never in WINTERRUHE; after
* the winter on the day after FRUEHJAHR begins ("first of the year"). One
* event per due date; the 7-day catch-up only covers job outages (A1 carries
* the reminder after). A changed interval (new segment) yields a new due
* date and so a second event with another key.
*/
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
type: 'InspectionOverdue',
async occurrences(db, today) {
async occurrences(db, _today, { seasons, day }) {
const colonies = await livingColonies(db);
const latest = await db.inspection.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
INSPECTION_INTERVAL_DAYS,
today,
const lastInspected = new Map(
latest.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, utcDay(_max.date)] as const],
),
);
return due.map(({ colonyId, day, lastOn }) => ({
const { config } = await readRule(db, 'INSPECTION_INTERVAL');
const states = await seasons.statesFor(
db,
colonies.map((colony) => colony.apiaryId),
day,
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastInspectedOn: lastOn },
},
}));
);
return colonies.flatMap((colony) => {
const season = states.get(colony.apiaryId);
const lastInspectedOn = lastInspected.get(colony.id) ?? null;
const due =
season === undefined
? null
: inspectionDueOn(
{ createdOn: colony.createdOn, lastInspectedOn },
season,
config,
);
if (due === null) {
return [];
}
return [
{
day: addDays(due.dueOn, 1),
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colony.id,
payload: {
dueOn: due.dueOn,
lastInspectedOn,
firstOfYear: due.firstOfYear,
},
},
},
];
});
},
};
@@ -332,6 +384,6 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
taskDue,
queenAged,
varroaMeasurementDue,
varroaDue,
inspectionOverdue,
];
+6 -16
View File
@@ -1,12 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SeasonModule } from '../season/season.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
import { AppBackupJob } from './app-backup.job';
@@ -15,22 +16,9 @@ import { DailyEvaluationJob } from './daily-evaluation.job';
import { Job } from './job';
import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job';
import { TimeEventsStep } from './steps/time-events.step';
import { TaskTemplatesJob } from './task-templates.job';
/**
* PermissionResolverService for the job context (BackupService clears its
* cache after a restore, which no job does). Not PermissionsModule: its
* PermissionCatalogSyncService would rewrite the permission catalog on
* every job start. Global like PermissionsModule, so modules that inject
* the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
class JobPermissionsModule {}
const JOB_CLASSES = [
DailyEvaluationJob,
TaskTemplatesJob,
@@ -58,6 +46,7 @@ const JOB_CLASSES = [
JobPermissionsModule,
BackupModule,
SystemModule,
SeasonModule,
],
providers: [
...JOB_CLASSES,
@@ -66,6 +55,7 @@ const JOB_CLASSES = [
useFactory: (...jobs: Job[]): Job[] => jobs,
inject: JOB_CLASSES,
},
TimeEventsStep,
JobRegistry,
IdempotencyCleanupService,
],
@@ -0,0 +1,168 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { berlinToday } from '../../common/dates';
import { AnyNewDomainEvent } from '../../domain-events/domain-event-types';
import { DomainEventsService } from '../../domain-events/domain-events.service';
import { PrismaService } from '../../prisma/prisma.service';
import { SeasonService } from '../../season/season.service';
import { JobPartialFailure } from '../../system/job-run.service';
import { TimeEventsStep } from './time-events.step';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('TimeEventsStep', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
let step: TimeEventsStep;
beforeEach(() => {
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
]),
$transaction: transaction,
task: { findMany: taskFindMany },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
} as unknown as PrismaService;
step = new TimeEventsStep(
prisma,
{ recordAll } as unknown as DomainEventsService,
new SeasonService(),
);
});
afterEach(() => jest.restoreAllMocks());
it('is the time-events step', () => {
expect(step.name).toBe('time-events');
});
it('reports the new events per type', async () => {
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10.
expect(await step.run('2026-06-10')).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
await step.run('2026-06-10');
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
});
it('a second run on the same day records nothing new', async () => {
await step.run('2026-06-10');
expect(await step.run('2026-06-10')).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it.each([
['summer', '2026-06-10T02:30:00.000Z', '2026-06-10'],
['winter', '2026-12-05T03:30:00.000Z', '2026-12-05'],
])(
'the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey (%s)',
async (_season, instant, day) => {
taskFindMany.mockResolvedValue([{ id: 'task-1', dueDate: d(day) }]);
expect(berlinToday(new Date(instant))).toBe(day);
await step.run(berlinToday(new Date(instant)));
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ dedupeKey: `TaskDue:task-1:${day}` }),
]);
},
);
it('a failing rule does not stop the others, but fails the step with their counters', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
const failure = await step.run('2026-06-10').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
failedSteps: ['TaskDue'],
result: {
WithdrawalEnded: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
},
});
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
it('refuses a malformed day', async () => {
await expect(step.run('2026-02-30')).rejects.toThrow('Invalid day');
});
});
@@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { dayToDate } from '../../common/dates';
import { loggableError } from '../../common/loggable-error';
import { DomainEventsService } from '../../domain-events/domain-events.service';
import { PrismaService } from '../../prisma/prisma.service';
import { SeasonService } from '../../season/season.service';
import { JobPartialFailure } from '../../system/job-run.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
DailyRuleContext,
} from '../daily-evaluation.rules';
import type { DailyEvaluationStep } from '../daily-evaluation-steps';
import type { JobResult } from '../job';
/**
* Step "time-events": records the time-based domain events, one rule per
* type, see DAILY_EVALUATION_RULES. An occurrence is recorded on every run
* within CATCH_UP_DAYS after its day under the same daily dedupe key, so a
* missed run is caught up and a repeated one adds nothing. The result counts
* the events that were new, per type.
*/
@Injectable()
export class TimeEventsStep implements DailyEvaluationStep {
readonly name = 'time-events';
private readonly logger = new Logger(TimeEventsStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
private readonly seasons: SeasonService,
) {}
/**
* Runs every rule for `day` (the Berlin day of the run). A failing rule is
* logged and does not stop the others - their events are committed - but
* fails the step with the counters of the rules that ran; the next run
* catches up.
*/
async run(day: string): Promise<JobResult> {
const today = dayToDate(day);
const context: DailyRuleContext = { seasons: this.seasons, day };
const counts: JobResult = {};
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts[rule.type] = await this.evaluateRule(rule, today, context);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new JobPartialFailure(counts, failed);
}
return counts;
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(
rule: DailyRule,
today: Date,
context: DailyRuleContext,
): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today, context),
today,
);
if (events.length === 0) {
return 0;
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
}
}