feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

@@ -154,12 +154,17 @@ describe('AutomationEventHandlers', () => {
async (method, type, model, core) => {
const record = { id: 'record-1', colonyId: 'colony-1' };
tx[model].findUnique.mockResolvedValueOnce(record);
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await run(method, type);
expect(tx[model].findUnique).toHaveBeenCalledWith({
where: { id: 'record-1' },
});
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { status: true },
});
expect(automation[core]).toHaveBeenCalledWith(tx, record);
},
);
@@ -174,8 +179,41 @@ describe('AutomationEventHandlers', () => {
},
);
it.each(cases)(
'rule handlers write nothing for an ended colony: %s on %s',
async (method, type, model, core) => {
tx[model].findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'DISSOLVED' });
await run(method, type);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it.each(cases)(
'rule handlers write nothing for a colony that is gone: %s on %s',
async (method, type, model, core) => {
tx[model].findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
await run(method, type);
expect(automation[core]).not.toHaveBeenCalled();
},
);
it('lets an evaluation failure propagate, so the delivery is retried', async () => {
tx.inspection.findUnique.mockResolvedValueOnce({ id: 'record-1' });
tx.inspection.findUnique.mockResolvedValueOnce({
id: 'record-1',
colonyId: 'colony-1',
});
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
automation.evaluateInspectionIn.mockRejectedValue(
new Error('notifications down'),
);
@@ -192,22 +230,24 @@ describe('AutomationEventHandlers', () => {
const event = () =>
envelope('ColonyStatusChanged', 'colony-1', { from, to });
it('hands the transition of the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ id: 'colony-1' });
it('hands the transition and the event to evaluateColonyHealthIn', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ status: 'ACTIVE' });
await handlers.colonyHealth(event(), asTx());
expect(tx.colony.findUnique).toHaveBeenCalledWith({
where: { id: 'colony-1' },
select: { id: true },
select: { status: true },
});
// The rule decides on the snapshots of the event, not on the colony
// as it is now: a later change must not hide the transition.
// as it is now: a later change must not hide the transition. The
// event is the anchor of the suggestion.
expect(automation.evaluateColonyHealthIn).toHaveBeenCalledWith(
tx,
'colony-1',
from,
to,
{ id: 'e1', occurredAt: new Date('2026-10-13T08:00:00.000Z') },
);
});
@@ -216,6 +256,14 @@ describe('AutomationEventHandlers', () => {
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
it('skips a colony that ended', async () => {
tx.colony.findUnique.mockResolvedValueOnce({ status: 'SOLD' });
await handlers.colonyHealth(event(), asTx());
expect(automation.evaluateColonyHealthIn).not.toHaveBeenCalled();
});
});
describe('raw-material-low', () => {
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { Prisma } from '../../generated/prisma';
import { StockService } from '../stock/stock.service';
import { AutomationService } from './automation.service';
@@ -10,9 +11,10 @@ type Tx = Prisma.TransactionClient;
/**
* Evaluates the automation rules on domain events. Pure DB writer: it reads
* the record the event is about through the delivery transaction and lets
* AutomationService write task, warning and audit entries in it. A record
* deleted before the event was dispatched is skipped. Errors propagate, so
* the delivery is rolled back as a whole and retried.
* AutomationService write suggestion, notification and audit entries in it.
* A record deleted before the event was dispatched, or of a colony that
* ended, is skipped. Errors propagate, so the delivery is rolled back as a
* whole and retried.
*/
@Injectable()
export class AutomationEventHandlers {
@@ -21,6 +23,18 @@ export class AutomationEventHandlers {
private readonly stock: StockService,
) {}
/**
* Fast path only: proposeForRuleIn re-checks the colony under its lock.
* Whether the colony still lives (a colony that ended takes no proposals).
*/
private async isLivingColonyIn(tx: Tx, colonyId: string): Promise<boolean> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
return colony !== null && isLivingColonyStatus(colony.status);
}
@OnDomainEvent('InspectionRecorded', 'automation.inspection-rules')
async inspectionRules(
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
@@ -29,14 +43,17 @@ export class AutomationEventHandlers {
const inspection = await tx.inspection.findUnique({
where: { id: event.aggregateId },
});
if (inspection === null) {
if (
inspection === null ||
!(await this.isLivingColonyIn(tx, inspection.colonyId))
) {
return;
}
await this.automation.evaluateInspectionIn(tx, inspection);
}
// A correction can lift a count over the threshold, so it is evaluated
// like a new measurement; the open-task check keeps it to one task.
// like a new measurement; the open-suggestion check keeps it to one.
@OnDomainEvent(
['VarroaMeasured', 'VarroaCorrected'],
'automation.varroa-threshold',
@@ -48,7 +65,10 @@ export class AutomationEventHandlers {
const measurement = await tx.varroaMeasurement.findUnique({
where: { id: event.aggregateId },
});
if (measurement === null) {
if (
measurement === null ||
!(await this.isLivingColonyIn(tx, measurement.colonyId))
) {
return;
}
await this.automation.evaluateVarroaMeasurementIn(tx, measurement);
@@ -62,7 +82,10 @@ export class AutomationEventHandlers {
const treatment = await tx.treatment.findUnique({
where: { id: event.aggregateId },
});
if (treatment === null) {
if (
treatment === null ||
!(await this.isLivingColonyIn(tx, treatment.colonyId))
) {
return;
}
await this.automation.evaluateTreatmentIn(tx, treatment);
@@ -76,7 +99,10 @@ export class AutomationEventHandlers {
const feeding = await tx.feeding.findUnique({
where: { id: event.aggregateId },
});
if (feeding === null) {
if (
feeding === null ||
!(await this.isLivingColonyIn(tx, feeding.colonyId))
) {
return;
}
await this.automation.evaluateFeedingIn(tx, feeding);
@@ -89,18 +115,15 @@ export class AutomationEventHandlers {
event: AnyDomainEventEnvelope<'ColonyStatusChanged'>,
tx: Tx,
): Promise<void> {
const colony = await tx.colony.findUnique({
where: { id: event.aggregateId },
select: { id: true },
});
if (colony === null) {
if (!(await this.isLivingColonyIn(tx, event.aggregateId))) {
return;
}
await this.automation.evaluateColonyHealthIn(
tx,
colony.id,
event.aggregateId,
event.payload.from,
event.payload.to,
{ id: event.id, occurredAt: event.occurredAt },
);
}
@@ -1,18 +1,18 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { StockModule } from '../stock/stock.module';
import { TasksModule } from '../tasks/tasks.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutomationController } from './automation.controller';
import { AutomationEventHandlers } from './automation.handlers';
import { AutomationService } from './automation.service';
/**
* Automation rules and the handlers that evaluate them on domain events.
* Rule tasks go through TasksService (audit entry, TaskCreated - and with
* it calendar entry and task.created webhook), so no WebhooksService here.
* The rules propose through SuggestionsService; the task follows when the
* beekeeper accepts (TasksService, with calendar entry and task.created
* webhook), so neither a TasksModule nor a WebhooksService is needed here.
*/
@Module({
imports: [TasksModule, NotificationsModule, StockModule],
imports: [SuggestionsModule, StockModule],
controllers: [AutomationController],
providers: [AutomationService, AutomationEventHandlers],
exports: [AutomationService],
File diff suppressed because it is too large. Load diff
@@ -9,9 +9,16 @@ import { lockRowForUpdate } from '../prisma/row-lock';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
import { runAfterCommit } from '../common/after-commit';
import { AuditLogService } from '../audit/audit-log.service';
import { NotificationsService } from '../notifications/notifications.service';
import { TasksService } from '../tasks/tasks.service';
import { addDays, berlinDayOf, berlinToday } from '../common/dates';
import { isLivingColonyStatus } from '../colonies/colony-status';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import type { ColonyStateSnapshot } from '../domain-events/domain-event-types';
import { SuggestionsService } from '../suggestions/suggestions.service';
import {
suggestionDedupeKey,
type SuggestionEvidence,
} from '../suggestions/suggestion.types';
import { treatmentWindowsIn } from '../treatments/treatment-windows';
import {
AutomationRule,
AutomationTriggerType,
@@ -20,6 +27,8 @@ import {
Prisma,
RawMaterialKind,
StockItem,
Suggestion,
SuggestionIntent,
SwarmControlCheck,
TaskRelatedType,
Treatment,
@@ -27,8 +36,13 @@ import {
} from '../../generated/prisma';
import { UpdateAutomationRuleDto } from './dto/update-automation-rule.dto';
import { formatGeneratedNumber } from './number-format';
import { DEFAULT_RULES, validateRuleConfig } from './rule-config';
import { varroaThresholdOf } from './varroa-threshold';
import {
DEFAULT_RULES,
lockRuleIn,
readRule,
validateRuleConfig,
} from './rule-config';
import { rateMeasurement } from './varroa-bands';
type Tx = Prisma.TransactionClient;
@@ -39,11 +53,6 @@ export type NumberFormatTrigger =
| 'RAW_MATERIAL_LOT_NUMBER_FORMAT'
| 'PRODUCTION_BATCH_NUMBER_FORMAT';
const RAW_MATERIAL_KIND_LABELS: Record<RawMaterialKind, string> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
/** What {ART} turns into in the raw material number format. */
export const RAW_MATERIAL_KIND_CODES: Record<RawMaterialKind, string> = {
BEESWAX: 'WA',
@@ -63,37 +72,44 @@ function isUniqueViolation(error: unknown): boolean {
);
}
/**
* Optional per-rule follow-up interval (config.dueInDays). There is no
* built-in default: how soon a queenless colony or a high mite fall needs a
* look depends on season and practice, so the rule stays date-less unless the
* beekeeper configures one.
*/
function formatDay(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'UTC',
});
/** The rules that propose instead of creating anything themselves. */
export type RuleSuggestionTrigger =
| 'QUEENLESS_NO_EGGS'
| 'FEED_STATUS_CRITICAL'
| 'VARROA_THRESHOLD'
| 'SWARM_MOOD_HIGH'
| 'COLONY_HEALTH_CRITICAL'
| 'STOCK_ITEM_LOW'
| 'RAW_MATERIAL_LOW'
| 'TREATMENT_FOLLOW_UP'
| 'FEEDING_FOLLOW_UP';
export interface RuleSuggestionTarget {
relatedType: TaskRelatedType;
relatedId: string;
colonyId: string | null;
intent: SuggestionIntent;
reasonCode: string;
evidence: SuggestionEvidence;
anchor: string;
requiredPermission: PermissionKey;
dueDate?: string | null;
sourceEventId?: string | null;
}
function dueDateFromConfig(
dueInDays: unknown,
baseDate: Date = new Date(),
): Date | undefined {
if (
typeof dueInDays !== 'number' ||
!Number.isInteger(dueInDays) ||
dueInDays < 0 ||
dueInDays > 365
) {
return undefined;
}
const due = new Date(baseDate);
due.setUTCHours(0, 0, 0, 0);
due.setUTCDate(due.getUTCDate() + dueInDays);
return due;
/** The anchor of a finding that has no record to hang on. */
const NO_ANCHOR = 'none';
/**
* Optional per-rule follow-up interval (config.dueInDays) counted from the
* business day. There is no built-in default: how soon a queenless colony or
* a high mite fall needs a look depends on season and practice, so the
* suggestion stays date-less unless the beekeeper configures one (accepting
* it then creates a task due today).
*/
function dueDayOf(config: object, baseDay: string): string | null {
const { dueInDays } = config as { dueInDays?: unknown };
return typeof dueInDays === 'number' ? addDays(baseDay, dueInDays) : null;
}
@Injectable()
@@ -103,8 +119,7 @@ export class AutomationService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly notifications: NotificationsService,
private readonly tasks: TasksService,
private readonly suggestions: SuggestionsService,
) {}
/**
@@ -216,42 +231,58 @@ export class AutomationService {
async evaluateInspectionIn(tx: Tx, inspection: Inspection): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateInspectionIn');
await this.ensureDefaults(tx);
const rules = await tx.automationRule.findMany({
where: {
triggerType: { in: ['QUEENLESS_NO_EGGS', 'FEED_STATUS_CRITICAL'] },
active: true,
},
});
const ruleByType = new Map(rules.map((rule) => [rule.triggerType, rule]));
const queenlessRule = ruleByType.get('QUEENLESS_NO_EGGS');
if (
queenlessRule &&
inspection.queenSeen === false &&
inspection.eggsPresent === false
) {
await this.triggerActionIn(
if (inspection.queenSeen === false && inspection.eggsPresent === false) {
await this.proposeOnInspectionIn(
tx,
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
'QUEENLESS_NO_EGGS',
'QUEEN_CHECK',
inspection,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus === 'CRITICAL') {
await this.triggerActionIn(
if (inspection.feedStatus === 'CRITICAL') {
await this.proposeOnInspectionIn(
tx,
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
'FEED_STATUS_CRITICAL',
'FEED',
inspection,
);
}
}
/** The reason code of an inspection rule is the rule's own name. */
private async proposeOnInspectionIn(
tx: Tx,
trigger: 'QUEENLESS_NO_EGGS' | 'FEED_STATUS_CRITICAL',
intent: SuggestionIntent,
inspection: Inspection,
): Promise<void> {
const rule = await readRule(tx, trigger);
if (!rule.active) {
return;
}
const inspectedOn = berlinDayOf(inspection.date);
await this.proposeForRuleIn(tx, trigger, {
relatedType: 'COLONY',
relatedId: inspection.colonyId,
colonyId: inspection.colonyId,
intent,
reasonCode: trigger,
evidence: {
colonyId: inspection.colonyId,
inspectionId: inspection.id,
inspectedOn,
},
anchor: inspection.id,
requiredPermission: 'inspections:read',
dueDate: dueDayOf(rule.config, inspectedOn),
});
}
/**
* A red measurement outside the effect of a treatment proposes a
* treatment check. The light comes from the same bands as the colony view
* (season, young colony, method), so rule and screen never disagree.
*/
async evaluateVarroaMeasurementIn(
tx: Tx,
measurement: VarroaMeasurement,
@@ -260,26 +291,78 @@ export class AutomationService {
tx,
'AutomationService.evaluateVarroaMeasurementIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
const rule = await readRule(tx, 'VARROA_THRESHOLD');
if (!rule.active) {
return;
}
const colony = await tx.colony.findUnique({
where: { id: measurement.colonyId },
select: { colonyType: true },
});
// null for an inactive rule; the one source of the default value.
const threshold = varroaThresholdOf(rule);
if (!rule || threshold === null) {
if (colony === null) {
return;
}
// The threshold is a natural mite fall rate (mites per day). Only that
// method yields a value in this unit - a mite count from a sugar roll or
// wash is per bee sample and would be compared against the wrong scale,
// as would a raw board count without the number of days it covered.
if (measurement.method !== 'NATURAL_MITE_FALL') {
const rating = rateMeasurement(
measurement,
colony.colonyType,
rule.config,
await treatmentWindowsIn(tx, measurement.colonyId),
);
// A correction can move a measurement out of red (or into it): an open
// suggestion of this measurement follows the corrected value.
const open = await tx.suggestion.findFirst({
where: {
ruleKey: 'VARROA_THRESHOLD',
status: 'OPEN',
dedupeKey: suggestionDedupeKey(
'VARROA_THRESHOLD',
measurement.colonyId,
measurement.id,
),
},
select: { id: true },
});
if (
rating.light !== 'red' ||
rating.band === null ||
measurement.calculatedValue === null
) {
if (open !== null) {
await this.suggestions.resolveIn(tx, [open.id], 'Bedingung entfällt');
}
return;
}
const value = measurement.calculatedValue;
if (value !== null && value >= threshold) {
await this.triggerActionIn(tx, rule, 'COLONY', measurement.colonyId);
const measuredOn = berlinDayOf(measurement.date);
const evidence = {
colonyId: measurement.colonyId,
measurementId: measurement.id,
measuredOn,
value: measurement.calculatedValue,
redAbove: rating.band.redAbove,
unit: rating.band.unit,
};
const dueDate = dueDayOf(rule.config, measuredOn);
if (open !== null) {
await this.suggestions.refreshIn(tx, open.id, {
reasonCode: 'VARROA_RED',
evidence,
dueDate,
priority: 'HIGH',
expiresAt: null,
});
return;
}
await this.proposeForRuleIn(tx, 'VARROA_THRESHOLD', {
relatedType: 'COLONY',
relatedId: measurement.colonyId,
colonyId: measurement.colonyId,
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
evidence,
anchor: measurement.id,
requiredPermission: 'varroa:read',
dueDate,
});
}
async evaluateSwarmControlCheckIn(
@@ -290,24 +373,38 @@ export class AutomationService {
tx,
'AutomationService.evaluateSwarmControlCheckIn',
);
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'SWARM_MOOD_HIGH' },
});
if (!rule || !rule.active) {
if (check.swarmMood !== 'HIGH') {
return;
}
if (check.swarmMood === 'HIGH') {
await this.triggerActionIn(tx, rule, 'COLONY', check.colonyId);
const rule = await readRule(tx, 'SWARM_MOOD_HIGH');
if (!rule.active) {
return;
}
const checkedOn = berlinDayOf(check.date);
await this.proposeForRuleIn(tx, 'SWARM_MOOD_HIGH', {
relatedType: 'COLONY',
relatedId: check.colonyId,
colonyId: check.colonyId,
intent: 'SWARM_CHECK',
reasonCode: 'SWARM_MOOD_HIGH',
evidence: { colonyId: check.colonyId, checkId: check.id, checkedOn },
anchor: check.id,
requiredPermission: 'swarm-control:read',
dueDate: dueDayOf(rule.config, checkedOn),
});
}
/** Fires only on the transition into CRITICAL, not on every edit while already critical. */
/**
* Fires only on the transition into CRITICAL, not on every edit while
* already critical. The event is the anchor: a later transition into
* CRITICAL is a new finding.
*/
async evaluateColonyHealthIn(
tx: Tx,
colonyId: string,
from: ColonyStateSnapshot,
to: ColonyStateSnapshot,
event: { id: string; occurredAt: Date },
): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateColonyHealthIn');
// ColonyStatusChanged comes with every status or health edit: decide on
@@ -315,16 +412,29 @@ export class AutomationService {
if (to.healthStatus !== 'CRITICAL' || from.healthStatus === 'CRITICAL') {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'COLONY_HEALTH_CRITICAL' },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'COLONY_HEALTH_CRITICAL');
if (!rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'COLONY', colonyId);
const changedOn = berlinDayOf(event.occurredAt);
await this.proposeForRuleIn(tx, 'COLONY_HEALTH_CRITICAL', {
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
intent: 'HEALTH_CHECK',
reasonCode: 'COLONY_HEALTH_CRITICAL',
evidence: { colonyId, changedOn },
anchor: event.id,
requiredPermission: 'colonies:read',
dueDate: dueDayOf(rule.config, changedOn),
sourceEventId: event.id,
});
}
/**
* Anchored on the newest incoming movement: a dismissed reorder stays
* dismissed until the item was restocked and ran low again.
*/
async evaluateStockItemIn(tx: Tx, item: StockItem): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateStockItemIn');
// Every movement of the item comes here: only one below its minimum
@@ -332,22 +442,39 @@ export class AutomationService {
if (item.minThreshold === null || item.quantity >= item.minThreshold) {
return;
}
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'STOCK_ITEM_LOW' },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'STOCK_ITEM_LOW');
if (!rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'STORAGE', item.id);
const restock = await tx.stockMovement.findFirst({
where: { stockItemId: item.id, type: 'IN' },
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
select: { id: true },
});
await this.proposeForRuleIn(tx, 'STOCK_ITEM_LOW', {
relatedType: 'STORAGE',
relatedId: item.id,
colonyId: null,
intent: 'STOCK_REORDER',
reasonCode: 'STOCK_ITEM_LOW',
evidence: {
stockItemId: item.id,
quantity: item.quantity,
minThreshold: item.minThreshold,
},
anchor: restock?.id ?? NO_ANCHOR,
requiredPermission: 'storage:read',
dueDate: dueDayOf(rule.config, berlinToday()),
});
}
/**
* After raw material left its lots (consumption, withdrawal, inventory):
* compares what is left of the kind across all lots with config.minGrams.
* The task is about the kind, not one lot - a new lot of the same kind is
* the remedy - so it hangs on GENERAL with the kind as relatedId, which
* keeps one open task per kind.
* The suggestion is about the kind, not one lot - a new lot of the same
* kind is the remedy - so it hangs on GENERAL with the kind as relatedId,
* which keeps one open suggestion per kind, and is anchored on the newest
* lot of the kind.
*/
async evaluateRawMaterialLowIn(
tx: Tx,
@@ -355,69 +482,73 @@ export class AutomationService {
remainingGrams: number,
): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateRawMaterialLowIn');
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'RAW_MATERIAL_LOW');
if (!rule.active) {
return;
}
const config = rule.config as {
minGrams?: Partial<Record<RawMaterialKind, unknown>>;
} | null;
const minGrams = config?.minGrams?.[kind];
const minGrams = rule.config.minGrams?.[kind];
if (typeof minGrams !== 'number' || remainingGrams >= minGrams) {
return;
}
await this.triggerActionIn(tx, rule, 'GENERAL', kind, undefined, {
description: `${RAW_MATERIAL_KIND_LABELS[kind]}: noch ${remainingGrams} g auf Lager, Mindestmenge ${minGrams} g.`,
const newestLot = await tx.rawMaterialLot.findFirst({
where: { kind },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { id: true },
});
await this.proposeForRuleIn(tx, 'RAW_MATERIAL_LOW', {
relatedType: 'GENERAL',
relatedId: kind,
colonyId: null,
intent: 'STOCK_REORDER',
reasonCode: 'RAW_MATERIAL_LOW',
evidence: { kind, remainingGrams, minGrams },
anchor: newestLot?.id ?? NO_ANCHOR,
requiredPermission: 'raw-material:read',
dueDate: dueDayOf(rule.config, berlinToday()),
});
}
async evaluateTreatmentIn(tx: Tx, treatment: Treatment): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateTreatmentIn');
const measureDate = treatment.endDate ?? treatment.date;
await this.evaluateFollowUpIn(
tx,
'TREATMENT_FOLLOW_UP',
treatment.colonyId,
measureDate,
`Nach Behandlung mit ${treatment.product} am ${formatDay(measureDate)}.`,
);
const rule = await readRule(tx, 'TREATMENT_FOLLOW_UP');
if (!rule.active) {
return;
}
const endedOn = berlinDayOf(treatment.endDate ?? treatment.date);
await this.proposeForRuleIn(tx, 'TREATMENT_FOLLOW_UP', {
relatedType: 'COLONY',
relatedId: treatment.colonyId,
colonyId: treatment.colonyId,
intent: 'FOLLOW_UP',
reasonCode: 'TREATMENT_FOLLOW_UP',
evidence: {
colonyId: treatment.colonyId,
treatmentId: treatment.id,
endedOn,
},
anchor: treatment.id,
requiredPermission: 'treatments:read',
dueDate: dueDayOf(rule.config, endedOn),
});
}
async evaluateFeedingIn(tx: Tx, feeding: Feeding): Promise<void> {
assertTransactionClient(tx, 'AutomationService.evaluateFeedingIn');
await this.evaluateFollowUpIn(
tx,
'FEEDING_FOLLOW_UP',
feeding.colonyId,
feeding.date,
`Nach Fütterung (${feeding.amount} ${feeding.unit}) am ${formatDay(feeding.date)}.`,
);
}
/**
* A check counted from the measure's own date, not from today. Its public
* callers check the transaction client, so an error names them.
*/
private async evaluateFollowUpIn(
tx: Tx,
triggerType: 'TREATMENT_FOLLOW_UP' | 'FEEDING_FOLLOW_UP',
colonyId: string,
measureDate: Date,
description: string,
): Promise<void> {
await this.ensureDefaults(tx);
const rule = await tx.automationRule.findUnique({
where: { triggerType },
});
if (!rule || !rule.active) {
const rule = await readRule(tx, 'FEEDING_FOLLOW_UP');
if (!rule.active) {
return;
}
await this.triggerActionIn(tx, rule, 'COLONY', colonyId, undefined, {
baseDate: measureDate,
description,
const fedOn = berlinDayOf(feeding.date);
await this.proposeForRuleIn(tx, 'FEEDING_FOLLOW_UP', {
relatedType: 'COLONY',
relatedId: feeding.colonyId,
colonyId: feeding.colonyId,
intent: 'FOLLOW_UP',
reasonCode: 'FEEDING_FOLLOW_UP',
evidence: { colonyId: feeding.colonyId, feedingId: feeding.id, fedOn },
anchor: feeding.id,
requiredPermission: 'feedings:read',
dueDate: dueDayOf(rule.config, fedOn),
});
}
@@ -547,84 +678,80 @@ export class AutomationService {
}
/**
* Leaves a suggestion task (never a record of work done) plus a warning,
* all on the caller's transaction: task, its audit entry and TaskCreated
* (TasksService.createInTx - the task.created webhook and the calendar
* entry follow from that event), the warning and the rule's audit entry.
* A failing write rolls back all of it; a handler delivery is retried.
* Proposes what a rule found, on the caller's transaction: the suggestion
* (SuggestionsService.proposeIn: colony then rule lock, one open thing per
* intent and object, an anchor is proposed once, the notification when the
* rule says `notify`) and the rule's audit entry. A failing write rolls
* back all of it; a handler delivery is retried. Resolves to null when
* nothing was proposed (rule off, colony ended, anchor or intent taken).
*
* At most one open task per rule and object: while the reorder task for a
* stock item or the check task for a colony is still open, further triggers
* (the next removal below minimum, a second high varroa count) add nothing.
* Repeating the same alarm would only bury the one task that matters.
*
* The check runs under a lock on the rule's row, held until the caller
* commits: two evaluations at the same moment (parallel requests, a
* handler and a legacy path, several pods) would otherwise both see
* "no open task" and both create one.
* The colony is locked and read before the rule is locked - the order
* every proposal uses - so a colony that ends in between is not missed.
*/
private async triggerActionIn(
async proposeForRuleIn(
tx: Tx,
rule: AutomationRule,
relatedType: TaskRelatedType,
relatedId: string,
sourceInspectionId?: string,
options: { baseDate?: Date; description?: string } = {},
): Promise<void> {
await tx.$queryRaw`SELECT id FROM "AutomationRule" WHERE id = ${rule.id} FOR UPDATE`;
const alreadyOpen = await tx.task.findFirst({
where: {
sourceAutomationTrigger: rule.triggerType,
relatedType,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
select: { id: true },
trigger: RuleSuggestionTrigger,
target: RuleSuggestionTarget,
): Promise<Suggestion | null> {
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
if (
target.colonyId !== null &&
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
) {
return null;
}
const rule = await lockRuleIn(tx, trigger);
if (!rule.active) {
return null;
}
const suggestion = await this.suggestions.proposeIn(tx, {
ruleKey: trigger,
source: 'RULE',
mode: 'PLAN',
intent: target.intent,
priority: 'HIGH',
relatedType: target.relatedType,
relatedId: target.relatedId,
colonyId: target.colonyId,
reasonCode: target.reasonCode,
evidence: target.evidence,
requiredPermission: target.requiredPermission,
dueDate: target.dueDate ?? null,
anchor: target.anchor,
sourceEventId: target.sourceEventId ?? null,
notify: rule.config.notify,
});
if (alreadyOpen) {
return;
if (suggestion === null) {
return null;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.tasks.createInTx(
tx,
{
title,
priority: 'HIGH',
relatedType,
relatedId,
sourceInspectionId,
sourceAutomationTrigger: rule.triggerType,
description: options.description,
dueDate: dueDateFromConfig(config?.dueInDays, options.baseDate),
},
null,
);
// Task 7 replaces this path by suggestions.
await this.notifications.recordForPermission(
'tasks:read',
{
priority: 'WARNUNG',
category: 'SUGGESTION',
message: title,
relatedType,
relatedId,
},
tx,
);
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: rule.id,
action: 'CREATE',
afterData: {
taskId: task.id,
relatedType,
relatedId,
triggerType: rule.triggerType,
suggestionId: suggestion.id,
relatedType: target.relatedType,
relatedId: target.relatedId,
triggerType: trigger,
},
},
tx,
);
return suggestion;
}
private async isLivingColonyLockedIn(
tx: Tx,
colonyId: string,
): Promise<boolean> {
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return false;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true },
});
return colony !== null && isLivingColonyStatus(colony.status);
}
}
@@ -29,7 +29,7 @@ export interface SimpleRuleOptions {
}
/**
* Rules that create one task: notify, an optional follow-up interval
* Rules that propose one suggestion: notify, an optional follow-up interval
* (dueInDays) and, for the raw material rule, minGrams per kind.
*/
export function simpleParser(
@@ -1,59 +0,0 @@
import {
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
varroaThresholdOf,
} from './varroa-threshold';
describe('varroaThresholdOf', () => {
it('uses the default when no rule exists yet (default rule is active)', () => {
expect(varroaThresholdOf(null)).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
expect(DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY).toBe(10);
});
it('returns null for an inactive rule', () => {
expect(
varroaThresholdOf({
active: false,
config: { thresholdMitesPerDay: 7 },
}),
).toBeNull();
});
it('returns the configured threshold of an active rule', () => {
expect(
varroaThresholdOf({ active: true, config: { thresholdMitesPerDay: 7 } }),
).toBe(7);
});
it('reads naturalFall.redAbove from the band format', () => {
expect(
varroaThresholdOf({
active: true,
config: { naturalFall: { redAbove: 8 } },
}),
).toBe(8);
});
it('prefers the band format over the legacy key', () => {
expect(
varroaThresholdOf({
active: true,
config: { thresholdMitesPerDay: 7, naturalFall: { redAbove: 8 } },
}),
).toBe(8);
});
it.each([
['a string', { thresholdMitesPerDay: 'x' }],
['zero', { thresholdMitesPerDay: 0 }],
['a negative number', { thresholdMitesPerDay: -1 }],
['NaN', { thresholdMitesPerDay: Number.NaN }],
['a missing key', {}],
['a null config', null],
])('falls back to the default for %s', (_label, config) => {
expect(varroaThresholdOf({ active: true, config })).toBe(
DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY,
);
});
});
@@ -1,28 +0,0 @@
import type { AutomationRule } from '../../generated/prisma';
import { ruleConfigOf } from './rule-config';
import { DEFAULT_VARROA_THRESHOLD_CONFIG } from './rule-config/varroa-threshold.config';
/** Default natural mite fall (mites per day) that triggers the varroa rule. */
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY =
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule: the red limit of
* the natural mite fall (naturalFall.redAbove; the legacy key
* thresholdMitesPerDay reads the same). Task 7 replaces this with the bands.
* - no rule row yet: the default rule applies (ensureDefaults creates it active)
* - inactive rule: null (no threshold in force)
* - active rule: the configured value, or the default if it is not usable
* (a hand-edited config must never disable the check).
*/
export function varroaThresholdOf(
rule: Pick<AutomationRule, 'active' | 'config'> | null,
): number | null {
if (rule === null) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
if (!rule.active) {
return null;
}
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
}