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

@@ -151,8 +151,8 @@ describe('CalendarSyncService', () => {
});
});
// enqueue()/cancel() run after the source record is committed: a failure
// must not fail that request (the client would retry and store it twice).
// enqueue() runs after the source record is committed: a failure must not
// fail that request (the client would retry and store it twice).
describe('post-commit calls', () => {
let errorSpy: jest.SpyInstance;
@@ -178,61 +178,50 @@ describe('CalendarSyncService', () => {
expect(message).toContain('INSPECTION inspection-1');
expect(message).toContain('db down');
});
it('cancel never throws and logs the failure with the record', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockRejectedValue(
new Error('db down'),
);
await expect(service.cancel('TASK', 'task-1')).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message] = errorSpy.mock.calls[0] as [string];
expect(message).toContain('TASK task-1');
expect(message).toContain('db down');
});
});
describe('cancel', () => {
it('does nothing when no outbox row exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue(null);
describe('enqueueIn', () => {
it('writes through the given client and lets errors propagate (handler retry)', async () => {
const txUpsert = jest.fn().mockResolvedValue(undefined);
const tx = {
calendarSyncOutboxEntry: { upsert: txUpsert },
} as unknown as Prisma.TransactionClient;
await service.cancel('TASK', 'task-1');
await service.enqueueIn(tx, 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.update).not.toHaveBeenCalled();
});
it('deletes the Graph event and clears the row when a graphEventId exists', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
id: 'entry-1',
graphEventId: 'graph-event-1',
expect(txUpsert).toHaveBeenCalledWith({
where: {
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
},
create: { sourceType: 'TASK', sourceId: 'task-1' },
update: {
status: 'PENDING',
attempts: 0,
lastError: null,
revision: { increment: 1 },
},
});
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
await service.cancel('TASK', 'task-1');
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
txUpsert.mockRejectedValue(new Error('tx failed'));
await expect(service.enqueueIn(tx, 'TASK', 'task-1')).rejects.toThrow(
'tx failed',
);
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
where: { id: 'entry-1' },
data: { status: 'SKIPPED', graphEventId: null },
});
});
it('still clears the row when the Graph delete fails', async () => {
prisma.calendarSyncOutboxEntry.findUnique.mockResolvedValue({
id: 'entry-1',
graphEventId: 'graph-event-1',
it('does nothing when sync is disabled', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
enabled: false,
});
graphClient.deleteEvent.mockRejectedValue(new Error('network down'));
const txUpsert = jest.fn();
const tx = {
calendarSyncOutboxEntry: { upsert: txUpsert },
} as unknown as Prisma.TransactionClient;
await service.cancel('TASK', 'task-1');
await service.enqueueIn(tx, 'TASK', 'task-1');
expect(prisma.calendarSyncOutboxEntry.update).toHaveBeenCalledWith({
where: { id: 'entry-1' },
data: { status: 'SKIPPED', graphEventId: null },
});
expect(txUpsert).not.toHaveBeenCalled();
});
});
@@ -241,6 +230,7 @@ describe('CalendarSyncService', () => {
id: 'task-1',
title: 'Beute prüfen',
dueDate: new Date('2026-03-07'),
status: 'OPEN',
};
/** The outcome write: only lands if the row is unchanged since the claim. */
@@ -441,7 +431,78 @@ describe('CalendarSyncService', () => {
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED' });
expect(graphClient.deleteEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it('deletes the Graph event of a task that is DONE and settles the entry SKIPPED', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
await service.processOutbox();
expect(graphClient.deleteEvent).toHaveBeenCalledWith(
'group-1',
'graph-event-1',
);
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it('treats a CANCELLED task like a DONE one', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({
...dueTask,
status: 'CANCELLED',
});
await service.processOutbox();
expect(graphClient.deleteEvent).toHaveBeenCalledTimes(1);
expectSettled({ status: 'SKIPPED', graphEventId: null });
});
it.each([404, 410])(
'treats %i on delete as already deleted',
async (status) => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1' }),
]);
prisma.task.findUnique.mockResolvedValue({
...dueTask,
status: 'DONE',
});
graphClient.deleteEvent.mockRejectedValue(
new GraphRequestError('deleteEvent', status, 'gone'),
);
await service.processOutbox();
expectSettled({ status: 'SKIPPED', graphEventId: null });
},
);
it('counts any other delete error as a failed attempt', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ graphEventId: 'graph-event-1', attempts: 1 }),
]);
prisma.task.findUnique.mockResolvedValue({ ...dueTask, status: 'DONE' });
graphClient.deleteEvent.mockRejectedValue(
new GraphRequestError('deleteEvent', 503, 'busy'),
);
await service.processOutbox();
expectSettled({
status: 'PENDING',
attempts: 2,
lastError: expect.any(String) as string,
});
});
it('only lets go of the claim when the row changed before SKIPPED could be written', async () => {