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