import { Injectable } from '@nestjs/common'; import type { Prisma } from '../../generated/prisma'; import { berlinToday } from '../common/dates'; import type { AnyDomainEventEnvelope, DomainEventType, } from '../domain-events/domain-event-types'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; import { lockRowsForUpdate } from '../prisma/row-lock'; import { AutopilotService } from './autopilot.service'; const COLONY_SYNC_EVENTS = [ 'InspectionRecorded', 'InspectionCorrected', 'VarroaMeasured', 'VarroaCorrected', 'TreatmentRecorded', 'TreatmentCorrected', 'FeedingRecorded', 'FeedingCorrected', 'HarvestRecorded', 'HarvestCorrected', 'ColonyStatusChanged', 'ColonySplit', 'QueenAssigned', 'TaskCompleted', 'TaskUpdated', 'WithdrawalEnded', 'QueenAged', 'InspectionOverdue', 'VarroaMeasurementDue', 'WinterCheckRecorded', ] as const satisfies readonly DomainEventType[]; type ColonySyncEvent = AnyDomainEventEnvelope< (typeof COLONY_SYNC_EVENTS)[number] >; /** The colony of a task, if it is about one. */ async function taskColonyIdIn( tx: Prisma.TransactionClient, taskId: string, ): Promise { const task = await tx.task.findUnique({ where: { id: taskId }, select: { relatedType: true, relatedId: true }, }); return task?.relatedType === 'COLONY' ? task.relatedId : null; } /** The colonies an event is about; none if it is about no colony. */ async function colonyIdsOf( event: ColonySyncEvent, tx: Prisma.TransactionClient, ): Promise<(string | null)[]> { switch (event.type) { case 'InspectionRecorded': case 'InspectionCorrected': case 'VarroaMeasured': case 'VarroaCorrected': case 'TreatmentRecorded': case 'TreatmentCorrected': case 'FeedingRecorded': case 'FeedingCorrected': case 'WithdrawalEnded': case 'WinterCheckRecorded': case 'QueenAged': return [event.payload.colonyId]; case 'HarvestRecorded': case 'HarvestCorrected': return event.payload.colonyIds; case 'ColonyStatusChanged': case 'InspectionOverdue': case 'VarroaMeasurementDue': return [event.aggregateId]; case 'ColonySplit': // The aggregate is the new colony. return [event.aggregateId, event.payload.parentColonyId]; case 'QueenAssigned': return [event.payload.colonyId, event.payload.previousColonyId]; case 'TaskCompleted': return [ event.payload.relatedType === 'COLONY' ? event.payload.relatedId : null, ]; case 'TaskUpdated': return [await taskColonyIdIn(tx, event.aggregateId)]; } } /** * Reconciles the autopilot suggestions of the colonies an event touches * (autopilot.colony-sync) and, for a season change, of all colonies of the * apiary (autopilot.apiary-sync). * Pure DB writer through the delivery transaction; the colonies are synced * in id order, so two deliveries never lock them the other way round. */ @Injectable() export class AutopilotEventHandlers { constructor(private readonly autopilot: AutopilotService) {} @OnDomainEvent([...COLONY_SYNC_EVENTS], 'autopilot.colony-sync') async colonySync( event: ColonySyncEvent, tx: Prisma.TransactionClient, ): Promise { const colonyIds = [ ...new Set((await colonyIdsOf(event, tx)).filter((id) => id !== null)), ].sort(); if (colonyIds.length === 0) { return; } if (colonyIds.length > 1) { // All colonies in one statement before any sync, like syncApiaryIn. await lockRowsForUpdate(tx, 'Colony', colonyIds); } const today = berlinToday(); for (const colonyId of colonyIds) { await this.autopilot.syncColonyIn(tx, colonyId, today); } } /** * A season change (new phase or segment) alters the rules for every colony * of the apiary: reconcile them all. There is one such event per apiary and * day (dailyDedupeKey); a change later the same day is caught by the daily * autopilot step. */ @OnDomainEvent(['SeasonPhaseChanged'], 'autopilot.apiary-sync') async apiarySync( event: AnyDomainEventEnvelope<'SeasonPhaseChanged'>, tx: Prisma.TransactionClient, ): Promise { await this.autopilot.syncApiaryIn( tx, event.payload.apiaryId, berlinToday(), ); } }