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

@@ -9,7 +9,7 @@ describe('ApiaryFieldService', () => {
apiary: { findUnique: jest.Mock };
colony: { findMany: jest.Mock };
inspection: { groupBy: jest.Mock; findMany: jest.Mock };
notification: { groupBy: jest.Mock };
$queryRaw: jest.Mock;
queen: { findMany: jest.Mock };
task: { findMany: jest.Mock };
};
@@ -23,6 +23,7 @@ describe('ApiaryFieldService', () => {
colonyType: 'PRODUCTION_COLONY',
hiveType: null,
strength: 5,
occupiedFrames: 12,
broodStatus: null,
feedStatus: null,
healthStatus: null,
@@ -55,7 +56,7 @@ describe('ApiaryFieldService', () => {
groupBy: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([]),
},
notification: { groupBy: jest.fn().mockResolvedValue([]) },
$queryRaw: jest.fn().mockResolvedValue([]),
queen: { findMany: jest.fn().mockResolvedValue([]) },
task: { findMany: jest.fn().mockResolvedValue([]) },
};
@@ -172,27 +173,23 @@ describe('ApiaryFieldService', () => {
expect(inspectionArgs.omit).toEqual({ userId: true });
});
const lastQuery = () =>
(prisma.$queryRaw.mock.calls[0] as [{ sql: string; values: unknown[] }])[0];
it('counts only unread WARNUNG and KRITISCH colony notifications visible to the caller', async () => {
grant('colonies:read');
prisma.notification.groupBy.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', _count: { _all: 2 } },
{ relatedId: 'c2', priority: 'KRITISCH', _count: { _all: 1 } },
prisma.$queryRaw.mockResolvedValue([
{ relatedId: 'c2', priority: 'WARNUNG', count: 2 },
{ relatedId: 'c2', priority: 'KRITISCH', count: 1 },
]);
const overview = await service.colonyOverview('apiary-1', 'u1');
expect(prisma.notification.groupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: ['c2', 'c10'] },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
userId: 'u1',
},
}),
);
const query = lastQuery();
expect(query.sql).toContain('"userId" =');
expect(query.sql).toContain('"read" = false');
expect(query.values).toContain('u1');
expect(query.values).toEqual(expect.arrayContaining(['c2', 'c10']));
expect(overview.rows[0]).toMatchObject({
code: 'V-2',
openWarnings: 2,
@@ -203,4 +200,16 @@ describe('ApiaryFieldService', () => {
openCritical: 0,
});
});
it('requires the notification permissions to be a subset of the held ones, stale keys included', async () => {
grant('colonies:read', 'varroa:read');
await service.colonyOverview('apiary-1', 'u1');
const query = lastQuery();
expect(query.sql).toContain('"requiredPermissions" <@');
// The held set is passed as is: a key outside the catalog is never
// contained in it, so such a row stays hidden (parity with findOwn).
expect(query.values).toContainEqual(['colonies:read', 'varroa:read']);
});
});
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '../../generated/prisma';
import type {
ColonyOverview,
ColonyOverviewRow,
@@ -83,6 +84,7 @@ export class ApiaryFieldService {
colonyType: true,
hiveType: true,
strength: true,
occupiedFrames: true,
broodStatus: true,
feedStatus: true,
healthStatus: true,
@@ -96,13 +98,14 @@ export class ApiaryFieldService {
const inspectionsVisible = permissions.has('inspections:read');
const [lastInspections, warnings] = await Promise.all([
inspectionsVisible ? this.lastInspectionDates(colonies) : null,
this.warningCounts(colonies, userId),
this.warningCounts(colonies, userId, permissions),
]);
const rows = colonies.map((colony): ColonyOverviewRow => ({
id: colony.id,
code: colony.code,
status: colony.status,
strength: colony.strength,
occupiedFrames: colony.occupiedFrames,
broodStatus: colony.broodStatus,
feedStatus: colony.feedStatus,
healthStatus: colony.healthStatus,
@@ -138,27 +141,33 @@ export class ApiaryFieldService {
private async warningCounts(
colonies: PackColony[],
userId: string,
held: ReadonlySet<string>,
): Promise<Map<string, { WARNUNG?: number; KRITISCH?: number }>> {
const groups = await this.prisma.notification.groupBy({
by: ['relatedId', 'priority'],
where: {
relatedType: 'COLONY',
relatedId: { in: colonies.map((colony) => colony.id) },
read: false,
priority: { in: ['WARNUNG', 'KRITISCH'] },
userId,
},
_count: { _all: true },
});
const counts = new Map<string, { WARNUNG?: number; KRITISCH?: number }>();
if (colonies.length === 0) {
return counts;
}
// Exactly the rule of NotificationsService.findOwn, decided in SQL: a
// row counts only when every key in requiredPermissions is held (subset
// test, so a key outside the catalog hides the row too).
const groups = await this.prisma.$queryRaw<
{ relatedId: string; priority: string; count: number }[]
>(Prisma.sql`
SELECT "relatedId", "priority"::text AS "priority", count(*)::int AS "count"
FROM "Notification"
WHERE "relatedType" = 'COLONY'
AND "relatedId" IN (${Prisma.join(colonies.map((colony) => colony.id))})
AND "read" = false
AND "priority"::text IN ('WARNUNG', 'KRITISCH')
AND "userId" = ${userId}
AND "requiredPermissions" <@ ${[...held]}::text[]
GROUP BY "relatedId", "priority"
`);
for (const group of groups) {
if (
group.relatedId &&
(group.priority === 'WARNUNG' || group.priority === 'KRITISCH')
) {
if (group.priority === 'WARNUNG' || group.priority === 'KRITISCH') {
counts.set(group.relatedId, {
...counts.get(group.relatedId),
[group.priority]: group._count._all,
[group.priority]: group.count,
});
}
}
@@ -14,6 +14,7 @@ export interface ColonyOverviewRow {
code: string;
status: ColonyStatus;
strength: number | null;
occupiedFrames: number | null;
broodStatus: ColonyCondition | null;
feedStatus: ColonyCondition | null;
healthStatus: ColonyCondition | null;
@@ -56,6 +57,7 @@ export interface OfflinePackV1 {
| 'colonyType'
| 'hiveType'
| 'strength'
| 'occupiedFrames'
| 'broodStatus'
| 'feedStatus'
| 'healthStatus'
+2
View File
@@ -54,6 +54,7 @@ import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
@@ -108,6 +109,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
DocumentsModule,
AutomationModule,
SuggestionsModule,
AutopilotModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
@@ -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;
}
@@ -0,0 +1,90 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { loadAutopilotInputIn } from './autopilot-input';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
const season = calendarSeasonState(
'api-1',
'2026-08-20',
ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
);
function tx(treatments: object[], varroaCount = 0) {
return {
colony: {
findUnique: jest.fn().mockResolvedValue({
id: 'c1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdAt: d('2026-04-01'),
feedStatus: null,
currentQueen: null,
}),
},
inspection: { findFirst: jest.fn().mockResolvedValue(null) },
varroaMeasurement: {
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(varroaCount),
},
treatment: {
findMany: jest.fn().mockResolvedValue(treatments),
count: jest.fn().mockResolvedValue(0),
},
feeding: { count: jest.fn().mockResolvedValue(0) },
winterCheck: { findMany: jest.fn().mockResolvedValue([]) },
};
}
describe('loadAutopilotInputIn', () => {
it('takes the treatment with the latest end day, not the latest date', async () => {
const db = tx([
{
id: 'old',
date: d('2026-07-01'),
startDate: null,
endDate: d('2026-08-10'),
},
{
id: 'backdated',
date: d('2026-07-20'),
startDate: null,
endDate: null,
},
{ id: 'single', date: d('2026-08-01'), startDate: null, endDate: null },
]);
const input = await loadAutopilotInputIn(db as never, 'c1', season);
expect(input?.lastTreatment).toEqual({
id: 'old',
startDay: '2026-07-01',
endDay: '2026-08-10',
});
});
it('counts only measurements after the end day', async () => {
const db = tx([
{
id: 't',
date: d('2026-08-01'),
startDate: null,
endDate: d('2026-08-10'),
},
]);
await loadAutopilotInputIn(db as never, 'c1', season);
expect(db.varroaMeasurement.count).toHaveBeenCalledWith({
where: { colonyId: 'c1', date: { gt: d('2026-08-10') } },
});
});
it('has no last treatment without treatments', async () => {
const input = await loadAutopilotInputIn(tx([]) as never, 'c1', season);
expect(input?.lastTreatment).toBeNull();
expect(input?.varroaSinceLastTreatmentEnd).toBe(false);
});
});
@@ -0,0 +1,139 @@
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, dayToDate } from '../common/dates';
import type { SeasonState } from '../season/season-types';
import { utcDay } from '../domain-events/domain-event-types';
import type { AutopilotInput } from './autopilot.types';
/**
* Until Task 27 passes the real start of the late summer, the feedings and
* treatments of A4-A6 are counted from this day of the year (the built-in
* calendar day of TRACHTENDE).
*/
const LATE_SUMMER_FALLBACK_START = '08-01';
const LATEST_FIRST = [{ date: 'desc' }, { createdAt: 'desc' }] as const;
function lateSummerStart(season: SeasonState): string | null {
return season.segment === 'SPAETSOMMER' || season.segment === 'HERBST'
? `${season.year}-${LATE_SUMMER_FALLBACK_START}`
: null;
}
/**
* What the autopilot rules need of one colony, in a fixed sequence of
* queries (no query per rule, none per record). Resolves to null for a
* colony that does not exist. The caller holds the colony row lock, so the
* picture stays consistent while the rules run.
*/
export async function loadAutopilotInputIn(
tx: Prisma.TransactionClient,
colonyId: string,
season: SeasonState,
): Promise<AutopilotInput | null> {
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: {
id: true,
code: true,
status: true,
colonyType: true,
apiaryId: true,
createdAt: true,
feedStatus: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
});
if (colony === null) {
return null;
}
const inspection = await tx.inspection.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: {
id: true,
date: true,
colonyStrength: true,
feedStatus: true,
queenCellsPresent: true,
},
});
const varroa = await tx.varroaMeasurement.findFirst({
where: { colonyId },
orderBy: [...LATEST_FIRST],
select: { id: true, date: true },
});
// The latest END day wins, not the latest entry: a backdated or
// overlapping treatment must not hide one that ends later.
const treatments = await tx.treatment.findMany({
where: { colonyId },
orderBy: [{ date: 'desc' }, { id: 'asc' }],
select: { id: true, date: true, startDate: true, endDate: true },
});
const lastTreatment = treatments
.map((treatment) => ({
id: treatment.id,
startDay: utcDay(treatment.startDate ?? treatment.date),
endDay: utcDay(treatment.endDate ?? treatment.date),
}))
.reduce<{ id: string; startDay: string; endDay: string } | null>(
(latest, candidate) =>
latest === null || candidate.endDay > latest.endDay
? candidate
: latest,
null,
);
const varroaSinceLastTreatmentEnd =
lastTreatment !== null &&
(await tx.varroaMeasurement.count({
where: { colonyId, date: { gt: dayToDate(lastTreatment.endDay) } },
})) > 0;
const feedingsSinceInspection = await tx.feeding.count({
where: {
colonyId,
...(inspection === null ? {} : { date: { gte: inspection.date } }),
},
});
const lateSummer = lateSummerStart(season);
const sinceLateSummer =
lateSummer === null ? null : { gte: dayToDate(lateSummer) };
const feedingsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.feeding.count({ where: { colonyId, date: sinceLateSummer } });
const treatmentsSinceLateSummer =
sinceLateSummer === null
? 0
: await tx.treatment.count({
where: { colonyId, date: sinceLateSummer },
});
const winterChecks = await tx.winterCheck.findMany({
where: { colonyId, winterYear: { gte: season.year - 1 } },
select: { winterYear: true, season: true },
orderBy: [{ winterYear: 'asc' }, { season: 'asc' }],
});
return {
colony: {
id: colony.id,
code: colony.code,
status: colony.status,
colonyType: colony.colonyType,
apiaryId: colony.apiaryId,
createdOn: berlinDayOf(colony.createdAt),
feedStatus: colony.feedStatus,
},
lastInspection:
inspection === null
? null
: { ...inspection, date: utcDay(inspection.date) },
lastVarroa:
varroa === null ? null : { id: varroa.id, date: utcDay(varroa.date) },
lastTreatment,
varroaSinceLastTreatmentEnd,
feedingsSinceInspection,
feedingsSinceLateSummer,
treatmentsSinceLateSummer,
queen: colony.currentQueen,
winterChecks,
season,
};
}
@@ -0,0 +1,372 @@
import { DEFAULT_RULES, ruleConfigOf } from '../automation/rule-config';
import { calendarSeasonState } from '../season/season-calendar';
import { evaluateAutopilot } from './autopilot-rules';
import type { AutopilotConfigs, AutopilotInput } from './autopilot.types';
type RuleName = 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL' | 'VARROA_THRESHOLD';
function activeRule(trigger: RuleName, override?: object) {
return {
id: null,
active: true,
config: ruleConfigOf(trigger, {
...DEFAULT_RULES[trigger].config,
...override,
}),
actionTemplate: null,
};
}
function configs(autopilotOverride?: object): AutopilotConfigs {
return {
autopilot: activeRule('SEASON_AUTOPILOT', autopilotOverride),
interval: activeRule('INSPECTION_INTERVAL'),
varroa: activeRule('VARROA_THRESHOLD'),
phenology: ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config),
} as AutopilotConfigs;
}
function input(
today: string,
patch: Partial<AutopilotInput> = {},
): AutopilotInput {
return {
colony: {
id: 'col_1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api_1',
createdOn: '2025-04-01',
feedStatus: null,
},
lastInspection: null,
lastVarroa: { id: 'var_1', date: today },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api_1', today, configs().phenology),
...patch,
};
}
const inspectionOn = (date: string) => ({
id: 'insp_9',
date,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
});
const TREATMENT = { id: 'tre_1', startDay: '2026-07-20', endDay: '2026-08-02' };
describe('autopilot rules', () => {
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', () => {
const today = '2026-05-12';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-04') }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-11',
priority: 'HIGH',
anchor: 'insp_9',
requiredPermission: 'inspections:read',
});
expect(result[0].evidence).toMatchObject({
colonyId: 'col_1',
inspectionId: 'insp_9',
lastInspectedOn: '2026-05-04',
intervalDays: 7,
segment: 'SCHWARMZEIT',
});
});
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', () => {
const today = '2026-05-12';
const inTwo = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-07') }),
configs(),
today,
);
expect(inTwo[0]).toMatchObject({
dueDate: '2026-05-14',
priority: 'NORMAL',
});
const inThree = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-08') }),
configs(),
today,
);
expect(inThree).toEqual([]);
const winter = '2026-12-05';
expect(
evaluateAutopilot(
input(winter, {
lastInspection: inspectionOn('2026-09-01'),
lastVarroa: { id: 'var_1', date: '2026-12-01' },
}),
configs(),
winter,
),
).toEqual([]);
});
it('A1: due today is not overdue; no inspection ever uses the anchor none', () => {
const today = '2026-05-12';
const dueToday = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2026-05-05') }),
configs(),
today,
);
expect(dueToday[0].priority).toBe('NORMAL');
const base = input(today);
const never = evaluateAutopilot(
input(today, { colony: { ...base.colony, createdOn: '2026-05-01' } }),
configs(),
today,
);
expect(never[0]).toMatchObject({ anchor: 'none', dueDate: '2026-05-08' });
expect(never[0].evidence).toMatchObject({
inspectionId: null,
lastInspectedOn: null,
});
});
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', () => {
const today = '2026-04-01';
const result = evaluateAutopilot(
input(today, { lastInspection: inspectionOn('2025-09-20') }),
configs(),
today,
);
expect(result[0]).toMatchObject({
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
dueDate: '2026-03-20',
priority: 'HIGH',
anchor: 'insp_9',
});
});
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-06' },
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode: 'VARROA_MEASURE_DUE',
dueDate: '2026-06-03',
priority: 'NORMAL',
anchor: 'var_7',
requiredPermission: 'varroa:read',
});
});
it('A2: a measurement 27 days ago yields nothing', () => {
const today = '2026-06-10';
expect(
evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-06-08'),
lastVarroa: { id: 'var_7', date: '2026-05-14' },
}),
configs(),
today,
),
).toEqual([]);
});
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', () => {
const today = '2026-11-20';
const result = evaluateAutopilot(
input(today, { lastVarroa: { id: 'var_2', date: '2026-09-01' } }),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
intent: 'VARROA_MEASURE',
reasonCode: 'VARROA_WINTER_MEASURE',
dueDate: '2026-11-15',
anchor: 'winter-2026',
});
});
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', () => {
const today = '2026-08-16';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-10'),
lastVarroa: { id: 'var_3', date: '2026-08-01' },
lastTreatment: TREATMENT,
}),
configs(),
today,
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rule: 'A3',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
dueDate: '2026-08-16',
priority: 'NORMAL',
anchor: 'tre_1',
requiredPermission: 'varroa:read',
});
expect(result[0].evidence).toMatchObject({
treatmentId: 'tre_1',
endedOn: '2026-08-02',
});
});
it('A3: nothing 13 days after the end, nothing once a measurement exists since', () => {
const early = '2026-08-15';
expect(
evaluateAutopilot(
input(early, {
lastInspection: inspectionOn('2026-08-10'),
lastTreatment: TREATMENT,
}),
configs(),
early,
),
).toEqual([]);
const late = '2026-08-20';
expect(
evaluateAutopilot(
input(late, {
lastInspection: inspectionOn('2026-08-15'),
lastTreatment: TREATMENT,
varroaSinceLastTreatmentEnd: true,
}),
configs(),
late,
),
).toEqual([]);
});
it('A3: honours recheckAfterDays of the varroa rule', () => {
const today = '2026-08-09';
const cfg = configs();
cfg.varroa = activeRule('VARROA_THRESHOLD', {
recheckAfterDays: 7,
}) as AutopilotConfigs['varroa'];
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-08-05'),
lastTreatment: TREATMENT,
}),
cfg,
today,
);
expect(result.map((c) => c.rule)).toEqual(['A3']);
expect(result[0].dueDate).toBe('2026-08-09');
});
it('returns the candidates in the order A1, A2', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A2']);
});
it('A2 is not proposed while A3 applies: the recheck is the measurement', () => {
const today = '2026-06-10';
const result = evaluateAutopilot(
input(today, {
lastInspection: inspectionOn('2026-05-20'),
lastVarroa: { id: 'var_7', date: '2026-04-20' },
lastTreatment: {
id: 'tre_1',
startDay: '2026-05-01',
endDay: '2026-05-10',
},
}),
configs(),
today,
);
expect(result.map((c) => c.rule)).toEqual(['A1', 'A3']);
});
it('A3 only looks at treatments that ended at most 60 days ago', () => {
const today = '2026-08-16';
const base = { lastInspection: inspectionOn('2026-08-10') };
const at = (endDay: string) =>
evaluateAutopilot(
input(today, {
...base,
lastTreatment: { id: 'tre_1', startDay: '2026-05-01', endDay },
}),
configs(),
today,
).filter((c) => c.rule === 'A3');
expect(at('2026-06-17')).toHaveLength(1); // 60 days ago
expect(at('2026-06-16')).toHaveLength(0); // 61 days ago
});
it('a rule switched off in config.rules yields nothing', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
expect(
evaluateAutopilot(base, configs(), today).map((c) => c.rule),
).toEqual(['A1']);
const a1Off = configs({
rules: { ...configs().autopilot.config.rules, A1: false },
});
expect(evaluateAutopilot(base, a1Off, today)).toEqual([]);
});
it('SEASON_AUTOPILOT switched off yields nothing; INSPECTION_INTERVAL off only silences A1', () => {
const today = '2026-05-12';
const base = input(today, { lastInspection: inspectionOn('2026-05-04') });
const off = configs();
off.autopilot = { ...off.autopilot, active: false };
expect(evaluateAutopilot(base, off, today)).toEqual([]);
const noInterval = configs();
noInterval.interval = { ...noInterval.interval, active: false };
expect(evaluateAutopilot(base, noInterval, today)).toEqual([]);
});
});
@@ -0,0 +1,180 @@
import type { AutopilotRuleId } from '../automation/rule-config';
import { addDays, daysBetween } from '../common/dates';
import { inspectionDueOn } from '../season/inspection-due';
import { varroaMeasurementDue } from '../season/varroa-due';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotInput,
AutopilotRule,
} from './autopilot.types';
/**
* A3 only follows treatments that ended at most this many days ago, so old
* history without a later measurement does not produce suggestions.
*/
const RECHECK_MAX_AGE_DAYS = 60;
/** A1: an inspection is due (or nearly) and the colony is not in winter rest. */
const INSPECTION_LEAD_DAYS = 2;
const a1Inspection: AutopilotRule = (input, configs, today) => {
if (!configs.interval.active) {
return null;
}
const due = inspectionDueOn(
{
createdOn: input.colony.createdOn,
lastInspectedOn: input.lastInspection?.date ?? null,
},
input.season,
configs.interval.config,
);
if (due === null || due.dueOn > addDays(today, INSPECTION_LEAD_DAYS)) {
return null;
}
return {
rule: 'A1',
ruleKey: 'INSPECTION_INTERVAL',
intent: 'INSPECT',
mode: 'DO',
reasonCode: due.firstOfYear ? 'INSPECTION_FIRST_OF_YEAR' : 'INSPECTION_DUE',
evidence: {
colonyId: input.colony.id,
inspectionId: input.lastInspection?.id ?? null,
lastInspectedOn: input.lastInspection?.date ?? null,
dueOn: due.dueOn,
intervalDays: due.intervalDays,
segment: input.season.segment,
},
dueDate: due.dueOn,
priority: due.dueOn < today ? 'HIGH' : 'NORMAL',
anchor: input.lastInspection?.id ?? 'none',
requiredPermission: 'inspections:read',
};
};
/** A2: the varroa measurement interval ran out, or the winter window is open. */
const a2VarroaMeasure: AutopilotRule = (input, configs, today) => {
const due = varroaMeasurementDue(
{
createdOn: input.colony.createdOn,
lastMeasuredOn: input.lastVarroa?.date ?? null,
lastMeasurementId: input.lastVarroa?.id ?? null,
},
input.season,
configs.autopilot.config,
today,
);
if (due === null || due.dueOn > today) {
return null;
}
return {
rule: 'A2',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_MEASURE',
mode: 'DO',
reasonCode:
due.kind === 'WINTER_WINDOW'
? 'VARROA_WINTER_MEASURE'
: 'VARROA_MEASURE_DUE',
evidence: {
colonyId: input.colony.id,
measurementId: input.lastVarroa?.id ?? null,
lastMeasuredOn: input.lastVarroa?.date ?? null,
dueOn: due.dueOn,
intervalDays: configs.autopilot.config.varroaMeasureIntervalDays,
},
dueDate: due.dueOn,
priority: 'NORMAL',
anchor: due.anchor,
requiredPermission: 'varroa:read',
};
};
/** A3: a treatment ended long enough ago and nothing was measured since. */
const a3VarroaRecheck: AutopilotRule = (input, configs, today) => {
const treatment = input.lastTreatment;
if (
treatment === null ||
input.varroaSinceLastTreatmentEnd ||
daysBetween(treatment.endDay, today) > RECHECK_MAX_AGE_DAYS
) {
return null;
}
const recheckAfterDays = configs.varroa.config.recheckAfterDays;
const dueOn = addDays(treatment.endDay, recheckAfterDays);
if (dueOn > today) {
return null;
}
return {
rule: 'A3',
ruleKey: 'SEASON_AUTOPILOT',
intent: 'VARROA_RECHECK',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
evidence: {
colonyId: input.colony.id,
treatmentId: treatment.id,
endedOn: treatment.endDay,
recheckOn: dueOn,
},
dueDate: dueOn,
priority: 'NORMAL',
anchor: treatment.id,
// The action is a varroa measurement.
requiredPermission: 'varroa:read',
};
};
/** A4-A10 follow with Task 27. */
export const AUTOPILOT_RULES: Readonly<
Partial<Record<AutopilotRuleId, AutopilotRule>>
> = {
A1: a1Inspection,
A2: a2VarroaMeasure,
A3: a3VarroaRecheck,
};
const RULE_ORDER: readonly AutopilotRuleId[] = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
];
/**
* The candidates for one colony, always in the order A1...A10 so concurrent
* syncs take the proposal locks in the same order. A switched-off autopilot
* or rule yields nothing.
*/
export function evaluateAutopilot(
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
): AutopilotCandidate[] {
if (!configs.autopilot.active) {
return [];
}
const candidates: AutopilotCandidate[] = [];
for (const id of RULE_ORDER) {
const rule = AUTOPILOT_RULES[id];
if (rule === undefined || !configs.autopilot.config.rules[id]) {
continue;
}
const candidate = rule(input, configs, today);
if (candidate !== null) {
candidates.push(candidate);
}
}
// The recheck after a treatment is the measurement: A2 would ask twice.
return candidates.some((candidate) => candidate.rule === 'A3')
? candidates.filter((candidate) => candidate.rule !== 'A2')
: candidates;
}
@@ -0,0 +1,181 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { AutopilotEventHandlers } from './autopilot.handlers';
import type { AutopilotService } from './autopilot.service';
const EXPECTED_TYPES: DomainEventType[] = [
'InspectionRecorded',
'InspectionCorrected',
'VarroaMeasured',
'VarroaCorrected',
'TreatmentRecorded',
'TreatmentCorrected',
'FeedingRecorded',
'FeedingCorrected',
'HarvestRecorded',
'HarvestCorrected',
'ColonyStatusChanged',
'ColonySplit',
'QueenAssigned',
'TaskCompleted',
'TaskUpdated',
'WithdrawalEnded',
'QueenAged',
'InspectionOverdue',
'VarroaMeasurementDue',
];
function event(
type: DomainEventType,
aggregateId: string,
payload: object,
): AnyDomainEventEnvelope {
return {
id: 'evt-1',
type,
aggregateType: 'Colony',
aggregateId,
payload,
actorUserId: null,
correlationId: 'cor-1',
occurredAt: new Date(),
attempts: 0,
} as AnyDomainEventEnvelope;
}
describe('AutopilotEventHandlers', () => {
let autopilot: { syncColonyIn: jest.Mock };
let tx: { task: { findUnique: jest.Mock } };
let handlers: AutopilotEventHandlers;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-05-12T08:00:00.000Z') });
autopilot = { syncColonyIn: jest.fn().mockResolvedValue({}) };
tx = { task: { findUnique: jest.fn() } };
handlers = new AutopilotEventHandlers(
autopilot as unknown as AutopilotService,
);
});
afterEach(() => {
jest.useRealTimers();
});
const run = (e: AnyDomainEventEnvelope) =>
handlers.colonySync(
e as Parameters<AutopilotEventHandlers['colonySync']>[0],
tx as unknown as Prisma.TransactionClient,
);
const synced = () =>
autopilot.syncColonyIn.mock.calls.map((call: unknown[]) => call[1]);
it('is registered as autopilot.colony-sync for the listed events', () => {
const entries = domainEventHandlerMetadata(
// eslint-disable-next-line @typescript-eslint/unbound-method
AutopilotEventHandlers.prototype.colonySync,
);
expect(entries).toHaveLength(1);
expect(entries[0].handlerId).toBe('autopilot.colony-sync');
expect([...entries[0].types].sort()).toEqual([...EXPECTED_TYPES].sort());
});
it.each([
['InspectionRecorded', { colonyId: 'c1' }],
['InspectionCorrected', { colonyId: 'c1' }],
['VarroaMeasured', { colonyId: 'c1' }],
['VarroaCorrected', { colonyId: 'c1' }],
['TreatmentRecorded', { colonyId: 'c1' }],
['TreatmentCorrected', { colonyId: 'c1' }],
['FeedingRecorded', { colonyId: 'c1' }],
['FeedingCorrected', { colonyId: 'c1' }],
['WithdrawalEnded', { colonyId: 'c1', endedOn: '2026-05-12' }],
['QueenAged', { colonyId: 'c1', hatchYear: 2024 }],
] as [DomainEventType, object][])(
'%s syncs the colony of its payload for the Berlin day',
async (type, payload) => {
await run(event(type, 'aggregate-1', payload));
expect(autopilot.syncColonyIn).toHaveBeenCalledTimes(1);
expect(autopilot.syncColonyIn).toHaveBeenCalledWith(
tx,
'c1',
'2026-05-12',
);
},
);
it.each([
'ColonyStatusChanged',
'InspectionOverdue',
'VarroaMeasurementDue',
] as const)('%s syncs the aggregate colony', async (type) => {
await run(event(type, 'c9', {}));
expect(synced()).toEqual(['c9']);
});
it('syncs every colony of a harvest, in id order', async () => {
await run(
event('HarvestRecorded', 'h1', {
apiaryId: 'a',
colonyIds: ['c3', 'c1', 'c2', 'c1'],
}),
);
expect(synced()).toEqual(['c1', 'c2', 'c3']);
});
it('syncs the new and the parent colony of a split', async () => {
await run(event('ColonySplit', 'new', { parentColonyId: 'mother' }));
expect(synced()).toEqual(['mother', 'new']);
});
it('syncs the colony and the previous colony of a queen assignment', async () => {
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: 'c1' }),
);
await run(
event('QueenAssigned', 'q1', { colonyId: 'c2', previousColonyId: null }),
);
expect(synced()).toEqual(['c1', 'c2', 'c2']);
});
it('syncs a colony task on completion and update, nothing for other tasks', async () => {
await run(
event('TaskCompleted', 't1', { relatedType: 'COLONY', relatedId: 'c1' }),
);
await run(
event('TaskCompleted', 't2', { relatedType: 'GENERAL', relatedId: null }),
);
tx.task.findUnique
.mockResolvedValueOnce({ relatedType: 'COLONY', relatedId: 'c4' })
.mockResolvedValueOnce({ relatedType: 'QUEEN', relatedId: 'q1' })
.mockResolvedValueOnce(null);
await run(event('TaskUpdated', 't3', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't4', { status: 'OPEN', dueDate: null }));
await run(event('TaskUpdated', 't5', { status: 'OPEN', dueDate: null }));
expect(synced()).toEqual(['c1', 'c4']);
});
it('does nothing for a queen event without a colony', async () => {
await run(event('QueenAged', 'q1', { colonyId: null, hatchYear: 2023 }));
expect(autopilot.syncColonyIn).not.toHaveBeenCalled();
});
it('lets errors propagate so the delivery is retried', async () => {
autopilot.syncColonyIn.mockRejectedValue(new Error('db down'));
await expect(
run(event('InspectionRecorded', 'i1', { colonyId: 'c1' })),
).rejects.toThrow('db down');
});
});
@@ -0,0 +1,112 @@
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 { 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',
] 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 '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.
* 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;
}
const today = berlinToday();
for (const colonyId of colonyIds) {
await this.autopilot.syncColonyIn(tx, colonyId, today);
}
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { SeasonModule } from '../season/season.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { AutopilotEventHandlers } from './autopilot.handlers';
import { AutopilotService } from './autopilot.service';
import { AutopilotStep } from './autopilot.step';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
/**
* The season autopilot: the event handler and the daily steps that keep the
* autopilot suggestions of each colony in line with the rules. Imported by
* AppModule and WorkerModule (the handler) and by JobsModule (the steps).
*/
@Module({
imports: [SeasonModule, SuggestionsModule],
providers: [
AutopilotService,
AutopilotEventHandlers,
AutopilotStep,
SuggestionsExpiryStep,
],
exports: [AutopilotService, AutopilotStep, SuggestionsExpiryStep],
})
export class AutopilotModule {}
@@ -0,0 +1,333 @@
import { Logger } from '@nestjs/common';
import {
DEFAULT_RULES,
readRule,
ruleConfigOf,
} from '../automation/rule-config';
import { lockRowForUpdate } from '../prisma/row-lock';
import { calendarSeasonState } from '../season/season-calendar';
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { loadAutopilotInputIn } from './autopilot-input';
import { AutopilotService } from './autopilot.service';
import type { AutopilotInput } from './autopilot.types';
jest.mock('../prisma/row-lock');
jest.mock('../automation/rule-config', () => ({
...jest.requireActual<object>('../automation/rule-config'),
readRule: jest.fn(),
}));
jest.mock('./autopilot-input');
const lockRow = jest.mocked(lockRowForUpdate);
const readRuleMock = jest.mocked(readRule);
const loadInput = jest.mocked(loadAutopilotInputIn);
const TODAY = '2026-05-12';
const PHENOLOGY = ruleConfigOf('PHENOLOGY', DEFAULT_RULES.PHENOLOGY.config);
function rule(trigger: string, override: object = {}, active = true) {
return {
id: null,
active,
config: ruleConfigOf(trigger as never, {
...DEFAULT_RULES[trigger as 'SEASON_AUTOPILOT'].config,
...override,
}),
actionTemplate: null,
};
}
function input(lastInspectedOn: string | null): AutopilotInput {
return {
colony: {
id: 'col-1',
code: 'B3',
status: 'ACTIVE',
colonyType: 'PRODUCTION_COLONY',
apiaryId: 'api-1',
createdOn: '2026-04-20',
feedStatus: null,
},
lastInspection:
lastInspectedOn === null
? null
: {
id: 'insp-9',
date: lastInspectedOn,
colonyStrength: null,
feedStatus: null,
queenCellsPresent: null,
},
lastVarroa: { id: 'var-1', date: '2026-05-10' },
lastTreatment: null,
varroaSinceLastTreatmentEnd: false,
feedingsSinceInspection: 0,
feedingsSinceLateSummer: 0,
treatmentsSinceLateSummer: 0,
queen: null,
winterChecks: [],
season: calendarSeasonState('api-1', TODAY, PHENOLOGY),
};
}
describe('AutopilotService', () => {
let tx: {
$connect?: undefined;
colony: { findUnique: jest.Mock };
suggestion: { findMany: jest.Mock };
};
let suggestions: {
proposeIn: jest.Mock;
refreshIn: jest.Mock;
resolveIn: jest.Mock;
};
let seasons: { statesFor: jest.Mock };
let service: AutopilotService;
let rules: Record<string, ReturnType<typeof rule>>;
beforeEach(() => {
jest.resetAllMocks();
tx = {
colony: {
findUnique: jest
.fn()
.mockResolvedValue({ status: 'ACTIVE', apiaryId: 'api-1' }),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
};
suggestions = {
proposeIn: jest.fn().mockResolvedValue({ id: 'new' }),
refreshIn: jest.fn().mockResolvedValue(undefined),
resolveIn: jest
.fn()
.mockImplementation((_tx, ids: string[]) => ids.length),
};
seasons = {
statesFor: jest
.fn()
.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
),
};
rules = {
SEASON_AUTOPILOT: rule('SEASON_AUTOPILOT'),
INSPECTION_INTERVAL: rule('INSPECTION_INTERVAL'),
VARROA_THRESHOLD: rule('VARROA_THRESHOLD'),
PHENOLOGY: rule('PHENOLOGY'),
};
readRuleMock.mockImplementation((_db, trigger) =>
Promise.resolve(rules[trigger] as never),
);
lockRow.mockResolvedValue(true);
loadInput.mockResolvedValue(input('2026-05-03'));
service = new AutopilotService(
suggestions as unknown as SuggestionsService,
seasons,
);
});
const sync = () => service.syncColonyIn(tx as never, 'col-1', TODAY);
it('locks the colony row first and does nothing for an ended colony', async () => {
tx.colony.findUnique.mockResolvedValue({
status: 'DECEASED',
apiaryId: 'api-1',
});
const result = await sync();
expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
expect(lockRow.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('does nothing when the colony row is gone', async () => {
lockRow.mockResolvedValue(false);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(tx.colony.findUnique).not.toHaveBeenCalled();
});
it('proposes a new candidate with the autopilot source, dedupe inputs and expiry', async () => {
const result = await sync();
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: 'col-1',
colonyId: 'col-1',
reasonCode: 'INSPECTION_DUE',
dueDate: '2026-05-10',
anchor: 'insp-9',
requiredPermission: 'inspections:read',
expiresAt: '2026-06-11',
}),
);
});
it('keeps an overdue suggestion alive for the full ttl from today', async () => {
loadInput.mockResolvedValue(input('2026-03-25'));
await sync();
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
dueDate: '2026-04-01',
expiresAt: '2026-06-11',
}),
);
});
it('refreshes an open suggestion with the same key instead of creating one', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-1',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9',
expiresAt: new Date('2026-06-01T00:00:00.000Z'),
},
]);
const result = await sync();
expect(result).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(suggestions.refreshIn).toHaveBeenCalledWith(tx, 'sug-1', {
reasonCode: 'INSPECTION_DUE',
evidence: expect.objectContaining({ inspectionId: 'insp-9' }) as object,
dueDate: '2026-05-10',
priority: 'HIGH',
expiresAt: '2026-06-01',
});
});
it('drops a candidate when proposeIn finds an open task of the same intent', async () => {
suggestions.proposeIn.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
});
it('resolves open autopilot suggestions whose key vanished, before proposing, never rule or chain ones', async () => {
tx.suggestion.findMany.mockResolvedValue([
{
id: 'sug-old',
dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-1',
expiresAt: null,
},
]);
const result = await sync();
expect(tx.suggestion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { colonyId: 'col-1', source: 'AUTOPILOT', status: 'OPEN' },
}),
);
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['sug-old'],
'Bedingung entfällt',
);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 1 });
expect(suggestions.resolveIn.mock.invocationCallOrder[0]).toBeLessThan(
suggestions.proposeIn.mock.invocationCallOrder[0],
);
});
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', async () => {
rules.SEASON_AUTOPILOT = rule('SEASON_AUTOPILOT', {}, false);
tx.suggestion.findMany.mockResolvedValue([
{ id: 'a', dedupeKey: 'k1', expiresAt: null },
{ id: 'b', dedupeKey: 'k2', expiresAt: null },
]);
const result = await sync();
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['a', 'b'],
'Autopilot aus',
);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 2 });
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(loadInput).not.toHaveBeenCalled();
});
it('proposes the candidates in the order A1, A2', async () => {
loadInput.mockResolvedValue({
...input('2026-04-20'),
lastVarroa: { id: 'var-1', date: '2026-04-01' },
});
await sync();
expect(
suggestions.proposeIn.mock.calls.map(
(call: [unknown, { intent: string }]) => call[1].intent,
),
).toEqual(['INSPECT', 'VARROA_MEASURE']);
});
it('uses preloaded configs and seasons without reading rules or seasons again', async () => {
const season = calendarSeasonState('api-1', TODAY, PHENOLOGY);
const preload = {
configs: {
autopilot: rules.SEASON_AUTOPILOT,
interval: rules.INSPECTION_INTERVAL,
varroa: rules.VARROA_THRESHOLD,
phenology: PHENOLOGY,
},
seasons: new Map([['api-1', season]]),
};
readRuleMock.mockClear();
const result = await service.syncColonyIn(
tx as never,
'col-1',
TODAY,
preload,
);
expect(result.opened).toBe(1);
expect(readRuleMock).not.toHaveBeenCalled();
expect(seasons.statesFor).not.toHaveBeenCalled();
expect(loadInput).toHaveBeenCalledWith(tx, 'col-1', season);
});
it('preloadFor reads the four rules and the seasons once', async () => {
const preload = await service.preloadFor(tx as never, ['api-1'], TODAY);
expect(readRuleMock).toHaveBeenCalledTimes(4);
expect(seasons.statesFor).toHaveBeenCalledWith(tx, ['api-1'], TODAY);
expect(preload.seasons.has('api-1')).toBe(true);
expect(preload.configs.phenology).toEqual(PHENOLOGY);
});
it('warns and does nothing when the apiary has no season state or the input is gone', async () => {
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
seasons.statesFor.mockResolvedValue(new Map());
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledWith(expect.stringContaining('season'));
seasons.statesFor.mockResolvedValue(
new Map([['api-1', calendarSeasonState('api-1', TODAY, PHENOLOGY)]]),
);
loadInput.mockResolvedValue(null);
expect(await sync()).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(warn).toHaveBeenCalledTimes(2);
warn.mockRestore();
});
});
@@ -0,0 +1,184 @@
import { Injectable, Logger } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { addDays } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import type { SeasonState } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import { SuggestionsService } from '../suggestions/suggestions.service';
import { suggestionDedupeKey } from '../suggestions/suggestion.types';
import { loadAutopilotInputIn } from './autopilot-input';
import { evaluateAutopilot } from './autopilot-rules';
import type {
AutopilotCandidate,
AutopilotConfigs,
AutopilotSyncResult,
} from './autopilot.types';
/** Rules and seasons a daily step reads once for all its colonies. */
export interface AutopilotPreload {
configs: AutopilotConfigs;
seasons: ReadonlyMap<string, SeasonState>;
}
const NOTHING: AutopilotSyncResult = { opened: 0, refreshed: 0, resolved: 0 };
/**
* A suggestion lives suggestionTtlDays from its due day, from today if it is
* already overdue: a condition that has lasted longer than the ttl would
* otherwise expire the day it is proposed and never be seen.
*/
function expiresOn(dueDate: string, today: string, ttlDays: number): string {
return addDays(dueDate > today ? dueDate : today, ttlDays);
}
/**
* Reconciles the open autopilot suggestions of one colony with what the
* rules say today. It writes suggestions only, never tasks or colony data,
* and leaves rule and chain suggestions alone.
*/
@Injectable()
export class AutopilotService {
private readonly logger = new Logger(AutopilotService.name);
constructor(
private readonly suggestions: SuggestionsService,
private readonly seasons: SeasonService,
) {}
/** The rules and the season of the given apiaries, read once for many colonies. */
async preloadFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<AutopilotPreload> {
return {
configs: await this.readConfigs(db),
seasons: await this.seasons.statesFor(db, apiaryIds, today),
};
}
/**
* Locks the colony row, then: ended colony - nothing; autopilot off - all
* open autopilot suggestions RESOLVED; otherwise suggestions whose key is
* gone are RESOLVED, those with the same key refreshed, new candidates
* proposed (in the order A1...A10, so concurrent syncs take the proposal
* locks in the same order). A candidate is dropped by `proposeIn` when an
* open suggestion or task of the same intent exists. A colony that has
* ended is skipped here; its open suggestions become OBSOLETE through the
* handler `suggestions.colony-ended`. `maxStepsPerColony` is not applied:
* it limits the next-steps view, not what is proposed. `preload` spares a
* daily step the rule and season reads per colony.
*/
async syncColonyIn(
tx: Prisma.TransactionClient,
colonyId: string,
today: string,
preload?: AutopilotPreload,
): Promise<AutopilotSyncResult> {
assertTransactionClient(tx, 'AutopilotService.syncColonyIn');
if (!(await lockRowForUpdate(tx, 'Colony', colonyId))) {
return NOTHING;
}
const colony = await tx.colony.findUnique({
where: { id: colonyId },
select: { status: true, apiaryId: true },
});
if (colony === null || !isLivingColonyStatus(colony.status)) {
return NOTHING;
}
const configs = preload?.configs ?? (await this.readConfigs(tx));
const autopilot = configs.autopilot;
const open = await tx.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', status: 'OPEN' },
select: { id: true, dedupeKey: true, expiresAt: true },
orderBy: { id: 'asc' },
});
if (!autopilot.active) {
const resolved = await this.suggestions.resolveIn(
tx,
open.map((suggestion) => suggestion.id),
'Autopilot aus',
);
return { ...NOTHING, resolved };
}
const season = (
preload?.seasons ??
(await this.seasons.statesFor(tx, [colony.apiaryId], today))
).get(colony.apiaryId);
if (season === undefined) {
this.logger.warn(`No season state for the apiary of colony ${colonyId}`);
return NOTHING;
}
const input = await loadAutopilotInputIn(tx, colonyId, season);
if (input === null) {
this.logger.warn(`Colony ${colonyId} vanished during the autopilot sync`);
return NOTHING;
}
const candidates = evaluateAutopilot(input, configs, today);
const keyOf = (candidate: AutopilotCandidate): string =>
suggestionDedupeKey(candidate.ruleKey, colonyId, candidate.anchor);
const currentKeys = new Set(candidates.map(keyOf));
const resolved = await this.suggestions.resolveIn(
tx,
open
.filter((s) => s.dedupeKey === null || !currentKeys.has(s.dedupeKey))
.map((s) => s.id),
'Bedingung entfällt',
);
const openByKey = new Map(
open.flatMap((s) => (s.dedupeKey === null ? [] : [[s.dedupeKey, s]])),
);
const ttlDays = autopilot.config.suggestionTtlDays;
let opened = 0;
let refreshed = 0;
for (const candidate of candidates) {
const existing = openByKey.get(keyOf(candidate));
if (existing !== undefined) {
await this.suggestions.refreshIn(tx, existing.id, {
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
dueDate: candidate.dueDate,
priority: candidate.priority,
expiresAt:
existing.expiresAt === null ? null : utcDay(existing.expiresAt),
});
refreshed += 1;
continue;
}
const created = await this.suggestions.proposeIn(tx, {
ruleKey: candidate.ruleKey,
source: 'AUTOPILOT',
mode: candidate.mode,
intent: candidate.intent,
priority: candidate.priority,
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
requiredPermission: candidate.requiredPermission,
dueDate: candidate.dueDate,
expiresAt: expiresOn(candidate.dueDate, today, ttlDays),
anchor: candidate.anchor,
notify: false,
});
if (created !== null) {
opened += 1;
}
}
return { opened, refreshed, resolved };
}
private async readConfigs(db: DbClient): Promise<AutopilotConfigs> {
return {
autopilot: await readRule(db, 'SEASON_AUTOPILOT'),
interval: await readRule(db, 'INSPECTION_INTERVAL'),
varroa: await readRule(db, 'VARROA_THRESHOLD'),
phenology: (await readRule(db, 'PHENOLOGY')).config,
};
}
}
@@ -0,0 +1,95 @@
import { Logger } from '@nestjs/common';
import type { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotStep } from './autopilot.step';
import type { AutopilotService } from './autopilot.service';
describe('AutopilotStep', () => {
const tx = { tx: true };
let findMany: jest.Mock;
let transaction: jest.Mock;
let syncColonyIn: jest.Mock;
let preloadFor: jest.Mock;
const PRELOAD = { configs: {}, seasons: new Map() };
let step: AutopilotStep;
beforeEach(() => {
findMany = jest.fn().mockResolvedValue([
{ id: 'c1', apiaryId: 'a1' },
{ id: 'c2', apiaryId: 'a1' },
{ id: 'c3', apiaryId: 'a2' },
]);
transaction = jest.fn((work: (client: unknown) => unknown) => work(tx));
syncColonyIn = jest
.fn()
.mockResolvedValue({ opened: 1, refreshed: 0, resolved: 2 });
preloadFor = jest.fn().mockResolvedValue(PRELOAD);
step = new AutopilotStep(
{
colony: { findMany },
$transaction: transaction,
} as unknown as PrismaService,
{ syncColonyIn, preloadFor } as unknown as AutopilotService,
);
});
afterEach(() => jest.restoreAllMocks());
it('is the autopilot step', () => {
expect(step.name).toBe('autopilot');
});
it('syncs every living colony in a transaction of its own and sums up', async () => {
const result = await step.run('2026-05-12');
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
orderBy: { id: 'asc' },
}),
);
expect(transaction).toHaveBeenCalledTimes(3);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c1', '2026-05-12', PRELOAD);
expect(syncColonyIn).toHaveBeenCalledWith(tx, 'c3', '2026-05-12', PRELOAD);
expect(result).toEqual({
autopilotColonies: 3,
suggestionsOpened: 3,
suggestionsResolved: 6,
});
});
it('reads rules and seasons once for all colonies', async () => {
await step.run('2026-05-12');
expect(preloadFor).toHaveBeenCalledTimes(1);
expect(preloadFor).toHaveBeenCalledWith(
expect.anything(),
['a1', 'a2'],
'2026-05-12',
);
});
it('a failing colony does not stop the others; the step fails with the counters', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
syncColonyIn
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce({ opened: 1, refreshed: 0, resolved: 0 });
const failure = await step.run('2026-05-12').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
result: {
autopilotColonies: 2,
suggestionsOpened: 2,
suggestionsResolved: 2,
},
failedSteps: ['1 colony'],
});
});
});
@@ -0,0 +1,72 @@
import { Injectable, Logger } from '@nestjs/common';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { loggableError } from '../common/loggable-error';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { AutopilotService } from './autopilot.service';
/**
* Step "autopilot" of the daily evaluation: reconciles the suggestions of
* every living colony, one transaction (and one colony row lock) per colony.
* A failing colony is logged and does not stop the others; the step then
* fails with the counters of the colonies that went through, and the next
* run tries again.
*/
@Injectable()
export class AutopilotStep implements DailyEvaluationStep {
readonly name = 'autopilot';
private readonly logger = new Logger(AutopilotStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly autopilot: AutopilotService,
) {}
async run(today: string): Promise<JobResult> {
const colonies = await this.prisma.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true },
orderBy: { id: 'asc' },
});
// Rules and seasons are the same for every colony: read them once.
const preload = await this.autopilot.preloadFor(
this.prisma,
[...new Set(colonies.map((colony) => colony.apiaryId))],
today,
);
let synced = 0;
let failed = 0;
let opened = 0;
let resolved = 0;
for (const { id } of colonies) {
try {
const result = await this.prisma.$transaction((tx) =>
this.autopilot.syncColonyIn(tx, id, today, preload),
);
synced += 1;
opened += result.opened;
resolved += result.resolved;
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(
`Autopilot of colony ${id} failed: ${message}`,
stack,
);
failed += 1;
}
}
const counts: JobResult = {
autopilotColonies: synced,
suggestionsOpened: opened,
suggestionsResolved: resolved,
};
if (failed > 0) {
throw new JobPartialFailure(counts, [
`${failed} ${failed === 1 ? 'colony' : 'colonies'}`,
]);
}
return counts;
}
}
@@ -0,0 +1,81 @@
import type {
ColonyCondition,
ColonyStatus,
ColonyType,
SuggestionIntent,
SuggestionMode,
TaskPriority,
WinterCheckSeason,
} from '../../generated/prisma';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import type {
ActiveRule,
AutopilotRuleId,
PhenologyConfig,
} from '../automation/rule-config';
import type { SeasonState } from '../season/season-types';
import type { SuggestionEvidence } from '../suggestions/suggestion.types';
/** What the autopilot rules see of one living colony. Days are YYYY-MM-DD. */
export interface AutopilotInput {
colony: {
id: string;
code: string;
status: ColonyStatus;
colonyType: ColonyType;
apiaryId: string;
createdOn: string;
feedStatus: ColonyCondition | null;
};
lastInspection: {
id: string;
date: string;
colonyStrength: number | null;
feedStatus: ColonyCondition | null;
queenCellsPresent: boolean | null;
} | null;
lastVarroa: { id: string; date: string } | null;
lastTreatment: { id: string; startDay: string; endDay: string } | null;
/** A measurement after the end of the last treatment exists. */
varroaSinceLastTreatmentEnd: boolean;
// A4-A6 (Task 27).
feedingsSinceInspection: number;
feedingsSinceLateSummer: number;
treatmentsSinceLateSummer: number;
queen: { id: string; hatchYear: number | null } | null;
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
season: SeasonState;
}
export interface AutopilotConfigs {
autopilot: ActiveRule<'SEASON_AUTOPILOT'>;
interval: ActiveRule<'INSPECTION_INTERVAL'>;
/** Only `recheckAfterDays` is used here (A3). */
varroa: ActiveRule<'VARROA_THRESHOLD'>;
phenology: PhenologyConfig;
}
export interface AutopilotCandidate {
rule: AutopilotRuleId;
ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL';
intent: SuggestionIntent;
mode: SuggestionMode;
reasonCode: string;
evidence: SuggestionEvidence;
dueDate: string;
priority: TaskPriority;
anchor: string;
requiredPermission: PermissionKey;
}
export type AutopilotRule = (
input: AutopilotInput,
configs: AutopilotConfigs,
today: string,
) => AutopilotCandidate | null;
export interface AutopilotSyncResult {
opened: number;
refreshed: number;
resolved: number;
}
@@ -0,0 +1,29 @@
import type { SuggestionsService } from '../suggestions/suggestions.service';
import { SuggestionsExpiryStep } from './suggestions-expiry.step';
describe('SuggestionsExpiryStep', () => {
it('expires the open suggestions past their day and reports the count', async () => {
const expireDue = jest.fn().mockResolvedValue(3);
const step = new SuggestionsExpiryStep({
expireDue,
} as unknown as SuggestionsService);
expect(step.name).toBe('suggestions-expiry');
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 3 });
expect(expireDue).toHaveBeenCalledWith('2026-05-12');
});
it('is idempotent: a second run expires nothing more', async () => {
const expireDue = jest
.fn()
.mockResolvedValueOnce(2)
.mockResolvedValueOnce(0);
const step = new SuggestionsExpiryStep({
expireDue,
} as unknown as SuggestionsService);
await step.run('2026-05-12');
expect(await step.run('2026-05-12')).toEqual({ suggestionsExpired: 0 });
});
});
@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import type { DailyEvaluationStep } from '../jobs/daily-evaluation-steps';
import type { JobResult } from '../jobs/job';
import { SuggestionsService } from '../suggestions/suggestions.service';
/**
* Step "suggestions-expiry": OPEN suggestions past their expiry day become
* EXPIRED (one statement, which also frees their dedupe key). Running it
* twice for one day changes nothing the second time.
*/
@Injectable()
export class SuggestionsExpiryStep implements DailyEvaluationStep {
readonly name = 'suggestions-expiry';
constructor(private readonly suggestions: SuggestionsService) {}
async run(today: string): Promise<JobResult> {
return { suggestionsExpired: await this.suggestions.expireDue(today) };
}
}
@@ -518,6 +518,44 @@ describe('ColoniesService', () => {
);
});
it('writing occupiedFrames leaves strength untouched', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
strength: 7,
});
tx.colony.update.mockResolvedValue({
...parentColony,
strength: 7,
occupiedFrames: 14,
});
await service.update('colony-1', { occupiedFrames: 14 }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { occupiedFrames: 14 },
});
});
it('updateIn locks the colony row before reading before', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
const result = await service.updateIn(
tx as never,
'colony-1',
{ notes: 'x' },
'user-1',
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result.before.id).toBe('colony-1');
expect(result.after).toBe(parentColony);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('update records no event when status and health stay the same', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
@@ -8,7 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto';
@@ -105,51 +105,63 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
// Fail fast with 404; everything else is decided on the locked row.
await this.findById(id);
const { after } = await this.prisma.$transaction((tx) =>
this.updateIn(tx, id, dto, actingUserId),
);
return after;
}
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
});
// Correcting the apiary has to leave the same trace a deliberate move
// does - otherwise the colony's location history would silently skip a
// stand it was recorded on.
if (movesApiary) {
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
},
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
/**
* The core of update, for callers that already hold a transaction (an
* inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change.
*/
async updateIn(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateColonyDto,
actingUserId: string,
): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
});
return colony;
// Correcting the apiary has to leave the same trace a deliberate move
// does - otherwise the colony's location history would silently skip a
// stand it was recorded on.
if (movesApiary) {
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
},
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return { before, after: updated };
}
async move(
@@ -0,0 +1,117 @@
import {
broodConditionFromPattern,
planColonyTakeover,
} from './colony-takeover';
const inspection = {
colonyStrength: 12 as number | null,
broodPattern: 4 as number | null,
feedStatus: 'GOOD' as 'GOOD' | null,
};
const allowed = {
canUpdateColony: true,
colonyEnded: false,
hasNewerInspection: false,
};
describe('colony takeover', () => {
it.each([
[5, 'EXCELLENT'],
[4, 'GOOD'],
[3, 'AVERAGE'],
[2, 'WEAK'],
[1, 'CRITICAL'],
[null, null],
])('brood pattern %p -> %p', (pattern, expected) => {
expect(broodConditionFromPattern(pattern)).toBe(expected);
});
it('takes over only requested fields that have a value', () => {
const result = planColonyTakeover(
{ ...inspection, feedStatus: null },
['occupiedFrames', 'feedStatus'],
allowed,
);
expect(result.patch).toEqual({ occupiedFrames: 12 });
expect(result.takeover).toEqual({
applied: ['occupiedFrames'],
skipped: [{ field: 'feedStatus', reason: 'NO_VALUE' }],
});
});
it('maps the brood pattern to the brood status', () => {
const result = planColonyTakeover(inspection, ['broodStatus'], allowed);
expect(result.patch).toEqual({ broodStatus: 'GOOD' });
});
it('takes nothing over when nothing is requested', () => {
const result = planColonyTakeover(inspection, [], allowed);
expect(result.patch).toEqual({});
expect(result.takeover).toEqual({ applied: [], skipped: [] });
});
it('skips everything with NO_PERMISSION when the caller may not update colonies', () => {
const result = planColonyTakeover(
inspection,
['occupiedFrames', 'broodStatus', 'feedStatus'],
{ ...allowed, canUpdateColony: false, hasNewerInspection: true },
);
expect(result.patch).toEqual({});
expect(result.takeover.applied).toEqual([]);
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
{ field: 'broodStatus', reason: 'NO_PERMISSION' },
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
]);
});
it('skips everything with NEWER_INSPECTION when a later inspection exists', () => {
const result = planColonyTakeover(inspection, ['occupiedFrames'], {
...allowed,
hasNewerInspection: true,
});
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
]);
});
it('skips every field with COLONY_ENDED when the colony no longer exists', () => {
const result = planColonyTakeover(
inspection,
['occupiedFrames', 'feedStatus'],
{ ...allowed, colonyEnded: true },
);
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
{ field: 'feedStatus', reason: 'COLONY_ENDED' },
]);
});
it.each([45, -1])(
'skips OUT_OF_RANGE for the value %p outside the colony card range',
(value) => {
const result = planColonyTakeover(
{ ...inspection, colonyStrength: value },
['occupiedFrames'],
allowed,
);
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'OUT_OF_RANGE' },
]);
},
);
it('accepts the bounds 0 and 40', () => {
for (const value of [0, 40]) {
const result = planColonyTakeover(
{ ...inspection, colonyStrength: value },
['occupiedFrames'],
allowed,
);
expect(result.patch).toEqual({ occupiedFrames: value });
}
});
});
@@ -0,0 +1,121 @@
import type {
Colony,
ColonyCondition,
Inspection,
} from '../../generated/prisma';
export const COLONY_TAKEOVER_FIELDS = [
'occupiedFrames',
'broodStatus',
'feedStatus',
] as const;
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
export type TakeoverSkipReason =
| 'NO_VALUE'
| 'NO_PERMISSION'
| 'NEWER_INSPECTION'
| 'COLONY_ENDED'
| 'OUT_OF_RANGE';
export interface ColonyTakeover {
applied: ColonyTakeoverField[];
skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[];
}
export const MAX_OCCUPIED_FRAMES = 40;
const CONDITION_BY_BROOD_PATTERN: Record<number, ColonyCondition> = {
5: 'EXCELLENT',
4: 'GOOD',
3: 'AVERAGE',
2: 'WEAK',
1: 'CRITICAL',
};
/** Brood pattern 1-5 as the colony card's brood status; the mapping is an assumption. */
export function broodConditionFromPattern(
pattern: number | null,
): ColonyCondition | null {
if (pattern === null) {
return null;
}
return CONDITION_BY_BROOD_PATTERN[pattern] ?? null;
}
/** Only values that exist: a takeover never writes null. */
type TakeoverPatch = {
[K in ColonyTakeoverField]?: NonNullable<Colony[K]>;
};
type TakeoverSource = Pick<
Inspection,
'colonyStrength' | 'broodPattern' | 'feedStatus'
>;
interface FieldPlan {
patch: TakeoverPatch | null;
skip: TakeoverSkipReason | null;
}
function planField(
field: ColonyTakeoverField,
inspection: TakeoverSource,
): FieldPlan {
switch (field) {
case 'occupiedFrames': {
const frames = inspection.colonyStrength;
if (frames === null) {
return { patch: null, skip: 'NO_VALUE' };
}
if (frames < 0 || frames > MAX_OCCUPIED_FRAMES) {
return { patch: null, skip: 'OUT_OF_RANGE' };
}
return { patch: { occupiedFrames: frames }, skip: null };
}
case 'broodStatus': {
const status = broodConditionFromPattern(inspection.broodPattern);
return status === null
? { patch: null, skip: 'NO_VALUE' }
: { patch: { broodStatus: status }, skip: null };
}
case 'feedStatus':
return inspection.feedStatus === null
? { patch: null, skip: 'NO_VALUE' }
: { patch: { feedStatus: inspection.feedStatus }, skip: null };
}
}
/**
* What a saved inspection hands over to the colony card. Values come from the
* stored inspection only. A missing permission, an ended colony and a newer
* inspection each skip every requested field: a newer inspection blocks all
* fields, not only the one it would overwrite (spec 7.9).
*/
export function planColonyTakeover(
inspection: TakeoverSource,
requested: readonly ColonyTakeoverField[],
ctx: {
canUpdateColony: boolean;
colonyEnded: boolean;
hasNewerInspection: boolean;
},
): { patch: TakeoverPatch; takeover: ColonyTakeover } {
const patch: TakeoverPatch = {};
const takeover: ColonyTakeover = { applied: [], skipped: [] };
for (const field of requested) {
const plan: FieldPlan = !ctx.canUpdateColony
? { patch: null, skip: 'NO_PERMISSION' }
: ctx.colonyEnded
? { patch: null, skip: 'COLONY_ENDED' }
: ctx.hasNewerInspection
? { patch: null, skip: 'NEWER_INSPECTION' }
: planField(field, inspection);
if (plan.skip !== null) {
takeover.skipped.push({ field, reason: plan.skip });
continue;
}
Object.assign(patch, plan.patch);
takeover.applied.push(field);
}
return { patch, takeover };
}
@@ -25,13 +25,29 @@ export class CreateColonyDto {
@IsString()
originText?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 10 })
@ApiPropertyOptional({
minimum: 1,
maximum: 10,
deprecated: true,
description: 'Volksstärke (alt, Skala 1–10)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
strength?: number;
@ApiPropertyOptional({
minimum: 0,
maximum: 40,
description: 'Volksstärke in besetzten Waben',
})
@IsOptional()
@IsInt()
@Min(0)
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
occupiedFrames?: number;
@ApiPropertyOptional({ enum: ColonyCondition })
@IsOptional()
@IsEnum(ColonyCondition)
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
'originText',
'colonyType',
'strength',
'occupiedFrames',
'broodStatus',
'feedStatus',
'healthStatus',
@@ -115,6 +115,7 @@ const contracts: Record<string, NullContract> = {
'hiveType',
'originText',
'strength',
'occupiedFrames',
'broodStatus',
'feedStatus',
'healthStatus',
@@ -334,6 +335,7 @@ const contracts: Record<string, NullContract> = {
clearable: [
'durationMinutes',
'miteCount',
'sampleBees',
'calculatedValue',
'assessment',
'notes',
@@ -59,6 +59,7 @@ describe('ExportService', () => {
colonyType: 'PRODUCTION_COLONY',
status: 'ACTIVE',
strength: 8,
occupiedFrames: 14,
healthStatus: null,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
@@ -67,9 +68,10 @@ describe('ExportService', () => {
const csv = await service.exportColoniesCsv();
const lines = csv.split('\n');
expect(lines[0]).toBe(
'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt',
'id,code,apiaryId,colonyType,status,strength,healthStatus,createdAt,occupiedFrames',
);
expect(lines[1]).toContain('"V-1, Ost"');
expect(lines[1]).toMatch(/,8,,2026-01-01T00:00:00.000Z,14$/);
});
it('exports honey batches as CSV', async () => {
@@ -122,6 +122,8 @@ export class ExportService {
'strength',
'healthStatus',
'createdAt',
// Last: consumers of the CSV read columns by position.
'occupiedFrames',
]);
}
@@ -169,6 +169,32 @@ describe('ImportService', () => {
]);
});
it('carries strength and occupiedFrames from the CSV', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
prisma.colony.create.mockResolvedValue({ id: 'colony-1' });
const report = await service.importCsv(
'colonies',
Buffer.from(
[
'code,apiaryName,colonyType,strength,occupiedFrames',
'V-1,Teststand,PRODUCTION_COLONY,6,14',
'V-2,Teststand,PRODUCTION_COLONY,,41',
].join('\n'),
),
'user-1',
);
expect(report.created).toBe(1);
expect(report.errors).toBe(1);
expect(report.rows[1].message).toContain('Höchstens 40 besetzte Waben.');
const [[createArg]] = prisma.colony.create.mock.calls as [
[{ data: object }],
];
expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 });
});
it('reports a validation error for an invalid colonyType', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
@@ -179,6 +179,7 @@ export class ImportService {
hiveType: emptyToUndefined(record.hiveType),
originText: emptyToUndefined(record.originText),
strength: parseNumberField(record.strength),
occupiedFrames: parseNumberField(record.occupiedFrames),
broodStatus: emptyToUndefined(record.broodStatus),
feedStatus: feed.feedStatus ?? undefined,
healthStatus: emptyToUndefined(record.healthStatus),
@@ -1,8 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
@@ -10,6 +14,10 @@ import {
Max,
Min,
} from 'class-validator';
import {
COLONY_TAKEOVER_FIELDS,
type ColonyTakeoverField,
} from '../../colonies/colony-takeover';
import { Transform } from 'class-transformer';
import { ColonyCondition } from '../../../generated/prisma';
import { feedConditionFromInput } from '../../common/feed-vocabulary';
@@ -33,10 +41,15 @@ export class CreateInspectionDto {
@IsNumber()
temperature?: number;
@ApiPropertyOptional()
@ApiPropertyOptional({
minimum: 0,
maximum: 40,
description: 'Volksstärke in besetzten Waben',
})
@IsOptional()
@IsInt()
@Min(0)
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
colonyStrength?: number;
@ApiPropertyOptional({ description: 'Anzahl Brutwaben' })
@@ -146,4 +159,17 @@ export class CreateInspectionDto {
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional({
enum: COLONY_TAKEOVER_FIELDS,
isArray: true,
description:
'Colony card fields to fill from this inspection, in the same request. The server takes the values from the saved inspection.',
})
@IsOptional()
@IsArray()
@ArrayUnique()
@ArrayMaxSize(3)
@IsIn(COLONY_TAKEOVER_FIELDS, { each: true })
applyToColony?: ColonyTakeoverField[];
}
@@ -1,11 +1,13 @@
import { PartialType } from '@nestjs/swagger';
import { OmitType, PartialType } from '@nestjs/swagger';
import { CreateInspectionDto } from './create-inspection.dto';
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
* repair the colony's history without deleting the record. `applyToColony` is
* not part of a correction: a correction takes nothing over to the colony.
*/
export class UpdateInspectionDto extends PartialType(CreateInspectionDto, {
skipNullProperties: false,
}) {}
export class UpdateInspectionDto extends PartialType(
OmitType(CreateInspectionDto, ['applyToColony'] as const),
{ skipNullProperties: false },
) {}
@@ -1,15 +1,17 @@
import { Module } from '@nestjs/common';
import { TasksModule } from '../tasks/tasks.module';
import { ColoniesModule } from '../colonies/colonies.module';
import { InspectionsController } from './inspections.controller';
import { ColonyInspectionsController } from './colony-inspections.controller';
import { InspectionsService } from './inspections.service';
/**
* Rules, calendar and webhooks follow from the inspection's domain events
* (DomainEventsModule is global), so no AutomationModule here.
* (DomainEventsModule is global), so no AutomationModule here. The
* PermissionResolverService comes from the global resolver module.
*/
@Module({
imports: [TasksModule],
imports: [TasksModule, ColoniesModule],
controllers: [InspectionsController, ColonyInspectionsController],
providers: [InspectionsService],
exports: [InspectionsService],
@@ -4,13 +4,21 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { TasksService } from '../tasks/tasks.service';
import { ColoniesService } from '../colonies/colonies.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { Prisma } from '../../generated/prisma';
describe('InspectionsService', () => {
let service: InspectionsService;
let tx: {
$queryRaw: jest.Mock;
inspection: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
colony: { findUniqueOrThrow: jest.Mock };
inspection: {
findUnique: jest.Mock;
findFirst: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$connect: jest.Mock;
@@ -25,6 +33,8 @@ describe('InspectionsService', () => {
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
let tasks: { createInTx: jest.Mock };
let colonies: { updateIn: jest.Mock };
let resolver: { getPermissions: jest.Mock };
const inspection = {
id: 'inspection-1',
@@ -35,8 +45,12 @@ describe('InspectionsService', () => {
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
colony: {
findUniqueOrThrow: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
},
inspection: {
findUnique: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn(),
update: jest.fn(),
},
@@ -56,11 +70,17 @@ describe('InspectionsService', () => {
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
tasks = { createInTx: jest.fn().mockResolvedValue({ id: 'task-1' }) };
colonies = { updateIn: jest.fn().mockResolvedValue({}) };
resolver = {
getPermissions: jest.fn().mockResolvedValue(new Set(['colonies:update'])),
};
service = new InspectionsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
tasks as unknown as TasksService,
colonies as unknown as ColoniesService,
resolver as unknown as PermissionResolverService,
);
});
@@ -72,6 +92,8 @@ describe('InspectionsService', () => {
AuditLogService,
DomainEventsService,
TasksService,
ColoniesService,
PermissionResolverService,
]);
});
@@ -90,7 +112,10 @@ describe('InspectionsService', () => {
'user-1',
);
expect(result).toBe(inspection);
expect(result).toEqual({
...inspection,
colonyTakeover: { applied: [], skipped: [] },
});
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.inspection.create).toHaveBeenCalledWith({
data: {
@@ -122,6 +147,164 @@ describe('InspectionsService', () => {
expect(tasks.createInTx).not.toHaveBeenCalled();
});
describe('applyToColony', () => {
const saved = {
...inspection,
colonyStrength: 14,
broodPattern: 4,
feedStatus: 'GOOD',
};
it('locks the colony, then takes the stored values over via updateIn in the same transaction', async () => {
tx.inspection.create.mockResolvedValue(saved);
const result = await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
colonyStrength: 14,
applyToColony: ['occupiedFrames', 'broodStatus', 'feedStatus'],
},
'user-1',
);
const lock = tx.$queryRaw.mock.invocationCallOrder[0];
expect(lock).toBeLessThan(
tx.inspection.create.mock.invocationCallOrder[0],
);
expect(lock).toBeLessThan(
tx.inspection.findFirst.mock.invocationCallOrder[0],
);
expect(lock).toBeLessThan(colonies.updateIn.mock.invocationCallOrder[0]);
// Permissions are resolved before the transaction opens.
expect(resolver.getPermissions.mock.invocationCallOrder[0]).toBeLessThan(
prisma.$transaction.mock.invocationCallOrder[0],
);
const createArg = (
tx.inspection.create.mock.calls[0] as [{ data: object }]
)[0];
expect(createArg.data).not.toHaveProperty('applyToColony');
expect(colonies.updateIn).toHaveBeenCalledWith(
tx,
'colony-1',
{ occupiedFrames: 14, broodStatus: 'GOOD', feedStatus: 'GOOD' },
'user-1',
);
expect(result.colonyTakeover.applied).toEqual([
'occupiedFrames',
'broodStatus',
'feedStatus',
]);
});
it('takes no lock and resolves no permission for an empty applyToColony', async () => {
tx.inspection.create.mockResolvedValue(saved);
const result = await service.create(
{ colonyId: 'colony-1', date: '2026-01-01', applyToColony: [] },
'user-1',
);
expect(tx.$queryRaw).not.toHaveBeenCalled();
expect(resolver.getPermissions).not.toHaveBeenCalled();
expect(tx.inspection.findFirst).not.toHaveBeenCalled();
expect(result.colonyTakeover).toEqual({ applied: [], skipped: [] });
});
it('orders two inspections by date, then createdAt, then id', async () => {
tx.inspection.create.mockResolvedValue({
...saved,
date: new Date('2026-01-01'),
createdAt: new Date('2026-01-02T10:00:00Z'),
});
await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
applyToColony: ['feedStatus'],
},
'user-1',
);
const [[arg]] = tx.inspection.findFirst.mock.calls as [
[{ where: { colonyId: string; OR: object[] } }],
];
expect(arg.where.OR).toEqual([
{ date: { gt: new Date('2026-01-01') } },
{
date: new Date('2026-01-01'),
createdAt: { gt: new Date('2026-01-02T10:00:00Z') },
},
{
date: new Date('2026-01-01'),
createdAt: new Date('2026-01-02T10:00:00Z'),
id: { gt: 'inspection-1' },
},
]);
});
it('skips with COLONY_ENDED for an ended colony but still saves the inspection', async () => {
tx.inspection.create.mockResolvedValue(saved);
tx.colony.findUniqueOrThrow.mockResolvedValue({ status: 'DISSOLVED' });
const result = await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
applyToColony: ['occupiedFrames'],
},
'user-1',
);
expect(colonies.updateIn).not.toHaveBeenCalled();
expect(result.id).toBe('inspection-1');
expect(result.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
]);
});
it('skips with NEWER_INSPECTION and writes no colony change', async () => {
tx.inspection.create.mockResolvedValue(saved);
tx.inspection.findFirst.mockResolvedValue({ id: 'later' });
const result = await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
applyToColony: ['occupiedFrames'],
},
'user-1',
);
expect(colonies.updateIn).not.toHaveBeenCalled();
expect(result.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
]);
});
it('skips with NO_PERMISSION without colonies:update', async () => {
tx.inspection.create.mockResolvedValue(saved);
resolver.getPermissions.mockResolvedValue(
new Set(['inspections:create']),
);
const result = await service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
applyToColony: ['feedStatus'],
},
'user-1',
);
expect(colonies.updateIn).not.toHaveBeenCalled();
expect(result.colonyTakeover.skipped).toEqual([
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
]);
});
});
it('create fails as a whole when its event cannot be written', async () => {
tx.inspection.create.mockResolvedValue(inspection);
events.record.mockRejectedValue(new Error('outbox down'));
@@ -1,16 +1,28 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { TasksService } from '../tasks/tasks.service';
import { Inspection, Task } from '../../generated/prisma';
import { ColoniesService } from '../colonies/colonies.service';
import {
ColonyTakeover,
ColonyTakeoverField,
planColonyTakeover,
} from '../colonies/colony-takeover';
import { Inspection, Prisma, Task } from '../../generated/prisma';
import { CreateInspectionDto } from './dto/create-inspection.dto';
import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto';
import { UpdateInspectionDto } from './dto/update-inspection.dto';
import { optionalDate } from '../common/patch-values';
export type InspectionWithTakeover = Inspection & {
colonyTakeover: ColonyTakeover;
};
/**
* Inspections are written together with their audit entry and domain event
* in one transaction: InspectionRecorded, InspectionCorrected. Automation
@@ -26,6 +38,8 @@ export class InspectionsService {
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly tasks: TasksService,
private readonly colonies: ColoniesService,
private readonly permissionResolver: PermissionResolverService,
) {}
async findAll(): Promise<Inspection[]> {
@@ -46,10 +60,24 @@ export class InspectionsService {
async create(
dto: CreateInspectionDto,
actingUserId: string,
): Promise<Inspection> {
): Promise<InspectionWithTakeover> {
const { applyToColony = [], ...data } = dto;
// Resolved before the transaction (it may query the database): the
// permission is only needed when a takeover was requested.
const canUpdateColony =
applyToColony.length > 0 &&
(await this.permissionResolver.getPermissions(actingUserId)).has(
'colonies:update',
);
return this.prisma.$transaction(async (tx) => {
if (applyToColony.length > 0) {
// Colony first (before the inspection row takes a key-share lock on
// it): serializes concurrent inspections of one colony, so "is there
// a newer inspection" below is decided on a stable state.
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
}
const inspection = await tx.inspection.create({
data: { ...dto, date: new Date(dto.date), userId: actingUserId },
data: { ...data, date: new Date(dto.date), userId: actingUserId },
});
await this.auditLog.record(
{
@@ -68,10 +96,69 @@ export class InspectionsService {
payload: { colonyId: inspection.colonyId },
actorUserId: actingUserId,
});
return inspection;
const colonyTakeover = await this.takeOver(
tx,
inspection,
applyToColony,
canUpdateColony,
actingUserId,
);
return { ...inspection, colonyTakeover };
});
}
/**
* Hands values of the just-saved inspection over to the colony card. The
* values come from the stored row, never from the request.
*/
private async takeOver(
tx: Prisma.TransactionClient,
inspection: Inspection,
requested: readonly ColonyTakeoverField[],
canUpdateColony: boolean,
actingUserId: string,
): Promise<ColonyTakeover> {
if (requested.length === 0) {
return { applied: [], skipped: [] };
}
// The colony row is locked (see create), so both reads below see a state
// no concurrent inspection or colony change can alter.
const colony = await tx.colony.findUniqueOrThrow({
where: { id: inspection.colonyId },
select: { status: true },
});
// Deterministic order of two inspections: date, then createdAt, then id.
const newer = await tx.inspection.findFirst({
where: {
colonyId: inspection.colonyId,
OR: [
{ date: { gt: inspection.date } },
{ date: inspection.date, createdAt: { gt: inspection.createdAt } },
{
date: inspection.date,
createdAt: inspection.createdAt,
id: { gt: inspection.id },
},
],
},
select: { id: true },
});
const { patch, takeover } = planColonyTakeover(inspection, requested, {
canUpdateColony,
colonyEnded: !isLivingColonyStatus(colony.status),
hasNewerInspection: newer !== null,
});
if (Object.keys(patch).length > 0) {
await this.colonies.updateIn(
tx,
inspection.colonyId,
patch,
actingUserId,
);
}
return takeover;
}
/**
* A correction evaluates no automation rule, as before the event
* backbone. InspectionCorrected moves the calendar entry when the date or
@@ -1,6 +1,8 @@
import { Logger } from '@nestjs/common';
import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
import type { AutopilotStep } from '../autopilot/autopilot.step';
import type { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
@@ -11,14 +13,29 @@ describe('DailyEvaluationJob', () => {
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
};
const AUTOPILOT_COUNTS = {
autopilotColonies: 4,
suggestionsOpened: 2,
suggestionsResolved: 1,
};
let timeEvents: { name: 'time-events'; run: jest.Mock };
let autopilot: { name: 'autopilot'; run: jest.Mock };
let expiry: { name: 'suggestions-expiry'; run: jest.Mock };
let job: DailyEvaluationJob;
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-06-10T02:30:00.000Z') });
timeEvents = { name: 'time-events', run: jest.fn() };
timeEvents.run.mockResolvedValue(COUNTS);
job = new DailyEvaluationJob(timeEvents as unknown as TimeEventsStep);
autopilot = { name: 'autopilot', run: jest.fn() };
autopilot.run.mockResolvedValue(AUTOPILOT_COUNTS);
expiry = { name: 'suggestions-expiry', run: jest.fn() };
expiry.run.mockResolvedValue({ suggestionsExpired: 3 });
job = new DailyEvaluationJob(
timeEvents as unknown as TimeEventsStep,
autopilot as unknown as AutopilotStep,
expiry as unknown as SuggestionsExpiryStep,
);
});
afterEach(() => {
@@ -31,8 +48,44 @@ describe('DailyEvaluationJob', () => {
});
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual(COUNTS);
expect(await job.run()).toEqual({
...COUNTS,
...AUTOPILOT_COUNTS,
suggestionsExpired: 3,
});
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(autopilot.run).toHaveBeenCalledWith('2026-06-10');
expect(expiry.run).toHaveBeenCalledWith('2026-06-10');
});
it('runs time-events, autopilot and suggestions-expiry in that order', async () => {
await job.run();
expect(timeEvents.run.mock.invocationCallOrder[0]).toBeLessThan(
autopilot.run.mock.invocationCallOrder[0],
);
expect(autopilot.run.mock.invocationCallOrder[0]).toBeLessThan(
expiry.run.mock.invocationCallOrder[0],
);
});
it('a failing autopilot step does not stop the expiry step', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
autopilot.run.mockRejectedValue(
new JobPartialFailure({ autopilotColonies: 1 }, ['1 colony']),
);
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(expiry.run).toHaveBeenCalled();
expect(failure).toMatchObject({
failedSteps: ['autopilot'],
result: { autopilotColonies: 1, suggestionsExpired: 3 },
message: 'Daily evaluation failed for: autopilot: 1 colony',
});
});
it('takes the Berlin day, not the UTC day, around midnight', async () => {
@@ -1,4 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { AutopilotStep } from '../autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../autopilot/suggestions-expiry.step';
import { berlinToday } from '../common/dates';
import { JobPartialFailure } from '../system/job-run.service';
import {
@@ -21,11 +23,19 @@ export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(private readonly timeEvents: TimeEventsStep) {}
constructor(
private readonly timeEvents: TimeEventsStep,
private readonly autopilot: AutopilotStep,
private readonly suggestionsExpiry: SuggestionsExpiryStep,
) {}
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const steps: DailyEvaluationStep[] = [this.timeEvents];
const steps: DailyEvaluationStep[] = [
this.timeEvents,
this.autopilot,
this.suggestionsExpiry,
];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
berlinToday(),
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
@@ -47,6 +48,7 @@ const JOB_CLASSES = [
BackupModule,
SystemModule,
SeasonModule,
AutopilotModule,
],
providers: [
...JOB_CLASSES,
@@ -24,6 +24,7 @@ describe('NotificationsService', () => {
createManyAndReturn: jest.Mock;
findMany: jest.Mock;
findFirstOrThrow: jest.Mock;
findFirst: jest.Mock;
updateMany: jest.Mock;
};
user: { findMany: jest.Mock };
@@ -39,6 +40,7 @@ describe('NotificationsService', () => {
),
findMany: jest.fn().mockResolvedValue([]),
findFirstOrThrow: jest.fn(),
findFirst: jest.fn(),
updateMany: jest.fn(),
},
user: { findMany: jest.fn().mockResolvedValue([]) },
@@ -351,17 +353,33 @@ describe('NotificationsService', () => {
});
it('answers 404 when the notification is foreign or unknown', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 0 });
prisma.notification.findFirst.mockResolvedValue(null);
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
expect(prisma.notification.findFirst).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
select: { requiredPermissions: true },
});
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
});
it('marks the own notification as read', async () => {
it('answers 404 and changes nothing for a row whose permissions are no longer held', async () => {
prisma.notification.findFirst.mockResolvedValue({
requiredPermissions: ['colonies:read'],
});
await expect(service.markRead('n1', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
expect(prisma.notification.updateMany).not.toHaveBeenCalled();
expect(prisma.notification.findFirstOrThrow).not.toHaveBeenCalled();
});
it('marks the own visible notification as read', async () => {
held.add('colonies:read');
prisma.notification.findFirst.mockResolvedValue({
requiredPermissions: ['colonies:read'],
});
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
prisma.notification.findFirstOrThrow.mockResolvedValue({
id: 'n1',
@@ -371,16 +389,30 @@ describe('NotificationsService', () => {
await expect(service.markRead('n1', 'user-1')).resolves.toMatchObject({
read: true,
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n1', userId: 'user-1' },
data: { read: true },
});
});
it('marks only the own unread notifications as read', async () => {
prisma.notification.updateMany.mockResolvedValue({ count: 3 });
it('marks only the own unread notifications the user may see', async () => {
held.add('colonies:read');
prisma.notification.findMany.mockResolvedValue([
{ id: 'a', requiredPermissions: [] },
{ id: 'b', requiredPermissions: ['colonies:read'] },
{ id: 'c', requiredPermissions: ['varroa:read'] },
]);
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
await expect(service.markAllRead('user-1')).resolves.toEqual({
updated: 3,
updated: 2,
});
expect(prisma.notification.findMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
select: { id: true, requiredPermissions: true },
});
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-1', read: false },
where: { id: { in: ['a', 'b'] }, userId: 'user-1', read: false },
data: { read: true },
});
});
@@ -168,8 +168,19 @@ export class NotificationsService {
.map(toView);
}
/** One statement; a foreign or unknown id matches nothing and answers 404. */
/**
* A foreign, unknown or no longer visible row (same rule as findOwn)
* answers 404 and is left untouched.
*/
async markRead(id: string, userId: string): Promise<NotificationView> {
const row = await this.prisma.notification.findFirst({
where: { id, userId },
select: { requiredPermissions: true },
});
const held = await this.permissionResolver.getPermissions(userId);
if (!row || !row.requiredPermissions.every((key) => held.has(key))) {
throw new NotFoundException('Notification not found');
}
const { count } = await this.prisma.notification.updateMany({
where: { id, userId },
data: { read: true },
@@ -183,9 +194,21 @@ export class NotificationsService {
});
}
/** Marks only the unread rows the user may see. */
async markAllRead(userId: string): Promise<{ updated: number }> {
const { count } = await this.prisma.notification.updateMany({
const unread = await this.prisma.notification.findMany({
where: { userId, read: false },
select: { id: true, requiredPermissions: true },
});
const held = await this.permissionResolver.getPermissions(userId);
const visible = unread
.filter((row) => row.requiredPermissions.every((key) => held.has(key)))
.map((row) => row.id);
if (visible.length === 0) {
return { updated: 0 };
}
const { count } = await this.prisma.notification.updateMany({
where: { id: { in: visible }, userId, read: false },
data: { read: true },
});
return { updated: count };
@@ -0,0 +1,174 @@
import { DEFAULT_SPLIT_SCHEDULE_CONFIG } from '../../automation/rule-config/split-schedule.config';
import { DEFAULT_CHAIN_CONFIG } from '../../automation/rule-config/chain.config';
import {
queenAcceptanceCandidate,
queenCellsCandidate,
splitScheduleCandidate,
} from './chain-planner';
const inspection = (queenCellsPresent: boolean | null) => ({
id: 'insp-1',
colonyId: 'col-1',
// 10:30 Berlin time on 2026-05-05
date: new Date('2026-05-05T08:30:00.000Z'),
queenCellsPresent,
});
const split = (queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null) => ({
parentColonyId: 'col-0',
lineageId: 'lin-1',
queenSource,
splitOn: '2026-05-01',
});
describe('chain planner', () => {
it('queen cells on 2026-05-05 propose a swarm check due 2026-05-12', () => {
const candidate = queenCellsCandidate(
inspection(true),
DEFAULT_CHAIN_CONFIG,
);
expect(candidate).toMatchObject({
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
intent: 'SWARM_CHECK',
anchor: 'insp-1',
baseDate: '2026-05-05',
dueDate: '2026-05-12',
plan: null,
requiredPermission: 'inspections:read',
});
expect(candidate?.evidence).toEqual({
colonyId: 'col-1',
inspectionId: 'insp-1',
inspectedOn: '2026-05-05',
baseDate: '2026-05-05',
});
});
it('no queen cells (false or unknown) yield nothing', () => {
expect(
queenCellsCandidate(inspection(false), DEFAULT_CHAIN_CONFIG),
).toBeNull();
expect(
queenCellsCandidate(inspection(null), DEFAULT_CHAIN_CONFIG),
).toBeNull();
});
it('uses the configured dueInDays', () => {
const candidate = queenCellsCandidate(inspection(true), {
...DEFAULT_CHAIN_CONFIG,
dueInDays: 3,
});
expect(candidate?.dueDate).toBe('2026-05-08');
});
it('RAISE_OWN yields one SPLIT_PLAN with offsets 9, 21 (window 3) and 28 from the split date', () => {
const candidate = splitScheduleCandidate(
'col-new',
split('RAISE_OWN'),
DEFAULT_SPLIT_SCHEDULE_CONFIG,
);
expect(candidate).toMatchObject({
ruleKey: 'SPLIT_SCHEDULE',
intent: 'SPLIT_PLAN',
anchor: 'lin-1',
baseDate: '2026-05-01',
dueDate: '2026-05-10',
requiredPermission: 'colonies:read',
});
expect(candidate?.plan).toEqual([
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
]);
expect(candidate?.evidence).toMatchObject({
colonyId: 'col-new',
parentColonyId: 'col-0',
lineageId: 'lin-1',
baseDate: '2026-05-01',
});
});
it('QUEEN_CELL yields one step at +21; QUEEN yields an acceptance check at +7', () => {
const cell = splitScheduleCandidate(
'col-new',
split('QUEEN_CELL'),
DEFAULT_SPLIT_SCHEDULE_CONFIG,
);
expect(cell?.intent).toBe('SPLIT_PLAN');
expect(cell?.plan).toEqual([{ step: 'EGGS_CHECK', offsetDays: 21 }]);
expect(cell?.dueDate).toBe('2026-05-22');
const queen = splitScheduleCandidate(
'col-new',
split('QUEEN'),
DEFAULT_SPLIT_SCHEDULE_CONFIG,
);
expect(queen).toMatchObject({
intent: 'QUEEN_ACCEPTANCE',
requiredPermission: 'queens:read',
anchor: 'lin-1',
dueDate: '2026-05-08',
});
expect(queen?.plan).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
});
it('a split without queenSource yields nothing', () => {
expect(
splitScheduleCandidate(
'col-new',
split(null),
DEFAULT_SPLIT_SCHEDULE_CONFIG,
),
).toBeNull();
});
it('the due date is the earliest step, whatever the order of the steps', () => {
const candidate = splitScheduleCandidate('col-new', split('RAISE_OWN'), {
...DEFAULT_SPLIT_SCHEDULE_CONFIG,
RAISE_OWN: [
{ step: 'EGGS_CHECK', offsetDays: 28 },
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
],
});
expect(candidate?.dueDate).toBe('2026-05-10');
});
it('a variant without steps yields nothing', () => {
expect(
splitScheduleCandidate('col-new', split('QUEEN'), {
...DEFAULT_SPLIT_SCHEDULE_CONFIG,
QUEEN: [],
}),
).toBeNull();
});
it('a queen assignment proposes an acceptance check +7, anchored on the history row', () => {
const candidate = queenAcceptanceCandidate(
{
colonyId: 'col-1',
historyId: 'hist-1',
assignedOn: '2026-05-05',
previousColonyId: null,
},
'queen-1',
DEFAULT_CHAIN_CONFIG,
);
expect(candidate).toMatchObject({
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
anchor: 'hist-1',
baseDate: '2026-05-05',
dueDate: '2026-05-12',
plan: null,
requiredPermission: 'queens:read',
});
expect(candidate.evidence).toMatchObject({
colonyId: 'col-1',
queenId: 'queen-1',
baseDate: '2026-05-05',
});
});
});
@@ -0,0 +1,121 @@
import type { Inspection } from '../../../generated/prisma';
import type {
ChainConfig,
PlanStepConfig,
SplitScheduleConfig,
} from '../../automation/rule-config';
import type { PermissionKey } from '../../auth/permissions/permission-catalog';
import { addDays, berlinDayOf } from '../../common/dates';
import type { DomainEventPayloads } from '../../domain-events/domain-event-types';
import type { SuggestionEvidence } from '../suggestion.types';
export interface ChainCandidate {
ruleKey:
'QUEEN_CELLS_SWARM_CHECK' | 'QUEEN_ACCEPTANCE_CHECK' | 'SPLIT_SCHEDULE';
intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN';
reasonCode: string;
anchor: string;
/** YYYY-MM-DD, the day the offsets count from. */
baseDate: string;
/** YYYY-MM-DD, the day of the first (or only) check. */
dueDate: string;
plan: PlanStepConfig[] | null;
requiredPermission: PermissionKey;
evidence: SuggestionEvidence;
}
/** A queen-cell finding on an inspection: check for a swarm a week later. */
export function queenCellsCandidate(
inspection: Pick<
Inspection,
'id' | 'colonyId' | 'date' | 'queenCellsPresent'
>,
config: ChainConfig,
): ChainCandidate | null {
if (inspection.queenCellsPresent !== true) {
return null;
}
const baseDate = berlinDayOf(inspection.date);
return {
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
intent: 'SWARM_CHECK',
reasonCode: 'QUEEN_CELLS_SWARM_CHECK',
anchor: inspection.id,
baseDate,
dueDate: addDays(baseDate, config.dueInDays),
plan: null,
requiredPermission: 'inspections:read',
evidence: {
colonyId: inspection.colonyId,
inspectionId: inspection.id,
inspectedOn: baseDate,
baseDate,
},
};
}
/** A queen put into a colony: check that it was accepted. */
export function queenAcceptanceCandidate(
payload: DomainEventPayloads['QueenAssigned'],
queenId: string,
config: ChainConfig,
): ChainCandidate {
return {
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
reasonCode: 'QUEEN_ASSIGNED_ACCEPTANCE',
anchor: payload.historyId,
baseDate: payload.assignedOn,
dueDate: addDays(payload.assignedOn, config.dueInDays),
plan: null,
requiredPermission: 'queens:read',
evidence: {
colonyId: payload.colonyId,
queenId,
historyId: payload.historyId,
assignedOn: payload.assignedOn,
baseDate: payload.assignedOn,
},
};
}
/**
* The follow-up plan of a split, by where the new colony's queen comes
* from. `colonyId` is the new colony. No queen source, no chain.
*/
export function splitScheduleCandidate(
colonyId: string,
payload: DomainEventPayloads['ColonySplit'],
config: SplitScheduleConfig,
): ChainCandidate | null {
const { queenSource } = payload;
if (queenSource === null) {
return null;
}
const plan = config[queenSource].map((step) => ({ ...step }));
if (plan.length === 0) {
return null;
}
const isAcceptance = queenSource === 'QUEEN';
return {
ruleKey: 'SPLIT_SCHEDULE',
intent: isAcceptance ? 'QUEEN_ACCEPTANCE' : 'SPLIT_PLAN',
reasonCode: `SPLIT_SCHEDULE_${queenSource}`,
anchor: payload.lineageId,
baseDate: payload.splitOn,
dueDate: addDays(
payload.splitOn,
Math.min(...plan.map((step) => step.offsetDays)),
),
plan,
requiredPermission: isAcceptance ? 'queens:read' : 'colonies:read',
evidence: {
colonyId,
parentColonyId: payload.parentColonyId,
lineageId: payload.lineageId,
queenSource,
splitOn: payload.splitOn,
baseDate: payload.splitOn,
},
};
}
@@ -0,0 +1,360 @@
import type { Prisma } from '../../../generated/prisma';
import { lockRowForUpdate } from '../../prisma/row-lock';
import type {
AnyDomainEventEnvelope,
DomainEventEnvelope,
DomainEventType,
} from '../../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../../domain-events/on-domain-event.decorator';
import type { SuggestionsService } from '../suggestions.service';
import { ChainEventHandlers } from './chains.handlers';
jest.mock('../../prisma/row-lock');
function envelope<T extends DomainEventType>(
type: T,
aggregateId: string,
payload: DomainEventEnvelope<T>['payload'],
): AnyDomainEventEnvelope<T> {
return {
id: 'ev-1',
type,
aggregateType: 'Inspection',
aggregateId,
payload,
actorUserId: null,
correlationId: 'corr-1',
occurredAt: new Date('2026-05-05T08:00:00.000Z'),
attempts: 0,
} as unknown as AnyDomainEventEnvelope<T>;
}
interface RuleRow {
id: string;
active: boolean;
config: unknown;
actionTemplate: string | null;
}
const assignedEvent = () =>
envelope('QueenAssigned', 'queen-1', {
colonyId: 'col-1',
historyId: 'hist-1',
assignedOn: '2026-05-05',
previousColonyId: null,
});
const splitEvent = (queenSource: 'RAISE_OWN' | 'QUEEN' | null) =>
envelope('ColonySplit', 'col-new', {
parentColonyId: 'col-0',
lineageId: 'lin-1',
queenSource,
splitOn: '2026-05-01',
});
describe('ChainEventHandlers', () => {
let tx: {
colony: { findUnique: jest.Mock };
inspection: { findUnique: jest.Mock; findMany: jest.Mock };
suggestion: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
};
let suggestions: { proposeIn: jest.Mock; resolveIn: jest.Mock };
let handlers: ChainEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
const rule = (overrides: Partial<RuleRow> = {}): RuleRow => ({
id: 'rule-1',
active: true,
config: {},
actionTemplate: null,
...overrides,
});
const inspectionEvent = () =>
envelope('InspectionRecorded', 'insp-1', { colonyId: 'col-1' });
const inspectionRow = (overrides: object = {}) => ({
id: 'insp-1',
colonyId: 'col-1',
date: new Date('2026-05-05T08:30:00.000Z'),
createdAt: new Date('2026-05-05T08:31:00.000Z'),
queenCellsPresent: true,
queenSeen: false,
eggsPresent: false,
...overrides,
});
const oldCheck = (inspectionId = 'insp-0') => ({
id: 'old',
evidence: { colonyId: 'col-1', inspectionId },
});
const firstWhere = () =>
(tx.suggestion.findMany.mock.calls[0] as [{ where: object }])[0].where;
beforeEach(() => {
(lockRowForUpdate as jest.Mock).mockReset().mockResolvedValue(true);
tx = {
colony: {
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
},
inspection: {
findUnique: jest.fn().mockResolvedValue(inspectionRow()),
findMany: jest.fn().mockResolvedValue([
{
id: 'insp-0',
date: new Date('2026-04-28T08:30:00.000Z'),
createdAt: new Date('2026-04-28T08:31:00.000Z'),
},
]),
},
suggestion: { findMany: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(rule()) },
};
suggestions = {
proposeIn: jest.fn().mockResolvedValue({ id: 'sug-1' }),
resolveIn: jest.fn().mockResolvedValue(0),
};
handlers = new ChainEventHandlers(
suggestions as unknown as SuggestionsService,
);
});
it('registers its three handler ids', () => {
const ids = (
['queenCells', 'queenAcceptance', 'splitSchedule'] as const
).flatMap((name) =>
// eslint-disable-next-line @typescript-eslint/unbound-method -- only the decorator metadata is read
domainEventHandlerMetadata(ChainEventHandlers.prototype[name]).map(
(entry) => entry.handlerId,
),
);
expect(ids).toEqual([
'chains.queen-cells',
'chains.queen-acceptance',
'chains.split-schedule',
]);
});
it('the handlers pass notify from the chain config to proposeIn', async () => {
tx.automationRule.findUnique.mockResolvedValue(
rule({ config: { notify: true, dueInDays: 5 } }),
);
await handlers.queenCells(inspectionEvent(), asTx());
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
source: 'CHAIN',
mode: 'PLAN',
intent: 'SWARM_CHECK',
relatedType: 'COLONY',
relatedId: 'col-1',
colonyId: 'col-1',
anchor: 'insp-1',
dueDate: '2026-05-10',
notify: true,
sourceEventId: 'ev-1',
}),
);
suggestions.proposeIn.mockClear();
tx.automationRule.findUnique.mockResolvedValue(
rule({ config: { notify: true } }),
);
await handlers.queenAcceptance(assignedEvent(), asTx());
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({ intent: 'QUEEN_ACCEPTANCE', notify: true }),
);
suggestions.proposeIn.mockClear();
await handlers.splitSchedule(splitEvent('RAISE_OWN'), asTx());
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({
ruleKey: 'SPLIT_SCHEDULE',
intent: 'SPLIT_PLAN',
colonyId: 'col-new',
relatedId: 'col-new',
notify: true,
plan: expect.any(Array) as unknown,
}),
);
});
it('notify defaults to false', async () => {
await handlers.queenAcceptance(assignedEvent(), asTx());
expect(suggestions.proposeIn).toHaveBeenCalledWith(
tx,
expect.objectContaining({ notify: false }),
);
});
it('locks the colony before it resolves or proposes anything', async () => {
const order: string[] = [];
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
suggestions.resolveIn.mockImplementation(() => {
order.push('resolve');
return Promise.resolve(1);
});
suggestions.proposeIn.mockImplementation(() => {
order.push('propose');
return Promise.resolve(null);
});
await handlers.queenCells(inspectionEvent(), asTx());
expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Colony', 'col-1');
expect(order[0]).toBe('lock');
expect(order.indexOf('resolve')).toBeLessThan(order.indexOf('propose'));
});
it('resolves all open swarm checks when the inspection finds queen cells', async () => {
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
await handlers.queenCells(inspectionEvent(), asTx());
expect(firstWhere()).toEqual({
colonyId: 'col-1',
status: 'OPEN',
source: 'CHAIN',
intent: 'SWARM_CHECK',
});
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['old'],
'Mit Durchsicht erledigt',
);
});
it('leaves a swarm check open whose base inspection is not older than this one', async () => {
// A backdated inspection: the open check rests on a newer inspection.
tx.inspection.findMany.mockResolvedValue([
{
id: 'insp-0',
date: new Date('2026-05-09T08:30:00.000Z'),
createdAt: new Date('2026-05-09T08:31:00.000Z'),
},
]);
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
await handlers.queenCells(inspectionEvent(), asTx());
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
[],
'Mit Durchsicht erledigt',
);
});
it('on the same day the later created inspection wins', async () => {
tx.inspection.findMany.mockResolvedValue([
{
id: 'insp-0',
date: new Date('2026-05-05T08:30:00.000Z'),
createdAt: new Date('2026-05-05T08:30:00.000Z'),
},
]);
tx.suggestion.findMany.mockResolvedValue([oldCheck()]);
await handlers.queenCells(inspectionEvent(), asTx());
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
['old'],
'Mit Durchsicht erledigt',
);
});
it('re-running the event for the same inspection resolves nothing', async () => {
tx.inspection.findMany.mockResolvedValue([
{
id: 'insp-1',
date: new Date('2026-05-05T08:30:00.000Z'),
createdAt: new Date('2026-05-05T08:31:00.000Z'),
},
]);
tx.suggestion.findMany.mockResolvedValue([oldCheck('insp-1')]);
await handlers.queenCells(inspectionEvent(), asTx());
expect(suggestions.resolveIn).toHaveBeenCalledWith(
tx,
[],
'Mit Durchsicht erledigt',
);
});
it('without queen cells only swarm checks due up to the inspection day are resolved, and nothing is proposed', async () => {
tx.inspection.findUnique.mockResolvedValue(
inspectionRow({ queenCellsPresent: false }),
);
await handlers.queenCells(inspectionEvent(), asTx());
expect(firstWhere()).toEqual({
colonyId: 'col-1',
status: 'OPEN',
source: 'CHAIN',
intent: 'SWARM_CHECK',
dueDate: { lte: new Date('2026-05-05T00:00:00.000Z') },
});
expect(suggestions.proposeIn).not.toHaveBeenCalled();
});
it('resolves acceptance checks only when the queen or eggs were seen', async () => {
tx.inspection.findUnique.mockResolvedValue(
inspectionRow({ queenCellsPresent: false }),
);
await handlers.queenCells(inspectionEvent(), asTx());
expect(tx.suggestion.findMany).toHaveBeenCalledTimes(1);
tx.suggestion.findMany.mockClear();
tx.inspection.findUnique.mockResolvedValue(
inspectionRow({ queenCellsPresent: false, eggsPresent: true }),
);
await handlers.queenCells(inspectionEvent(), asTx());
const intents = (
tx.suggestion.findMany.mock.calls as [{ where: { intent: string } }][]
).map(([arg]) => arg.where.intent);
expect(intents).toEqual(['SWARM_CHECK', 'QUEEN_ACCEPTANCE']);
});
it('nothing for an ended colony', async () => {
tx.colony.findUnique.mockResolvedValue({ status: 'DISSOLVED' });
await handlers.queenCells(inspectionEvent(), asTx());
await handlers.queenAcceptance(assignedEvent(), asTx());
await handlers.splitSchedule(splitEvent('QUEEN'), asTx());
expect(suggestions.proposeIn).not.toHaveBeenCalled();
expect(suggestions.resolveIn).not.toHaveBeenCalled();
});
it('nothing for an inactive rule', async () => {
tx.automationRule.findUnique.mockResolvedValue(rule({ active: false }));
await handlers.queenCells(inspectionEvent(), asTx());
await handlers.queenAcceptance(assignedEvent(), asTx());
await handlers.splitSchedule(splitEvent('QUEEN'), asTx());
expect(suggestions.proposeIn).not.toHaveBeenCalled();
});
it('nothing for a colony or inspection that no longer exists', async () => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
await handlers.queenCells(inspectionEvent(), asTx());
expect(tx.colony.findUnique).not.toHaveBeenCalled();
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
tx.inspection.findUnique.mockResolvedValue(null);
await handlers.queenCells(inspectionEvent(), asTx());
expect(suggestions.proposeIn).not.toHaveBeenCalled();
});
it('a split without queenSource proposes nothing', async () => {
await handlers.splitSchedule(splitEvent(null), asTx());
expect(suggestions.proposeIn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,263 @@
import { Injectable } from '@nestjs/common';
import type {
Inspection,
Prisma,
SuggestionIntent,
} from '../../../generated/prisma';
import { readRule } from '../../automation/rule-config';
import { isLivingColonyStatus } from '../../colonies/colony-status';
import { berlinDayOf, dayToDate } from '../../common/dates';
import type { AnyDomainEventEnvelope } from '../../domain-events/domain-event-types';
import { OnDomainEvent } from '../../domain-events/on-domain-event.decorator';
import { lockRowForUpdate } from '../../prisma/row-lock';
import type { ProposeInput } from '../suggestion.types';
import { SuggestionsService } from '../suggestions.service';
import {
queenAcceptanceCandidate,
queenCellsCandidate,
splitScheduleCandidate,
type ChainCandidate,
} from './chain-planner';
type Tx = Prisma.TransactionClient;
/**
* Follow-up chains of the beekeeping craft: queen cells lead to a swarm
* check, a new queen to an acceptance check, a split to its schedule. Each
* handler locks the colony first (the same order as the colony sync), then
* lets SuggestionsService do the dedupe and the notification. Writes only
* through `tx`; an ended colony takes no new chain.
*/
@Injectable()
export class ChainEventHandlers {
constructor(private readonly suggestions: SuggestionsService) {}
@OnDomainEvent('InspectionRecorded', 'chains.queen-cells')
async queenCells(
event: AnyDomainEventEnvelope<'InspectionRecorded'>,
tx: Tx,
): Promise<void> {
const { colonyId } = event.payload;
if (!(await this.lockLivingColonyIn(tx, colonyId))) {
return;
}
const inspection = await tx.inspection.findUnique({
where: { id: event.aggregateId },
});
if (inspection === null) {
return;
}
// Clean up first: an open check of the same intent would block the new one.
await this.resolveAnsweredChecksIn(tx, inspection);
const rule = await readRule(tx, 'QUEEN_CELLS_SWARM_CHECK');
if (!rule.active) {
return;
}
const candidate = queenCellsCandidate(inspection, rule.config);
if (candidate !== null) {
await this.proposeIn(
tx,
colonyId,
candidate,
rule.config.notify,
event.id,
);
}
}
@OnDomainEvent('QueenAssigned', 'chains.queen-acceptance')
async queenAcceptance(
event: AnyDomainEventEnvelope<'QueenAssigned'>,
tx: Tx,
): Promise<void> {
const { colonyId } = event.payload;
if (!(await this.lockLivingColonyIn(tx, colonyId))) {
return;
}
const rule = await readRule(tx, 'QUEEN_ACCEPTANCE_CHECK');
if (!rule.active) {
return;
}
const candidate = queenAcceptanceCandidate(
event.payload,
event.aggregateId,
rule.config,
);
await this.proposeIn(tx, colonyId, candidate, rule.config.notify, event.id);
}
// The event's aggregate is the new colony.
@OnDomainEvent('ColonySplit', 'chains.split-schedule')
async splitSchedule(
event: AnyDomainEventEnvelope<'ColonySplit'>,
tx: Tx,
): Promise<void> {
const colonyId = event.aggregateId;
if (!(await this.lockLivingColonyIn(tx, colonyId))) {
return;
}
const rule = await readRule(tx, 'SPLIT_SCHEDULE');
if (!rule.active) {
return;
}
const candidate = splitScheduleCandidate(
colonyId,
event.payload,
rule.config,
);
if (candidate !== null) {
await this.proposeIn(
tx,
colonyId,
candidate,
rule.config.notify,
event.id,
);
}
}
/** Locks the colony row; true when the colony exists and still lives. */
private async lockLivingColonyIn(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);
}
/**
* An inspection answers the open chain checks of its colony: queen cells
* found replace every open swarm check that rests on an older inspection,
* otherwise the swarm checks due by now are done; an acceptance check is
* done once the queen or eggs were seen. A backdated inspection never
* closes a check that rests on a newer one.
*/
private async resolveAnsweredChecksIn(
tx: Tx,
inspection: Pick<
Inspection,
| 'id'
| 'colonyId'
| 'date'
| 'createdAt'
| 'queenCellsPresent'
| 'queenSeen'
| 'eggsPresent'
>,
): Promise<void> {
const dueUpTo = { lte: dayToDate(berlinDayOf(inspection.date)) };
const swarmChecks = await this.openChecksIn(
tx,
inspection.colonyId,
'SWARM_CHECK',
inspection.queenCellsPresent === true ? undefined : dueUpTo,
);
await this.suggestions.resolveIn(
tx,
await this.restingOnOlderIn(tx, swarmChecks, inspection),
'Mit Durchsicht erledigt',
);
if (inspection.queenSeen === true || inspection.eggsPresent === true) {
const acceptanceChecks = await this.openChecksIn(
tx,
inspection.colonyId,
'QUEEN_ACCEPTANCE',
dueUpTo,
);
await this.suggestions.resolveIn(
tx,
acceptanceChecks.map((check) => check.id),
'Mit Durchsicht erledigt',
);
}
}
private async openChecksIn(
tx: Tx,
colonyId: string,
intent: SuggestionIntent,
dueDate: { lte: Date } | undefined,
): Promise<{ id: string; evidence: Prisma.JsonValue }[]> {
return tx.suggestion.findMany({
where: {
colonyId,
status: 'OPEN',
source: 'CHAIN',
intent,
...(dueDate === undefined ? {} : { dueDate }),
},
select: { id: true, evidence: true },
});
}
/**
* The checks whose base inspection (evidence.inspectionId) is older than
* `inspection`: by date, on the same date by creation time. A check whose
* base inspection is gone counts as older.
*/
private async restingOnOlderIn(
tx: Tx,
checks: readonly { id: string; evidence: Prisma.JsonValue }[],
inspection: Pick<Inspection, 'id' | 'date' | 'createdAt'>,
): Promise<string[]> {
const baseIdOf = (evidence: Prisma.JsonValue): string | null => {
const id = (evidence as { inspectionId?: unknown } | null)?.inspectionId;
return typeof id === 'string' ? id : null;
};
const baseIds = checks.flatMap((check) => baseIdOf(check.evidence) ?? []);
if (baseIds.length === 0) {
return checks.map((check) => check.id);
}
const bases = new Map(
(
await tx.inspection.findMany({
where: { id: { in: baseIds } },
select: { id: true, date: true, createdAt: true },
})
).map((base) => [base.id, base]),
);
const isOlder = (base: { id: string; date: Date; createdAt: Date }) =>
base.id !== inspection.id &&
(base.date.getTime() < inspection.date.getTime() ||
(base.date.getTime() === inspection.date.getTime() &&
base.createdAt.getTime() < inspection.createdAt.getTime()));
return checks
.filter((check) => {
const baseId = baseIdOf(check.evidence);
const base = baseId === null ? undefined : bases.get(baseId);
return base === undefined || isOlder(base);
})
.map((check) => check.id);
}
private async proposeIn(
tx: Tx,
colonyId: string,
candidate: ChainCandidate,
notify: boolean,
sourceEventId: string,
): Promise<void> {
const input: ProposeInput = {
ruleKey: candidate.ruleKey,
source: 'CHAIN',
mode: 'PLAN',
intent: candidate.intent,
priority: 'NORMAL',
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: candidate.reasonCode,
evidence: candidate.evidence,
requiredPermission: candidate.requiredPermission,
dueDate: candidate.dueDate,
plan: candidate.plan,
anchor: candidate.anchor,
sourceEventId,
notify,
};
await this.suggestions.proposeIn(tx, input);
}
}
@@ -106,22 +106,5 @@ export function renderSuggestion(
return override ? { ...rendered, title: override } : rendered;
}
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
const numberFormats = new Map<number, Intl.NumberFormat>();
/** A number with a decimal comma: 12.4 as '12,4'. */
export function formatNumberDe(value: number, maxFractionDigits = 1): string {
let format = numberFormats.get(maxFractionDigits);
if (!format) {
format = new Intl.NumberFormat('de-DE', {
maximumFractionDigits: maxFractionDigits,
});
numberFormats.set(maxFractionDigits, format);
}
return format.format(value);
}
export { formatDayDe } from './texts/format-day';
export { formatNumberDe } from './texts/format-number';
@@ -140,4 +140,31 @@ describe('SuggestionViewsService', () => {
skip: 10,
});
});
it('counts only open suggestions the user may see in the summary', async () => {
const { service, prisma } = build([]);
prisma.suggestion.groupBy.mockResolvedValue([
{ priority: 'NORMAL', _count: { _all: 2 } },
{ priority: 'URGENT', _count: { _all: 1 } },
]);
const summary = await service.summary('user_1');
const [args] = prisma.suggestion.groupBy.mock.calls[0] as [
{ where: { AND: unknown[] } },
];
expect(args.where.AND).toEqual([
{
OR: [
{ requiredPermission: null },
{ requiredPermission: { in: ['inspections:read'] } },
],
},
{ status: 'OPEN' },
]);
expect(summary).toEqual({
open: 3,
byPriority: { LOW: 0, NORMAL: 2, HIGH: 0, URGENT: 1 },
});
});
});
@@ -36,7 +36,8 @@ export interface ProposeInput {
sourceEventId?: string | null;
/**
* Default false. Rule and chain suggestions pass `config.notify`, the
* autopilot never; the notification itself is wired by Task 7.
* autopilot never; a new row then notifies the holders of `tasks:read` and
* of the required permission.
*/
notify?: boolean;
}
@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TasksModule } from '../tasks/tasks.module';
import { ChainEventHandlers } from './chains/chains.handlers';
import { SuggestionViewsService } from './suggestion-views.service';
import { SuggestionsController } from './suggestions.controller';
import { SuggestionsService } from './suggestions.service';
@@ -10,9 +12,9 @@ import { SuggestionsService } from './suggestions.service';
* the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs).
*/
@Module({
imports: [TasksModule, AuditModule],
imports: [TasksModule, AuditModule, NotificationsModule],
controllers: [SuggestionsController],
providers: [SuggestionsService, SuggestionViewsService],
providers: [SuggestionsService, SuggestionViewsService, ChainEventHandlers],
exports: [SuggestionsService, SuggestionViewsService],
})
export class SuggestionsModule {}
@@ -7,6 +7,7 @@ import {
lockRowsForShare,
lockRowsForUpdate,
} from '../prisma/row-lock';
import type { NotificationsService } from '../notifications/notifications.service';
import type { TasksService } from '../tasks/tasks.service';
import { SuggestionsService } from './suggestions.service';
import type { ProposeInput } from './suggestion.types';
@@ -91,8 +92,9 @@ describe('SuggestionsService', () => {
task: { findMany: jest.Mock };
inspection: { count: jest.Mock };
user: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock; findMany: jest.Mock };
};
let notifications: { recordForPermission: jest.Mock };
let prisma: { $connect: jest.Mock; $transaction: jest.Mock } & {
$executeRaw: jest.Mock;
};
@@ -119,8 +121,12 @@ describe('SuggestionsService', () => {
},
colony: {
findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }),
findMany: jest.fn().mockResolvedValue([{ id: 'col-1', code: 'LWG' }]),
},
};
notifications = {
recordForPermission: jest.fn().mockResolvedValue([]),
};
prisma = {
$connect: jest.fn(),
$executeRaw: jest.fn().mockResolvedValue(0),
@@ -160,6 +166,7 @@ describe('SuggestionsService', () => {
prisma as unknown as PrismaService,
audit as unknown as AuditLogService,
tasks as unknown as TasksService,
notifications as unknown as NotificationsService,
);
});
@@ -291,6 +298,122 @@ describe('SuggestionsService', () => {
).resolves.not.toBeNull();
});
describe('notify', () => {
const rulePlan: ProposeInput = {
...proposal,
ruleKey: 'QUEENLESS_NO_EGGS',
source: 'RULE',
mode: 'PLAN',
intent: 'QUEEN_CHECK',
priority: 'HIGH',
reasonCode: 'QUEENLESS_NO_EGGS',
evidence: {
colonyId: 'col-1',
inspectionId: 'insp-9',
inspectedOn: '2026-05-05',
},
requiredPermission: 'inspections:read',
anchor: 'insp-9',
};
beforeEach(() => {
tx.suggestion.createManyAndReturn.mockResolvedValue([
suggestionRow({
id: 'sug-new',
ruleKey: 'QUEENLESS_NO_EGGS',
source: 'RULE',
mode: 'PLAN',
intent: 'QUEEN_CHECK',
priority: 'HIGH',
reasonCode: 'QUEENLESS_NO_EGGS',
evidence: rulePlan.evidence,
plan: null,
}),
]);
});
it('with notify and a new row writes one SUGGESTION notification to tasks:read plus the required permission', async () => {
await service.proposeIn(tx as never, { ...rulePlan, notify: true });
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
expect(notifications.recordForPermission).toHaveBeenCalledWith(
['tasks:read', 'inspections:read'],
{
priority: 'WARNUNG',
category: 'SUGGESTION',
message: expect.stringMatching(/^LWG: .+/) as string,
link: '/colonies/col-1',
relatedType: 'Suggestion',
relatedId: 'sug-new',
dedupeKeyPrefix: 'suggestion:sug-new',
},
tx,
);
});
it('writes an URGENT suggestion as KRITISCH and links a colony-less one to /suggestions', async () => {
tx.suggestion.createManyAndReturn.mockResolvedValue([
suggestionRow({
id: 'sug-urgent',
priority: 'URGENT',
colonyId: null,
relatedType: 'GENERAL',
relatedId: 'BEESWAX',
reasonCode: 'NO_SUCH_CODE',
evidence: {},
plan: null,
}),
]);
await service.proposeIn(tx as never, {
...rulePlan,
colonyId: null,
relatedType: 'GENERAL',
relatedId: 'BEESWAX',
notify: true,
});
const [, content] = notifications.recordForPermission.mock.calls[0] as [
unknown,
{ priority: string; link: string },
];
expect(content).toMatchObject({
priority: 'KRITISCH',
link: '/suggestions',
});
});
it('notify false or absent writes none', async () => {
await service.proposeIn(tx as never, { ...rulePlan, notify: false });
await service.proposeIn(tx as never, rulePlan);
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('a skipped row (existing dedupe key or open intent) writes none', async () => {
tx.suggestion.createManyAndReturn.mockResolvedValue([]);
await service.proposeIn(tx as never, { ...rulePlan, notify: true });
tx.suggestion.findMany.mockResolvedValue([{ intent: 'QUEEN_CHECK' }]);
await service.proposeIn(tx as never, { ...rulePlan, notify: true });
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('addresses only tasks:read when the suggestion needs no permission', async () => {
await service.proposeIn(tx as never, {
...rulePlan,
requiredPermission: null,
notify: true,
});
expect(notifications.recordForPermission).toHaveBeenCalledWith(
['tasks:read'],
expect.anything(),
tx,
);
});
});
it('rejects the root client', async () => {
await expect(
service.proposeIn(prisma as never, proposal),
@@ -338,9 +461,9 @@ describe('SuggestionsService', () => {
expect(calls[1][1].description).toContain('bis 25.05.');
expect(calls[0][1].description).toBeNull();
expect(calls.map((c) => c[1].title)).toEqual([
'Weiselzellen kontrollieren',
'Begattung kontrollieren',
'Eier und Stifte kontrollieren',
'Nachschaffungszellen prüfen',
'Begattung prüfen',
'Stifte prüfen',
]);
for (const [client, input, actingUserId] of calls) {
expect(client).toBe(tx);
@@ -1000,6 +1123,25 @@ describe('SuggestionsService', () => {
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('refreshIn also updates the reason code when given', async () => {
await service.refreshIn(tx as never, 'sug-1', {
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
evidence: { colonyId: 'col-1' },
dueDate: null,
priority: 'NORMAL',
expiresAt: null,
});
const args = argOf<{ data: Record<string, unknown> }>(
tx.suggestion.updateMany,
0,
0,
);
expect(args.data).toMatchObject({
reasonCode: 'INSPECTION_FIRST_OF_YEAR',
});
});
it('refreshIn updates an open suggestion only', async () => {
await service.refreshIn(tx as never, 'sug-1', {
evidence: { colonyId: 'col-1' },
@@ -17,6 +17,7 @@ import {
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRuleIn, readRule } from '../automation/rule-config';
import { NotificationsService } from '../notifications/notifications.service';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { berlinToday, dayToDate } from '../common/dates';
@@ -122,6 +123,7 @@ export class SuggestionsService {
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly tasks: TasksService,
private readonly notifications: NotificationsService,
) {}
/**
@@ -143,7 +145,7 @@ export class SuggestionsService {
// The colony is gone: nothing to propose (and no FK error).
return null;
}
await lockRuleIn(tx, input.ruleKey);
const rule = await lockRuleIn(tx, input.ruleKey);
const open = await this.openIntentsIn(tx, input);
if (open.has(input.intent)) {
return null;
@@ -165,9 +167,57 @@ export class SuggestionsService {
},
tx,
);
if (input.notify) {
await this.notifyIn(tx, suggestion, input, rule.actionTemplate);
}
return suggestion;
}
/**
* One SUGGESTION notification per user who may read tasks and the
* suggestion's area (its title can name data of that area). Written in the
* proposing transaction, so a failing write rolls the suggestion back and
* the delivery is retried.
*/
private async notifyIn(
tx: Prisma.TransactionClient,
suggestion: Suggestion,
input: ProposeInput,
actionTemplate: string | null,
): Promise<void> {
const lookup = await buildEvidenceLookup(tx, [
suggestion.evidence as SuggestionEvidence,
]);
const { title } = renderSuggestion(
suggestion,
lookup,
berlinToday(),
actionTemplate,
);
const colonyCode =
suggestion.colonyId === null
? null
: lookup.colonyCode(suggestion.colonyId);
await this.notifications.recordForPermission(
input.requiredPermission === null
? ['tasks:read']
: ['tasks:read', input.requiredPermission],
{
priority: suggestion.priority === 'URGENT' ? 'KRITISCH' : 'WARNUNG',
category: 'SUGGESTION',
message: colonyCode === null ? title : `${colonyCode}: ${title}`,
link:
suggestion.colonyId === null
? '/suggestions'
: `/colonies/${suggestion.colonyId}`,
relatedType: 'Suggestion',
relatedId: suggestion.id,
dedupeKeyPrefix: `suggestion:${suggestion.id}`,
},
tx,
);
}
/** The intents with an open suggestion or an open task for the object. */
async openIntentsIn(
db: DbClient,
@@ -210,6 +260,8 @@ export class SuggestionsService {
tx: Prisma.TransactionClient,
id: string,
patch: {
/** Given when the same anchor can carry another text (e.g. first of the year). */
reasonCode?: string;
evidence: SuggestionEvidence;
dueDate: string | null;
priority: TaskPriority;
@@ -220,6 +272,9 @@ export class SuggestionsService {
await tx.suggestion.updateMany({
where: { id, status: 'OPEN' },
data: {
...(patch.reasonCode === undefined
? {}
: { reasonCode: patch.reasonCode }),
evidence: patch.evidence,
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
priority: patch.priority,
@@ -0,0 +1,98 @@
import type { EvidenceLookup } from '../evidence-lookup';
import { renderSuggestion } from '../suggestion-texts';
const lookup: EvidenceLookup = {
colonyCode: () => 'B3',
queenCode: () => null,
treatmentProduct: (id) => (id === 't1' ? 'Oxuvar' : null),
apiaryName: () => null,
stockItemName: () => null,
};
function render(
reasonCode: string,
evidence: Record<string, string | number | null>,
today = '2026-05-12',
) {
return renderSuggestion(
{ reasonCode, evidence, intent: 'INSPECT' },
lookup,
today,
);
}
describe('autopilot texts', () => {
it('INSPECTION_DUE names the days since the last inspection and the interval of the segment', () => {
expect(
render('INSPECTION_DUE', {
lastInspectedOn: '2026-05-04',
intervalDays: 7,
segment: 'SCHWARMZEIT',
}),
).toEqual({
title: 'Durchsicht',
reasons: [
'Letzte Durchsicht vor 8 Tagen.',
'In der Schwarmzeit alle 7 Tage.',
],
});
});
it('INSPECTION_DUE copes with no inspection yet and singular days', () => {
expect(
render('INSPECTION_DUE', {
lastInspectedOn: null,
intervalDays: 14,
segment: 'SOMMER',
}).reasons,
).toEqual(['Noch keine Durchsicht erfasst.', 'Im Sommer alle 14 Tage.']);
expect(
render('INSPECTION_DUE', {
lastInspectedOn: '2026-05-11',
intervalDays: 7,
segment: 'SCHWARMZEIT',
}).reasons[0],
).toBe('Letzte Durchsicht vor 1 Tag.');
});
it('INSPECTION_FIRST_OF_YEAR says so', () => {
expect(
render('INSPECTION_FIRST_OF_YEAR', { lastInspectedOn: '2025-09-20' }),
).toEqual({
title: 'Erste Durchsicht im Jahr',
reasons: ['Die Saison hat begonnen.', 'Letzte Durchsicht am 20.09.2025.'],
});
});
it('VARROA_MEASURE_DUE and VARROA_WINTER_MEASURE', () => {
expect(
render(
'VARROA_MEASURE_DUE',
{ lastMeasuredOn: '2026-04-07', intervalDays: 28 },
'2026-05-12',
),
).toEqual({
title: 'Varroa messen',
reasons: ['Letzte Messung vor 35 Tagen.', 'Alle 28 Tage messen.'],
});
expect(render('VARROA_WINTER_MEASURE', {}).reasons).toEqual([
'Vor der Winterbehandlung den Milbenfall messen.',
]);
});
it('VARROA_RECHECK_DUE names the product from the lookup', () => {
expect(
render('VARROA_RECHECK_DUE', {
treatmentId: 't1',
endedOn: '2026-08-02',
recheckOn: '2026-08-16',
}),
).toEqual({
title: 'Behandlungserfolg messen',
reasons: [
'Behandlung mit Oxuvar endete am 02.08.',
'Nachkontrolle ab 16.08.',
],
});
});
});
@@ -1,4 +1,175 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
import { daysBetween } from '../../common/dates';
import type { SuggestionEvidence } from '../suggestion.types';
import {
formatDayDe,
type SuggestionTextRegister,
type SuggestionTextTemplate,
} from '../suggestion-texts';
const MAX_PRODUCT_LENGTH = 40;
const SEGMENT_PHRASES: Readonly<Record<string, string>> = {
FRUEHJAHR: 'Im Frühjahr',
SCHWARMZEIT: 'In der Schwarmzeit',
SOMMER: 'Im Sommer',
SPAETSOMMER: 'Im Spätsommer',
HERBST: 'Im Herbst',
};
function text(evidence: SuggestionEvidence, key: string): string | null {
const value = evidence[key];
return typeof value === 'string' ? value : null;
}
function count(evidence: SuggestionEvidence, key: string): number | null {
const value = evidence[key];
return typeof value === 'number' ? value : null;
}
/** 'vor 8 Tagen', 'vor 1 Tag', 'heute'. */
function agoDe(from: string, today: string): string {
const days = Math.max(0, daysBetween(from, today));
if (days === 0) {
return 'heute';
}
return `vor ${days} ${days === 1 ? 'Tag' : 'Tagen'}`;
}
function lastInspectionReason(
evidence: SuggestionEvidence,
today: string,
): string {
const last = text(evidence, 'lastInspectedOn');
return last === null
? 'Noch keine Durchsicht erfasst.'
: `Letzte Durchsicht ${agoDe(last, today)}.`;
}
function intervalReason(evidence: SuggestionEvidence): string[] {
const phrase = SEGMENT_PHRASES[text(evidence, 'segment') ?? ''];
const days = count(evidence, 'intervalDays');
return phrase !== undefined && days !== null
? [`${phrase} alle ${days} Tage.`]
: [];
}
const INSPECTION_DUE: SuggestionTextTemplate = {
ruleKey: 'INSPECTION_INTERVAL',
example: {
colonyId: 'c1',
inspectionId: 'i1',
lastInspectedOn: '2026-05-04',
dueOn: '2026-05-11',
intervalDays: 7,
segment: 'SCHWARMZEIT',
},
render: (evidence, _lookup, today) => ({
title: 'Durchsicht',
reasons: [
lastInspectionReason(evidence, today),
...intervalReason(evidence),
],
}),
};
const INSPECTION_FIRST_OF_YEAR: SuggestionTextTemplate = {
ruleKey: 'INSPECTION_INTERVAL',
example: {
colonyId: 'c1',
inspectionId: 'i1',
lastInspectedOn: '2025-09-20',
dueOn: '2026-03-20',
intervalDays: 14,
segment: 'FRUEHJAHR',
},
render: (evidence) => {
const last = text(evidence, 'lastInspectedOn');
return {
title: 'Erste Durchsicht im Jahr',
reasons: [
'Die Saison hat begonnen.',
last === null
? 'Noch keine Durchsicht erfasst.'
: `Letzte Durchsicht am ${formatDayDe(last, true)}.`,
],
};
},
};
const VARROA_MEASURE_DUE: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
measurementId: 'm1',
lastMeasuredOn: '2026-05-06',
dueOn: '2026-06-03',
intervalDays: 28,
},
render: (evidence, _lookup, today) => {
const last = text(evidence, 'lastMeasuredOn');
const days = count(evidence, 'intervalDays');
return {
title: 'Varroa messen',
reasons: [
last === null
? 'Noch keine Messung erfasst.'
: `Letzte Messung ${agoDe(last, today)}.`,
...(days === null ? [] : [`Alle ${days} Tage messen.`]),
],
};
},
};
const VARROA_WINTER_MEASURE: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
measurementId: 'm1',
lastMeasuredOn: '2026-09-01',
dueOn: '2026-11-15',
intervalDays: 28,
},
render: () => ({
title: 'Varroa messen',
reasons: ['Vor der Winterbehandlung den Milbenfall messen.'],
}),
};
const VARROA_RECHECK_DUE: SuggestionTextTemplate = {
ruleKey: 'SEASON_AUTOPILOT',
example: {
colonyId: 'c1',
treatmentId: 't1',
endedOn: '2026-08-02',
recheckOn: '2026-08-16',
},
render: (evidence, lookup) => {
const treatmentId = text(evidence, 'treatmentId');
const product =
(treatmentId === null ? null : lookup.treatmentProduct(treatmentId)) ??
'Behandlung';
const name = product.slice(0, MAX_PRODUCT_LENGTH);
const ended = text(evidence, 'endedOn');
const recheck = text(evidence, 'recheckOn');
return {
title: 'Behandlungserfolg messen',
reasons: [
ended === null
? `Behandlung mit ${name} beendet.`
: `Behandlung mit ${name} endete am ${formatDayDe(ended)}`,
...(recheck === null
? []
: [`Nachkontrolle ab ${formatDayDe(recheck)}`]),
],
};
},
};
/** Texts of the season autopilot suggestions, by reason code. */
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {};
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {
INSPECTION_DUE,
INSPECTION_FIRST_OF_YEAR,
VARROA_MEASURE_DUE,
VARROA_WINTER_MEASURE,
VARROA_RECHECK_DUE,
};
@@ -0,0 +1,30 @@
// Imported before anything else: the texts must not depend on suggestion-texts
// having been loaded first.
import { CHAIN_TEXTS } from './chain-texts';
import { SUGGESTION_TEXTS } from '../suggestion-texts';
describe('chain texts', () => {
it('load on their own and are part of the merged register', () => {
expect(Object.keys(CHAIN_TEXTS).length).toBeGreaterThan(0);
for (const code of Object.keys(CHAIN_TEXTS)) {
expect(SUGGESTION_TEXTS[code]).toBe(CHAIN_TEXTS[code]);
}
});
it('names the suggestions as the spec does', () => {
const lookup = {
colonyCode: () => 'V-1',
queenCode: () => 'Q-1',
treatmentProduct: () => null,
apiaryName: () => null,
stockItemName: () => null,
};
const title = (code: string) =>
CHAIN_TEXTS[code].render(CHAIN_TEXTS[code].example, lookup, '2026-05-05')
.title;
expect(title('SPLIT_SCHEDULE_RAISE_OWN')).toBe('Ableger-Plan übernehmen');
expect(title('SPLIT_SCHEDULE_QUEEN_CELL')).toBe('Stifte prüfen');
expect(title('SPLIT_SCHEDULE_QUEEN')).toBe('Annahme der Königin prüfen');
expect(title('QUEEN_CELLS_SWARM_CHECK')).toBe('Schwarmkontrolle');
});
});
@@ -1,12 +1,111 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
import type {
SuggestionTextRegister,
SuggestionTextTemplate,
} from '../suggestion-texts';
import { formatDayDe } from './format-day';
import type { EvidenceLookup } from '../evidence-lookup';
import type { SuggestionEvidence } from '../suggestion.types';
function dayOf(evidence: SuggestionEvidence, key: string): string {
const value = evidence[key];
return typeof value === 'string' ? formatDayDe(value) : '';
}
function colonyOf(
evidence: SuggestionEvidence,
key: string,
lookup: EvidenceLookup,
): string {
const id = evidence[key];
return (typeof id === 'string' ? lookup.colonyCode(id) : null) ?? 'Volk';
}
/** Split suggestions differ only in the second reason. */
function splitTemplate(
title: string,
queenSource: string,
reason: string,
): SuggestionTextTemplate {
return {
ruleKey: 'SPLIT_SCHEDULE',
example: {
colonyId: 'col-1',
parentColonyId: 'col-0',
lineageId: 'lin-1',
queenSource,
splitOn: '2026-05-01',
baseDate: '2026-05-01',
},
render: (evidence, lookup) => ({
title,
reasons: [
`Ableger von ${colonyOf(evidence, 'parentColonyId', lookup)} am ${dayOf(evidence, 'splitOn')}.`,
reason,
],
}),
};
}
/** Texts of the chain suggestions, by reason code. */
export const CHAIN_TEXTS: SuggestionTextRegister = {};
export const CHAIN_TEXTS: SuggestionTextRegister = {
QUEEN_CELLS_SWARM_CHECK: {
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
example: {
colonyId: 'col-1',
inspectionId: 'insp-1',
inspectedOn: '2026-05-05',
baseDate: '2026-05-05',
},
render: (evidence, lookup) => ({
title: 'Schwarmkontrolle',
reasons: [
`Weiselzellen bei ${colonyOf(evidence, 'colonyId', lookup)} am ${dayOf(evidence, 'inspectedOn')} gefunden.`,
],
}),
},
QUEEN_ASSIGNED_ACCEPTANCE: {
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
example: {
colonyId: 'col-1',
queenId: 'queen-1',
historyId: 'hist-1',
assignedOn: '2026-05-05',
baseDate: '2026-05-05',
},
render: (evidence, lookup) => {
const queenId = evidence.queenId;
const queen =
(typeof queenId === 'string' ? lookup.queenCode(queenId) : null) ??
'Königin';
return {
title: 'Annahme der Königin prüfen',
reasons: [
`${queen} wurde am ${dayOf(evidence, 'assignedOn')} in ${colonyOf(evidence, 'colonyId', lookup)} gesetzt.`,
],
};
},
},
SPLIT_SCHEDULE_RAISE_OWN: splitTemplate(
'Ableger-Plan übernehmen',
'RAISE_OWN',
'Die Königin wird selbst nachgezogen.',
),
SPLIT_SCHEDULE_QUEEN_CELL: splitTemplate(
'Stifte prüfen',
'QUEEN_CELL',
'Mit Weiselzelle gebildet.',
),
SPLIT_SCHEDULE_QUEEN: splitTemplate(
'Annahme der Königin prüfen',
'QUEEN',
'Die Königin wurde zugesetzt.',
),
};
/** Task title of each step of a plan suggestion (SPLIT_SCHEDULE). */
export const CHAIN_PLAN_STEP_TITLES: Readonly<Record<string, string>> = {
RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren',
MATING_CHECK: 'Begattung kontrollieren',
EGGS_CHECK: 'Eier und Stifte kontrollieren',
ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren',
RAISE_CELLS_CHECK: 'Nachschaffungszellen prüfen',
MATING_CHECK: 'Begattung prüfen',
EGGS_CHECK: 'Stifte prüfen',
ACCEPTANCE_CHECK: 'Annahme der Königin prüfen',
};
@@ -0,0 +1,5 @@
/** '2026-05-05' as '05.05.', with the year as '05.05.2026'. */
export function formatDayDe(day: string, withYear = false): string {
const [year, month, date] = day.split('-');
return withYear ? `${date}.${month}.${year}` : `${date}.${month}.`;
}
@@ -0,0 +1,13 @@
const numberFormats = new Map<number, Intl.NumberFormat>();
/** A number with a decimal comma: 12.4 as '12,4'. */
export function formatNumberDe(value: number, maxFractionDigits = 1): string {
let format = numberFormats.get(maxFractionDigits);
if (!format) {
format = new Intl.NumberFormat('de-DE', {
maximumFractionDigits: maxFractionDigits,
});
numberFormats.set(maxFractionDigits, format);
}
return format.format(value);
}
@@ -0,0 +1,108 @@
import type { EvidenceLookup } from '../evidence-lookup';
import { renderSuggestion } from '../suggestion-texts';
import type { SuggestionEvidence } from '../suggestion.types';
import { RULE_TEXTS } from './rule-texts';
const lookup: EvidenceLookup = {
colonyCode: (id) => (id === 'gone' ? null : 'LWG'),
queenCode: () => null,
treatmentProduct: (id) => (id === 'gone' ? null : 'Ameisensäure'),
apiaryName: () => null,
stockItemName: () => 'Gläser 500 g',
};
const TODAY = '2026-10-14';
function render(
reasonCode: string,
evidence: SuggestionEvidence,
intent: 'VARROA_TREAT' | 'FOLLOW_UP' | 'STOCK_REORDER' = 'FOLLOW_UP',
) {
return renderSuggestion(
{ reasonCode, evidence: evidence as never, intent },
lookup,
TODAY,
);
}
describe('rule texts', () => {
it('has a template for every reason code of the rule suggestions', () => {
expect(Object.keys(RULE_TEXTS).sort()).toEqual(
[
'COLONY_HEALTH_CRITICAL',
'FEEDING_FOLLOW_UP',
'FEED_STATUS_CRITICAL',
'QUEENLESS_NO_EGGS',
'RAW_MATERIAL_LOW',
'STOCK_ITEM_LOW',
'SWARM_MOOD_HIGH',
'TREATMENT_FOLLOW_UP',
'VARROA_RED',
].sort(),
);
});
it('VARROA_RED names rate, day and limit with the colony code', () => {
expect(render('VARROA_RED', RULE_TEXTS.VARROA_RED.example)).toEqual({
title: 'Behandlung prüfen',
reasons: ['12,4 Milben pro Tag am 10.05.', 'Grenze: 10 (LWG).'],
});
});
it('VARROA_RED words a percentage method in percent', () => {
const { reasons } = render('VARROA_RED', {
colonyId: 'c',
measuredOn: '2026-07-10',
value: 6,
redAbove: 5,
unit: 'PERCENT',
});
expect(reasons).toEqual(['6 % Befall am 10.07.', 'Grenze: 5 % (LWG).']);
});
it('QUEENLESS_NO_EGGS and the inspection rules name the day and the colony', () => {
expect(
render('QUEENLESS_NO_EGGS', RULE_TEXTS.QUEENLESS_NO_EGGS.example).reasons,
).toEqual(['Königin nicht gesehen, keine Eier am 05.05.']);
expect(
render('FEED_STATUS_CRITICAL', RULE_TEXTS.FEED_STATUS_CRITICAL.example)
.reasons,
).toEqual(['Futterstatus kritisch am 05.05.']);
});
it('STOCK_ITEM_LOW names the item and the minimum', () => {
expect(
render('STOCK_ITEM_LOW', RULE_TEXTS.STOCK_ITEM_LOW.example).reasons,
).toEqual(['Gläser 500 g: noch 2 auf Lager, Mindestbestand 5.']);
});
it('RAW_MATERIAL_LOW names the kind in German', () => {
expect(
render('RAW_MATERIAL_LOW', RULE_TEXTS.RAW_MATERIAL_LOW.example).reasons,
).toEqual(['Bienenwachs: noch 400 g auf Lager, Mindestmenge 1.000 g.']);
});
it('TREATMENT_FOLLOW_UP names the product, or none when the treatment is gone', () => {
expect(
render('TREATMENT_FOLLOW_UP', RULE_TEXTS.TREATMENT_FOLLOW_UP.example)
.reasons,
).toEqual(['Nach Behandlung mit Ameisensäure am 10.08.']);
expect(
render('TREATMENT_FOLLOW_UP', {
colonyId: 'gone',
treatmentId: 'gone',
endedOn: '2026-08-10',
}).reasons,
).toEqual(['Nach der Behandlung am 10.08.']);
});
it('survives evidence without its optional parts', () => {
for (const [reasonCode, template] of Object.entries(RULE_TEXTS)) {
const { title, reasons } = render(reasonCode, {});
expect(title).not.toBe('');
expect(reasons.length).toBeLessThanOrEqual(2);
expect(template.ruleKey).toBeTruthy();
}
});
});
@@ -1,4 +1,214 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
import { formatDayDe } from './format-day';
import { formatNumberDe } from './format-number';
import type {
RenderedSuggestionText,
SuggestionTextRegister,
SuggestionTextTemplate,
} from '../suggestion-texts';
import type { EvidenceLookup } from '../evidence-lookup';
import type { SuggestionEvidence } from '../suggestion.types';
/** Texts of the rule suggestions, by reason code (filled by the rule tasks). */
export const RULE_TEXTS: SuggestionTextRegister = {};
const RAW_MATERIAL_LABELS: Readonly<Record<string, string>> = {
BEESWAX: 'Bienenwachs',
PROPOLIS: 'Propolis',
};
function text(evidence: SuggestionEvidence, key: string): string | null {
const value = evidence[key];
return typeof value === 'string' ? value : null;
}
function num(evidence: SuggestionEvidence, key: string): number | null {
const value = evidence[key];
return typeof value === 'number' ? value : null;
}
/** '05.05.' for an evidence day; empty when the evidence has none. */
function dayOf(evidence: SuggestionEvidence, key: string): string {
const day = text(evidence, key);
return day === null ? '' : formatDayDe(day);
}
/** 'Text am 05.05.' (the day's dot ends the sentence), or 'Text.' */
function dated(
base: string,
evidence: SuggestionEvidence,
key: string,
): string {
const day = dayOf(evidence, key);
return day === '' ? `${base}.` : `${base} am ${day}`;
}
function colonyCodeOf(
evidence: SuggestionEvidence,
lookup: EvidenceLookup,
): string | null {
const id = text(evidence, 'colonyId');
return id === null ? null : lookup.colonyCode(id);
}
function template(
ruleKey: SuggestionTextTemplate['ruleKey'],
example: SuggestionEvidence,
render: (
evidence: SuggestionEvidence,
lookup: EvidenceLookup,
) => RenderedSuggestionText,
): SuggestionTextTemplate {
return { ruleKey, example, render };
}
function varroaReasons(
evidence: SuggestionEvidence,
lookup: EvidenceLookup,
): string[] {
const value = num(evidence, 'value');
const limit = num(evidence, 'redAbove');
const perDay = evidence.unit === 'MITES_PER_DAY';
const shown = value === null ? '?' : formatNumberDe(value);
const measured = dated(
perDay ? `${shown} Milben pro Tag` : `${shown} % Befall`,
evidence,
'measuredOn',
);
if (limit === null) {
return [measured];
}
const code = colonyCodeOf(evidence, lookup);
const unit = perDay ? '' : ' %';
return [
measured,
`Grenze: ${formatNumberDe(limit)}${unit}${code === null ? '' : ` (${code})`}.`,
];
}
/** Texts of the rule suggestions, by reason code. */
export const RULE_TEXTS: SuggestionTextRegister = {
QUEENLESS_NO_EGGS: template(
'QUEENLESS_NO_EGGS',
{
colonyId: 'colony-1',
inspectionId: 'insp-1',
inspectedOn: '2026-05-05',
},
(evidence) => ({
title: 'Königin prüfen',
reasons: [
dated('Königin nicht gesehen, keine Eier', evidence, 'inspectedOn'),
],
}),
),
FEED_STATUS_CRITICAL: template(
'FEED_STATUS_CRITICAL',
{
colonyId: 'colony-1',
inspectionId: 'insp-1',
inspectedOn: '2026-05-05',
},
(evidence) => ({
title: 'Füttern',
reasons: [dated('Futterstatus kritisch', evidence, 'inspectedOn')],
}),
),
VARROA_RED: template(
'VARROA_THRESHOLD',
{
colonyId: 'colony-1',
measurementId: 'm-1',
measuredOn: '2026-05-10',
value: 12.4,
redAbove: 10,
unit: 'MITES_PER_DAY',
},
(evidence, lookup) => ({
title: 'Behandlung prüfen',
reasons: varroaReasons(evidence, lookup),
}),
),
SWARM_MOOD_HIGH: template(
'SWARM_MOOD_HIGH',
{ colonyId: 'colony-1', checkId: 'check-1', checkedOn: '2026-05-05' },
(evidence) => ({
title: 'Schwarmkontrolle',
reasons: [dated('Hohe Schwarmstimmung', evidence, 'checkedOn')],
}),
),
COLONY_HEALTH_CRITICAL: template(
'COLONY_HEALTH_CRITICAL',
{ colonyId: 'colony-1', changedOn: '2026-05-05' },
(evidence) => ({
title: 'Gesundheit prüfen',
reasons: [
dated('Gesundheitsstatus auf kritisch gesetzt', evidence, 'changedOn'),
],
}),
),
STOCK_ITEM_LOW: template(
'STOCK_ITEM_LOW',
{ stockItemId: 'item-1', quantity: 2, minThreshold: 5 },
(evidence, lookup) => {
const itemId = text(evidence, 'stockItemId');
const name = itemId === null ? null : lookup.stockItemName(itemId);
const quantity = num(evidence, 'quantity');
const minimum = num(evidence, 'minThreshold');
return {
title: 'Nachbestellen',
reasons: [
`${name ?? 'Lagerartikel'}: noch ${quantity === null ? '?' : formatNumberDe(quantity)} auf Lager, Mindestbestand ${minimum === null ? '?' : formatNumberDe(minimum)}.`,
],
};
},
),
RAW_MATERIAL_LOW: template(
'RAW_MATERIAL_LOW',
{ kind: 'BEESWAX', remainingGrams: 400, minGrams: 1000 },
(evidence) => {
const kind = text(evidence, 'kind');
const label =
kind !== null && Object.hasOwn(RAW_MATERIAL_LABELS, kind)
? RAW_MATERIAL_LABELS[kind]
: 'Rohstoff';
const remaining = num(evidence, 'remainingGrams');
const minimum = num(evidence, 'minGrams');
return {
title: 'Rohstoff nachbestellen',
reasons: [
`${label}: noch ${remaining === null ? '?' : formatNumberDe(remaining)} g auf Lager, Mindestmenge ${minimum === null ? '?' : formatNumberDe(minimum)} g.`,
],
};
},
),
TREATMENT_FOLLOW_UP: template(
'TREATMENT_FOLLOW_UP',
{
colonyId: 'colony-1',
treatmentId: 'treatment-1',
endedOn: '2026-08-10',
},
(evidence, lookup) => {
const treatmentId = text(evidence, 'treatmentId');
const product =
treatmentId === null ? null : lookup.treatmentProduct(treatmentId);
return {
title: 'Behandlungserfolg kontrollieren',
reasons: [
dated(
product === null
? 'Nach der Behandlung'
: `Nach Behandlung mit ${product}`,
evidence,
'endedOn',
),
],
};
},
),
FEEDING_FOLLOW_UP: template(
'FEEDING_FOLLOW_UP',
{ colonyId: 'colony-1', feedingId: 'feeding-1', fedOn: '2026-09-01' },
(evidence) => ({
title: 'Futterabnahme kontrollieren',
reasons: [dated('Nach der Fütterung', evidence, 'fedOn')],
}),
),
};
@@ -26,4 +26,23 @@ export class VarroaThresholdsQuery {
@IsString()
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
date?: string;
@ApiPropertyOptional({
description:
'Range form: first day (JJJJ-MM-TT); needs `to`, excludes `date`',
example: '2025-01-01',
})
@IsOptional()
@IsString()
@Matches(DAY_PATTERN, { message: 'from muss ein Tag (JJJJ-MM-TT) sein' })
from?: string;
@ApiPropertyOptional({
description: 'Range form: last day (JJJJ-MM-TT), at most 731 days',
example: '2026-12-31',
})
@IsOptional()
@IsString()
@Matches(DAY_PATTERN, { message: 'to muss ein Tag (JJJJ-MM-TT) sein' })
to?: string;
}
@@ -100,6 +100,108 @@ describe('VarroaBandsService', () => {
);
});
describe('thresholdSegments', () => {
const range = (from: string, to: string, colonyId?: string) =>
service.thresholdSegments({ colonyId, from, to });
it('merges days with identical bands into one segment', async () => {
const { segments } = await range('2026-06-05', '2026-06-20');
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
from: '2026-06-05',
to: '2026-06-20',
active: true,
postTreatmentUntil: null,
});
expect(segments[0].bands.NATURAL_MITE_FALL).toBeDefined();
});
it.each([
['2026-02-27', '2026-03-02', '2026-02-28', '2026-03-01'],
['2026-04-29', '2026-05-02', '2026-04-30', '2026-05-01'],
['2026-07-30', '2026-08-02', '2026-07-31', '2026-08-01'],
['2026-10-30', '2026-11-02', '2026-10-31', '2026-11-01'],
])(
'splits %s..%s between %s and %s',
async (from, to, lastOfFirst, firstOfSecond) => {
const { segments } = await range(from, to);
expect(segments.map((segment) => [segment.from, segment.to])).toEqual([
[from, lastOfFirst],
[firstOfSecond, to],
]);
},
);
it('splits at treatment windows and names the recheck day', async () => {
prisma.colony.findUnique.mockResolvedValue({
colonyType: 'PRODUCTION_COLONY',
});
prisma.treatment.findMany.mockResolvedValue([
{ date: d('2026-07-12'), startDate: d('2026-07-10'), endDate: null },
]);
const { segments } = await range('2026-07-08', '2026-07-30', 'c1');
expect(segments.map((s) => [s.from, s.to, s.postTreatmentUntil])).toEqual(
[
['2026-07-08', '2026-07-09', null],
['2026-07-10', '2026-07-25', '2026-07-26'],
['2026-07-26', '2026-07-30', null],
],
);
expect(prisma.treatment.findMany).toHaveBeenCalledTimes(1);
expect(prisma.colony.findUnique).toHaveBeenCalledTimes(1);
});
it('returns one inactive segment without bands while the rule is off', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'r',
active: false,
config: {},
actionTemplate: null,
});
await expect(range('2026-03-01', '2026-09-30')).resolves.toEqual({
segments: [
{
from: '2026-03-01',
to: '2026-09-30',
bands: {},
postTreatmentUntil: null,
active: false,
},
],
});
});
it('accepts exactly 731 days and rejects 732', async () => {
await expect(range('2025-01-01', '2027-01-01')).resolves.toBeDefined();
await expect(range('2025-01-01', '2027-01-02')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects from after to, invalid days and an unknown colony', async () => {
await expect(range('2026-06-02', '2026-06-01')).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(range('2026-02-30', '2026-03-05')).rejects.toBeInstanceOf(
BadRequestException,
);
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
range('2026-06-01', '2026-06-02', 'missing'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('accepts a single day', async () => {
const { segments } = await range('2026-06-01', '2026-06-01');
expect(segments).toHaveLength(1);
});
});
describe('rate', () => {
const measurement = {
id: 'm1',
@@ -11,7 +11,7 @@ import {
type VarroaBands,
type VarroaRating,
} from '../automation/varroa-bands';
import { berlinToday, dayToDate } from '../common/dates';
import { addDays, berlinToday, dayToDate, daysBetween } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { treatmentWindowsIn } from '../treatments/treatment-windows';
@@ -20,6 +20,22 @@ export interface VarroaThresholds extends VarroaBands {
active: boolean;
}
/** A run of consecutive days on which the bands are identical. */
export interface VarroaThresholdSegment {
from: string;
to: string;
bands: VarroaBands['bands'];
postTreatmentUntil: string | null;
active: boolean;
}
export interface VarroaThresholdSegments {
segments: VarroaThresholdSegment[];
}
/** Longest range of the range query, in days (two years plus a leap day). */
export const MAX_THRESHOLD_RANGE_DAYS = 731;
type RatableMeasurement = Pick<
VarroaMeasurement,
'date' | 'method' | 'calculatedValue'
@@ -72,6 +88,68 @@ export class VarroaBandsService {
};
}
/**
* The bands over a day range as segments of identical bands (they change by
* month and with treatment windows). Rule, colony type and treatments are
* read once.
*/
async thresholdSegments(query: {
colonyId?: string;
from: string;
to: string;
}): Promise<VarroaThresholdSegments> {
const from = validDay(query.from);
const to = validDay(query.to);
const days = daysBetween(from, to) + 1;
if (days < 1 || days > MAX_THRESHOLD_RANGE_DAYS) {
throw new BadRequestException(
`Der Zeitraum muss zwischen 1 und ${MAX_THRESHOLD_RANGE_DAYS} Tage lang sein.`,
);
}
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
const colonyType =
query.colonyId === undefined
? null
: await this.colonyTypeOf(query.colonyId);
if (!rule.active) {
return {
segments: [
{ from, to, bands: {}, postTreatmentUntil: null, active: false },
],
};
}
const treatments =
query.colonyId === undefined
? []
: await treatmentWindowsIn(this.prisma, query.colonyId);
const segments: VarroaThresholdSegment[] = [];
let key = '';
for (let offset = 0; offset < days; offset++) {
const day = addDays(from, offset);
const { bands, postTreatmentUntil } = bandsFor(
day,
colonyType,
rule.config,
treatments,
);
const dayKey = JSON.stringify([bands, postTreatmentUntil]);
const last = segments[segments.length - 1];
if (last && dayKey === key) {
last.to = day;
} else {
segments.push({
from: day,
to: day,
bands,
postTreatmentUntil,
active: true,
});
key = dayKey;
}
}
return { segments };
}
/** Each measurement of one colony with its rating. */
async rate<M extends RatableMeasurement>(
colonyId: string,
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Get,
@@ -34,11 +35,20 @@ export class VarroaController {
return this.varroaService.findAll();
}
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
/** Bands in force on a day (`date`) or as segments over a range (`from`, `to`) for a colony; declared before :id so it is not shadowed. */
@RequirePermission('varroa:read', 'varroa:create')
@Get('thresholds')
getThresholds(@Query() query: VarroaThresholdsQuery) {
return this.varroaBandsService.thresholds(query);
const { colonyId, date, from, to } = query;
if (from === undefined && to === undefined) {
return this.varroaBandsService.thresholds({ colonyId, date });
}
if (from === undefined || to === undefined || date !== undefined) {
throw new BadRequestException(
'Bitte entweder date oder from und to angeben.',
);
}
return this.varroaBandsService.thresholdSegments({ colonyId, from, to });
}
@RequirePermission('varroa:read')
@@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { AutomationModule } from '../automation/automation.module';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -33,6 +34,7 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
TasksModule,
AutomationModule,
SuggestionsModule,
AutopilotModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],