feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

+72 -25
View File
@@ -1,4 +1,4 @@
import { INestApplication } from '@nestjs/common';
import { INestApplication, Logger } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -6,14 +6,18 @@ import { PrismaService } from '../src/prisma/prisma.service';
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
import { CalendarSyncSettingsService } from '../src/calendar-sync/calendar-sync-settings.service';
import { GraphClientService } from '../src/calendar-sync/graph-client.service';
import { withTimeout } from '../src/common/with-timeout';
import { WAKE_CHANNELS } from '../src/common/worker-wakeup';
import { PgListener } from '../src/worker/pg-listener';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
/**
* The M365 outbox against the real database, with Microsoft Graph replaced
* by a recorder: several workers (pods, the cron and "Jetzt
* synchronisieren") must never write the same entry twice, and an edit
* that arrives while an entry is being synced must not get lost.
* by a recorder: several workers (worker pods, each ticking and woken by
* "Jetzt synchronisieren") must never write the same entry twice, and an
* edit that arrives while an entry is being synced must not get lost. The
* API itself only queues a pass; it has no worker loop.
*/
describe('Calendar sync outbox (e2e)', () => {
let app: INestApplication<App>;
@@ -134,11 +138,11 @@ describe('Calendar sync outbox (e2e)', () => {
tasks.push(await queueTask(title));
}
// Two pods' cron ticks plus a click on "Jetzt synchronisieren".
// Three worker pods, ticking or woken by "Jetzt synchronisieren".
await Promise.all([
calendarSync.processOutbox(),
calendarSync.processOutbox(),
calendarSync.runNow(),
calendarSync.processOutbox(),
]);
for (const title of titles) {
@@ -211,7 +215,7 @@ describe('Calendar sync outbox (e2e)', () => {
});
});
it('retries a FAILED entry on "Jetzt synchronisieren", not on the cron tick', async () => {
it('retries a FAILED entry after "Jetzt synchronisieren", not on a regular tick', async () => {
const task = await queueTask('Failed before');
await prisma.calendarSyncOutboxEntry.update({
where: { sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id } },
@@ -222,7 +226,9 @@ describe('Calendar sync outbox (e2e)', () => {
expect(createsFor('Failed before')).toHaveLength(0);
expect((await entryFor(task.id)).status).toBe('FAILED');
await calendarSync.runNow();
// The click, then the tick of the worker it woke.
await calendarSync.requestRun();
await calendarSync.processOutbox();
expect(createsFor('Failed before')).toHaveLength(1);
const entry = await entryFor(task.id);
@@ -234,9 +240,51 @@ describe('Calendar sync outbox (e2e)', () => {
describe('API', () => {
const auth = () => ({ Authorization: `Bearer ${token}` });
it('answers "Jetzt synchronisieren" with 202 while the pass still runs', async () => {
const task = await queueTask('Started via API');
const title = `Started via API ${suffix}`;
it('answers "Jetzt synchronisieren" with 202, resets FAILED and wakes the worker - no Graph call from the API', async () => {
const task = await queueTask('Requested via API');
await prisma.calendarSyncOutboxEntry.update({
where: {
sourceType_sourceId: { sourceType: 'TASK', sourceId: task.id },
},
data: { status: 'FAILED', attempts: 5, lastError: 'Graph down' },
});
let wake: () => void = () => undefined;
const woken = new Promise<void>((resolve) => {
wake = resolve;
});
const worker = new PgListener({
connectionString: process.env.DATABASE_URL ?? '',
channels: [WAKE_CHANNELS.calendarSync],
onNotify: () => wake(),
logger: new Logger('CalendarSyncE2E'),
});
await worker.start();
try {
const response = await request(app.getHttpServer())
.post('/api/v1/calendar/sync/run-now')
.set(auth())
.expect(202);
// A pass is due: the client follows it through GET status.
expect((response.body as { running: boolean }).running).toBe(true);
await withTimeout(woken, 5_000, 'no calendar_sync wake-up');
} finally {
await worker.stop();
}
expect(await entryFor(task.id)).toMatchObject({
status: 'PENDING',
attempts: 0,
lastError: null,
claimedAt: null,
});
expect(createdEvents).toEqual([]);
expect(updateEvent).not.toHaveBeenCalled();
}, 15_000);
it('reports running in GET status while a worker holds an entry', async () => {
const task = await queueTask('Held by a worker');
const title = `Held by a worker ${suffix}`;
let releaseCreate: () => void = () => undefined;
jest.spyOn(graph, 'createEvent').mockImplementation(async (_g, input) => {
createdEvents.push(input.title);
@@ -248,24 +296,23 @@ describe('Calendar sync outbox (e2e)', () => {
eventCounter += 1;
return `event-${suffix}-${eventCounter}`;
});
const status = async () =>
(
await request(app.getHttpServer())
.get('/api/v1/calendar/sync/status')
.set(auth())
.expect(200)
).body as { running: boolean };
const response = await request(app.getHttpServer())
.post('/api/v1/calendar/sync/run-now')
.set(auth())
.expect(202);
expect((response.body as { running: boolean }).running).toBe(true);
await eventually(() => createsFor('Started via API').length === 1);
expect((await entryFor(task.id)).status).toBe('PENDING');
const whileRunning = await request(app.getHttpServer())
.get('/api/v1/calendar/sync/status')
.set(auth())
.expect(200);
expect((whileRunning.body as { running: boolean }).running).toBe(true);
// A worker's tick, held inside the Graph call for this entry.
const pass = calendarSync.processOutbox();
await eventually(() => createsFor('Held by a worker').length === 1);
expect((await status()).running).toBe(true);
releaseCreate();
await eventually(async () => !(await calendarSync.getStatus()).running);
await pass;
expect((await status()).running).toBe(false);
expect((await entryFor(task.id)).status).toBe('SYNCED');
}, 15_000);