- 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]>
130 lines
4.2 KiB
TypeScript
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)],
|
|
};
|
|
}
|