import { Injectable } from '@nestjs/common'; import type { Prisma } from '../../generated/prisma'; import { readRule } from '../automation/rule-config'; import { isLivingColonyStatus } from '../colonies/colony-status'; import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types'; import { OnDomainEvent } from '../domain-events/on-domain-event.decorator'; import { NotificationsService } from '../notifications/notifications.service'; import { formatDayDe } from '../suggestions/texts/format-day'; import { isInWindow, withdrawalWindowOf, } from '../treatments/withdrawal-window'; import { WithdrawalCheckService } from './withdrawal-check.service'; const READY_RECIPIENT_PERMISSIONS = [ 'harvests:create', 'treatments:read', ] as const; /** * The withdrawal chain: a harvest or a bottling inside a withdrawal window * leaves one notification (the key is the same at both stages), and the end * of a window tells that the colony may be harvested again. Pure DB writers * through the delivery transaction; errors propagate so a delivery is retried. */ @Injectable() export class WithdrawalEventHandlers { constructor( private readonly check: WithdrawalCheckService, private readonly notifications: NotificationsService, ) {} @OnDomainEvent( ['HarvestRecorded', 'HarvestCorrected'], 'withdrawal.harvest-check', ) async harvestCheck( event: AnyDomainEventEnvelope<'HarvestRecorded' | 'HarvestCorrected'>, tx: Prisma.TransactionClient, ): Promise { const conflicts = await this.check.conflictsForHarvests(tx, [ event.aggregateId, ]); await this.check.notifyConflictsIn(tx, conflicts); } // The other direction: a treatment entered after the harvest (backdated), // or corrected to longer days, covers harvests that already exist. The key // is the same as on the harvest path, so nothing is notified twice. @OnDomainEvent( ['TreatmentRecorded', 'TreatmentCorrected'], 'withdrawal.treatment-check', ) async treatmentCheck( event: AnyDomainEventEnvelope<'TreatmentRecorded' | 'TreatmentCorrected'>, tx: Prisma.TransactionClient, ): Promise { const conflicts = await this.check.conflictsForTreatment( tx, event.aggregateId, ); await this.check.notifyConflictsIn(tx, conflicts); } // The extraction (Schleuderung) joins the check with T4b; until then the // bottling is the second stage. A removed bottling has nothing to check. @OnDomainEvent( ['BatchBottled', 'BottlingCorrected'], 'withdrawal.bottling-check', ) async bottlingCheck( event: AnyDomainEventEnvelope<'BatchBottled' | 'BottlingCorrected'>, tx: Prisma.TransactionClient, ): Promise { if (event.type === 'BottlingCorrected' && event.payload.removed) { return; } const conflicts = await this.check.conflictsForHoneyBatch( tx, event.payload.honeyBatchId, ); await this.check.notifyConflictsIn(tx, conflicts); } // The aggregate is the treatment. Quiet when the rule does not notify, when // the colony is gone, when the treatment changed since the event (the job // records a new event for its new end day) and while another window of the // colony still runs: only then is the colony harvestable again. @OnDomainEvent('WithdrawalEnded', 'notifications.withdrawal-ended') async withdrawalEnded( event: AnyDomainEventEnvelope<'WithdrawalEnded'>, tx: Prisma.TransactionClient, ): Promise { const rule = await readRule(tx, 'WITHDRAWAL_ENDED'); if (!rule.active || !rule.config.notify) { return; } const treatment = await tx.treatment.findUnique({ where: { id: event.aggregateId }, select: { id: true, colonyId: true, product: true, date: true, startDate: true, endDate: true, withdrawalPeriodDays: true, colony: { select: { code: true, status: true } }, }, }); if (treatment === null || !isLivingColonyStatus(treatment.colony.status)) { return; } const endedOn = event.payload.endedOn; if (withdrawalWindowOf(treatment)?.untilDay !== endedOn) { return; } if (await this.hasOtherRunningWindow(tx, treatment, endedOn)) { return; } await this.notifications.recordForPermission( [...READY_RECIPIENT_PERMISSIONS], { priority: 'INFO', category: 'WITHDRAWAL', message: `${treatment.colony.code} ist wieder erntefähig. Wartezeit nach ${treatment.product} endete am ${formatDayDe(endedOn, true)}.`, relatedType: 'Colony', relatedId: treatment.colonyId, link: `/colonies/${treatment.colonyId}`, dedupeKeyPrefix: `withdrawal-ended:${treatment.id}:${endedOn}`, }, tx, ); } private async hasOtherRunningWindow( tx: Prisma.TransactionClient, treatment: { id: string; colonyId: string }, day: string, ): Promise { const others = await tx.treatment.findMany({ where: { colonyId: treatment.colonyId, id: { not: treatment.id }, withdrawalPeriodDays: { not: null }, }, select: { id: true, colonyId: true, product: true, date: true, startDate: true, endDate: true, withdrawalPeriodDays: true, }, }); return others.some((other) => { const window = withdrawalWindowOf(other); return window !== null && isInWindow(window, day); }); } }