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,117 @@
import { Injectable } from '@nestjs/common';
import type {
AnyDomainEventEnvelope,
DomainEventPayloads,
DomainEventType,
} from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { Prisma } from '../../generated/prisma';
import { WebhookEvent } from './webhook-events';
import { WebhooksService } from './webhooks.service';
/** Which domain event goes out as which webhook event. */
export const DOMAIN_EVENT_WEBHOOKS: Readonly<
Partial<Record<DomainEventType, WebhookEvent>>
> = Object.freeze({
InspectionRecorded: 'inspection.created',
VarroaMeasured: 'varroa.measured',
TreatmentRecorded: 'treatment.created',
FeedingRecorded: 'feeding.created',
HarvestRecorded: 'harvest.created',
BatchBottled: 'batch.updated',
BottlingCorrected: 'batch.updated',
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
});
/**
* Turns domain events into queued webhook deliveries. Pure DB writer: it
* reads the record through the delivery transaction and queues the rows in
* it (WebhooksService.enqueueDeliveries); the worker's webhook-deliveries
* loop sends them. Errors propagate, so a failed delivery is retried.
*/
@Injectable()
export class WebhookEventHandlers {
constructor(private readonly webhooks: WebhooksService) {}
@OnDomainEvent(
Object.keys(DOMAIN_EVENT_WEBHOOKS) as DomainEventType[],
'webhooks.forward',
)
async forward(
event: AnyDomainEventEnvelope,
tx: Prisma.TransactionClient,
): Promise<void> {
const webhookEvent = DOMAIN_EVENT_WEBHOOKS[event.type];
if (webhookEvent === undefined) {
return;
}
const data = await webhookData(event, tx);
// The record is gone (deleted before the event was dispatched), or the
// change is of no interest to receivers: nothing to send.
if (data === null) {
return;
}
await this.webhooks.enqueueDeliveries(tx, webhookEvent, data);
}
}
/**
* The webhook's `data`, as the legacy path sent it: the record the event is
* about. Null when there is nothing to send.
*/
async function webhookData(
event: AnyDomainEventEnvelope,
tx: Prisma.TransactionClient,
): Promise<unknown> {
const byAggregate = { where: { id: event.aggregateId } };
switch (event.type) {
case 'InspectionRecorded':
return tx.inspection.findUnique(byAggregate);
case 'VarroaMeasured':
return tx.varroaMeasurement.findUnique(byAggregate);
case 'TreatmentRecorded':
return tx.treatment.findUnique(byAggregate);
case 'FeedingRecorded':
return tx.feeding.findUnique(byAggregate);
case 'HarvestRecorded':
return tx.harvest.findUnique({
...byAggregate,
include: { colonies: { include: { colony: true } } },
});
// The aggregate is the bottling batch; receivers get its honey batch,
// which outlives a removed bottling.
case 'BatchBottled':
case 'BottlingCorrected':
return tx.honeyBatch.findUnique({
where: { id: event.payload.honeyBatchId },
});
case 'StockChanged':
return stockChange(event.payload);
case 'TaskCreated':
case 'TaskCompleted':
return tx.task.findUnique(byAggregate);
default:
return null;
}
}
/**
* Receivers (the shop) fetch the numbers themselves. Raw material is not
* sold there, so a change that only touches raw material is not sent.
*/
function stockChange(
payload: DomainEventPayloads['StockChanged'],
): { bottlingBatchIds: string[]; productIds: string[] } | null {
if (
payload.bottlingBatchIds.length === 0 &&
payload.productIds.length === 0
) {
return null;
}
return {
bottlingBatchIds: [...new Set(payload.bottlingBatchIds)],
productIds: [...new Set(payload.productIds)],
};
}