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> > = 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 { 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 { 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)], }; }