feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

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 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

@@ -0,0 +1,447 @@
import { PrismaService } from '../prisma/prisma.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';
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[] {
const days: string[] = [];
for (let t = d(from).getTime(); t <= d(to).getTime(); t += DAY_MS) {
days.push(new Date(t).toISOString().slice(0, 10));
}
return days;
}
interface FakeRows {
treatments?: {
id: string;
colonyId: string;
date: Date;
endDate: Date | null;
withdrawalPeriodDays: number | null;
}[];
tasks?: { id: string; dueDate: Date | null }[];
queens?: {
id: string;
hatchYear: number | null;
colony: { id: string } | null;
}[];
colonies?: { id: string; createdAt: Date }[];
/** The latest varroa measurement per colony. */
varroa?: { colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
}
interface FakeDb {
db: PrismaService;
queryRaw: jest.Mock;
taskFindMany: jest.Mock;
queenFindMany: jest.Mock;
colonyFindMany: jest.Mock;
}
/**
* Returns the given rows whatever the query filters - the rules decide by
* 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 db = {
$queryRaw: queryRaw,
task: { findMany: taskFindMany },
queen: { findMany: queenFindMany },
colony: { findMany: colonyFindMany },
varroaMeasurement: {
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
},
inspection: {
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
},
} as unknown as PrismaService;
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
}
/** 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)));
}
return events;
}
/**
* Runs the evaluation on each of `days` into a store keyed like the unique
* DomainEvent.dedupeKey: an event whose key exists is skipped, as
* recordAll's createMany(skipDuplicates) does.
*/
async function runDaily(
db: PrismaService,
days: readonly string[],
store = new Map<string, DailyEvent>(),
): Promise<Map<string, DailyEvent>> {
for (const day of days) {
for (const event of await eventsOn(db, day)) {
if (!store.has(event.dedupeKey)) {
store.set(event.dedupeKey, event);
}
}
}
return store;
}
describe('daily evaluation rules', () => {
it('keeps the values 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', () => {
expect(DAILY_EVALUATION_RULES.map((rule) => rule.type)).toEqual([
'WithdrawalEnded',
'TaskDue',
'QueenAged',
'VarroaMeasurementDue',
'InspectionOverdue',
]);
});
describe('withdrawalEndDay', () => {
it('a 14-day withdrawal of a treatment ending 2026-10-01 ends on 2026-10-15', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: d('2026-10-01'),
withdrawalPeriodDays: 14,
}),
).toBe('2026-10-15'));
it('counts from the treatment day when the treatment has no end date', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: null,
withdrawalPeriodDays: 14,
}),
).toBe('2026-10-12'));
it('ends on the treatment day itself without a waiting time and ignores the time of day', () =>
expect(
withdrawalEndDay({
date: new Date('2026-09-28T22:15:00.000Z'),
endDate: null,
withdrawalPeriodDays: 0,
}),
).toBe('2026-09-28'));
it('has no end when the treatment has no withdrawal period', () =>
expect(
withdrawalEndDay({
date: d('2026-09-28'),
endDate: d('2026-10-01'),
withdrawalPeriodDays: null,
}),
).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');
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
expect(inCatchUpWindow('2026-06-02', today)).toBe(false);
expect(inCatchUpWindow('2026-06-11', today)).toBe(false);
});
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);
});
});
it('builds each event from its rule: aggregate, payload and daily dedupe key', async () => {
const { db } = fakeDb({
treatments: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-15'),
endDate: d('2026-05-20'),
withdrawalPeriodDays: 5,
},
],
tasks: [{ id: 'task-1', dueDate: new Date('2026-05-27T09:00:00Z') }],
colonies: [
// Measured and inspected: the intervals count from the records.
{ id: 'colony-1', createdAt: d('2026-03-01') },
// 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') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
});
expect(await eventsOn(db, '2026-05-29')).toEqual([
{
type: 'WithdrawalEnded',
aggregateType: 'Treatment',
aggregateId: 'treatment-1',
payload: { colonyId: 'colony-1', endedOn: '2026-05-25' },
dedupeKey: 'WithdrawalEnded:treatment-1:2026-05-25',
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 'task-1',
payload: { dueOn: '2026-05-27' },
dedupeKey: 'TaskDue:task-1:2026-05-27',
},
// colony-2: 2026-04-12 + 28 = 2026-05-10, outside the window.
{
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
},
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-2',
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
},
]);
});
it('catches up occurrences of the last 7 days exactly once', async () => {
// The job last ran on 2026-06-01 and is back on 2026-06-10 (CronJob
// down for a week). Then it runs daily again.
const { db } = fakeDb({
treatments: [
// Ends 2026-06-02: 8 days before the comeback, outside the window.
{
id: 'missed',
colonyId: 'colony-1',
date: d('2026-05-19'),
endDate: null,
withdrawalPeriodDays: 14,
},
// Ends 2026-06-03: 7 days before the comeback, caught up.
{
id: 'caught-up',
colonyId: 'colony-1',
date: d('2026-05-20'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
tasks: [
{ id: 'due-while-down', dueDate: d('2026-06-06') },
{ id: 'due-later', dueDate: d('2026-06-14') },
],
});
const store = await runDaily(db, ['2026-06-01']);
expect([...store.keys()]).toEqual([]);
const comeback = await eventsOn(db, '2026-06-10');
expect(comeback.map((event) => event.dedupeKey)).toEqual([
'WithdrawalEnded:caught-up:2026-06-03',
'TaskDue:due-while-down:2026-06-06',
]);
// Every later run within the window repeats the same keys, so the
// unique dedupeKey keeps one event per occurrence.
await runDaily(db, daysBetween('2026-06-10', '2026-06-30'), store);
expect([...store.keys()].sort()).toEqual(
[
'TaskDue:due-later:2026-06-14',
'TaskDue:due-while-down:2026-06-06',
'WithdrawalEnded:caught-up:2026-06-03',
].sort(),
);
// Once the window has passed, the occurrence is no longer produced.
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') }],
});
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]);
// 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',
]);
});
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 },
],
});
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-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',
},
]);
});
it('asks the database only for the candidates of the catch-up window', async () => {
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
fakeDb({});
await eventsOn(db, '2026-06-10');
expect(taskFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['OPEN', 'IN_PROGRESS'] },
dueDate: { gte: d('2026-06-03'), lt: d('2026-06-11') },
},
}),
);
expect(colonyFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
}),
);
const [strings, ...values] = queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
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 } },
}),
);
});
});