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,146 @@
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 { DailyEvaluationJob } from './daily-evaluation.job';
type RawCall = [readonly string[], ...unknown[]];
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
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[]]
>;
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);
});
afterEach(() => 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('writes each type in its own transaction, with its daily dedupe keys', async () => {
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',
}),
],
],
]);
});
it('a second run on the same day records nothing new', async () => {
await job.run();
expect(await job.run()).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
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),
);
});
});