Files
ApiCore/ApiCore-Backend/src/webhooks/webhook.handlers.ts
T
Nick SzaunigandClaude Opus 5.5 1971b2d9c4 feat: T4b/T4c wave 8 - weather history, phenology, honey chain, Telegram base
- Weather history from Open-Meteo behind an adapter: worker loop
  phenology-weather with backoff, Retry-After, User-Agent, chunked
  writes and forecast-to-archive correction
- Phenology: growing degree sum per DWD, phase from reports, GTS and
  calendar, calibration from previous years, daily job step,
  SeasonService reads the stored state
- Honey chain: prefill for extractions and batches (proposals only),
  plausibility and withdrawal warnings, Extraction lock, chain events
  and batch.created webhook from HoneyBatchRecorded
- Telegram base: schema, NotificationDelivery outbox, hardened bot
  client, startup check without crash, no-network-outside-loops guard
- ARCHITECTURE.md: sections 11.11-11.13, assumptions 42-46, env table

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-14 20:00:46 +02:00

130 lines
4.2 KiB
TypeScript

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 { honeyBatchInclude } from '../honey-batches/honey-batch-include';
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',
HoneyBatchRecorded: 'batch.created',
StockChanged: 'stock.changed',
TaskCreated: 'task.created',
TaskCompleted: 'task.completed',
QueenAssigned: 'queen.replaced',
});
/**
* 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 },
});
// The new honey batch with the relations the API shows, as the legacy
// path sent it after the create.
case 'HoneyBatchRecorded':
return tx.honeyBatch.findUnique({
...byAggregate,
include: honeyBatchInclude,
});
case 'StockChanged':
return stockChange(event.payload);
case 'TaskCreated':
case 'TaskCompleted':
return tx.task.findUnique(byAggregate);
case 'QueenAssigned':
return tx.queen.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)],
};
}