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