Files
ApiCore/ApiCore-Backend/src/autopilot/autopilot.handlers.ts
T
Nick SzaunigandClaude Opus 5.5 16cc557462
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s
feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

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

140 lines
4.2 KiB
TypeScript

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<string | null> {
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<void> {
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<void> {
await this.autopilot.syncApiaryIn(
tx,
event.payload.apiaryId,
berlinToday(),
);
}
}