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
+11805 -1209

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,
if (inspection.queenSeen === false && inspection.eggsPresent === false) {
await this.proposeOnInspectionIn(
tx,
'QUEENLESS_NO_EGGS',
'QUEEN_CHECK',
inspection,
);
}
if (inspection.feedStatus === 'CRITICAL') {
await this.proposeOnInspectionIn(
tx,
'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),
});
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(
tx,
queenlessRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus === 'CRITICAL') {
await this.triggerActionIn(
tx,
feedRule,
'COLONY',
inspection.colonyId,
inspection.id,
);
}
}
/**
* 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 },
});
if (alreadyOpen) {
return;
trigger: RuleSuggestionTrigger,
target: RuleSuggestionTarget,
): Promise<Suggestion | null> {
assertTransactionClient(tx, 'AutomationService.proposeForRuleIn');
if (
target.colonyId !== null &&
!(await this.isLivingColonyLockedIn(tx, target.colonyId))
) {
return null;
}
const title = rule.actionTemplate ?? 'Automatisierte Aufgabe';
const config = rule.config as { dueInDays?: unknown } | null;
const task = await this.tasks.createInTx(
tx,
{
title,
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,
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,
);
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 (suggestion === null) {
return null;
}
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,10 +105,24 @@ 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) => {
/**
* 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;
@@ -147,9 +161,7 @@ export class ColoniesService {
},
tx,
);
return updated;
});
return colony;
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],
+152 -92
View File
@@ -50,6 +50,24 @@ describe('Automation rules (e2e)', () => {
return id;
}
const suggestionsOf = (ruleKey?: AutomationTriggerType) =>
prisma.suggestion.findMany({
where: { colonyId, source: 'RULE', ...(ruleKey ? { ruleKey } : {}) },
orderBy: { createdAt: 'asc' },
});
// Other sources (the season autopilot) propose on the same events.
const ruleSuggestionCount = () =>
prisma.suggestion.count({ where: { colonyId, source: 'RULE' } });
const tasksOfColony = () =>
prisma.task.count({ where: { relatedId: colonyId } });
// Settles every open suggestion, so the one-open-thing guard cannot be
// what keeps a new one from appearing - only the missing evaluation may.
const dismissOpen = () =>
prisma.suggestion.updateMany({
where: { colonyId, status: 'OPEN' },
data: { status: 'DISMISSED' },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
@@ -91,15 +109,15 @@ describe('Automation rules (e2e)', () => {
});
afterAll(async () => {
// Rule tasks and the rules' own entries are written without a user -
// they are not covered by the userId cleanup below. Deliveries go with
// their event (ON DELETE CASCADE).
const ruleTaskIds = (
await prisma.task.findMany({
where: { relatedId: colonyId },
// Rule suggestions and the rules' own entries are written without a
// user - they are not covered by the userId cleanup below. Deliveries go
// with their event (ON DELETE CASCADE).
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId },
select: { id: true },
})
).map((task) => task.id);
).map((suggestion) => suggestion.id);
const byColony = { where: { colonyId }, select: { id: true } };
const recordIds = [
...(await prisma.inspection.findMany(byColony)),
@@ -108,7 +126,7 @@ describe('Automation rules (e2e)', () => {
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Task', entityId: { in: ruleTaskIds } },
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
{
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId },
@@ -117,13 +135,15 @@ describe('Automation rules (e2e)', () => {
},
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: [...ruleTaskIds, ...recordIds] } },
where: { aggregateId: { in: recordIds } },
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: [...ruleTaskIds, ...recordIds] } },
where: { sourceId: { in: recordIds } },
});
await prisma.task.deleteMany({ where: { relatedId: colonyId } });
await prisma.notification.deleteMany({ where: { relatedId: colonyId } });
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
await prisma.swarmControlCheck.deleteMany({ where: { colonyId } });
await prisma.inspection.deleteMany({ where: { colonyId } });
@@ -254,19 +274,32 @@ describe('Automation rules (e2e)', () => {
}
});
it('creates a task and notification when queen not seen and no eggs present', async () => {
await record('inspections', { queenSeen: false, eggsPresent: false });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
it('proposes a queen check and notifies when queen not seen and no eggs present', async () => {
const inspectionId = await record('inspections', {
queenSeen: false,
eggsPresent: false,
});
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toContain('Kontrolle');
const suggestions = await suggestionsOf();
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
ruleKey: 'QUEENLESS_NO_EGGS',
source: 'RULE',
mode: 'PLAN',
intent: 'QUEEN_CHECK',
status: 'OPEN',
priority: 'HIGH',
reasonCode: 'QUEENLESS_NO_EGGS',
requiredPermission: 'inspections:read',
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
});
// The rule proposes, it creates no task.
expect(await tasksOfColony()).toBe(0);
const notifications = await prisma.notification.findMany({
where: { relatedId: colonyId },
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
});
// One copy per user who may read tasks, never a broadcast.
// One copy per user who may read tasks and inspections, never a broadcast.
expect(notifications.length).toBeGreaterThan(0);
expect(notifications.every((n) => n.category === 'SUGGESTION')).toBe(true);
expect(new Set(notifications.map((n) => n.userId)).size).toBe(
@@ -277,31 +310,28 @@ describe('Automation rules (e2e)', () => {
it('does not trigger when the queen was seen', async () => {
await record('inspections', { queenSeen: true, eggsPresent: false });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
});
expect(tasks).toHaveLength(1);
expect(await suggestionsOf()).toHaveLength(1);
});
it('triggers a feeding task when feed status is kritisch', async () => {
it('proposes feeding when feed status is kritisch, without a notification', async () => {
await record('inspections', { feedStatus: 'CRITICAL' });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
});
expect(tasks).toHaveLength(2);
expect(tasks.some((task) => task.title.includes('Fütterung'))).toBe(true);
const suggestions = await suggestionsOf('FEED_STATUS_CRITICAL');
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({ intent: 'FEED', status: 'OPEN' });
// The rule is off for notifications by default (notify: false).
expect(
await prisma.notification.count({
where: { relatedType: 'Suggestion', relatedId: suggestions[0].id },
}),
).toBe(0);
expect(await tasksOfColony()).toBe(0);
});
it('evaluates no rule on a corrected inspection', async () => {
const before = await prisma.task.count({ where: { relatedId: colonyId } });
const id = await record('inspections', { queenSeen: true });
// Close the open tasks so the one-open-task guard cannot be what keeps
// a new task from appearing - only the missing evaluation may.
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
await dismissOpen();
const before = await ruleSuggestionCount();
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${id}`)
@@ -310,57 +340,74 @@ describe('Automation rules (e2e)', () => {
.expect(200);
await drainDomainEvents(app, [id]);
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
expect(await ruleSuggestionCount()).toBe(before);
});
it('triggers a treatment check when the varroa threshold is exceeded', async () => {
const before = await prisma.task.count({ where: { relatedId: colonyId } });
await record('varroa-measurements', {
it('proposes a treatment check when the mite fall is red', async () => {
const id = await record('varroa-measurements', {
method: 'NATURAL_MITE_FALL',
miteCount: 20,
// One day on the board: 20 mites per day, above the default 10.
date: '2026-08-10T00:00:00.000Z',
// One day on the board: 20 mites per day, above the limit of 10.
durationMinutes: 24 * 60,
});
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
const suggestions = await suggestionsOf('VARROA_THRESHOLD');
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
status: 'OPEN',
requiredPermission: 'varroa:read',
evidence: {
colonyId,
measurementId: id,
measuredOn: '2026-08-10',
value: 20,
redAbove: 10,
unit: 'MITES_PER_DAY',
},
});
expect(tasks).toHaveLength(before + 1);
expect(tasks.some((task) => task.title.includes('Behandlung'))).toBe(true);
expect(await tasksOfColony()).toBe(0);
});
it('does not compare a sample-based count against the per-day threshold', async () => {
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
it('rates a sample-based count with the percentage band', async () => {
await dismissOpen();
const before = await ruleSuggestionCount();
// Summer band: red above 5 %. 5 of 500 bees is 1 %: yellow, no proposal.
await record('varroa-measurements', {
method: 'POWDERED_SUGAR',
miteCount: 5,
sampleBees: 500,
date: '2026-07-10T00:00:00.000Z',
});
const before = await prisma.task.count({ where: { relatedId: colonyId } });
expect(await ruleSuggestionCount()).toBe(before);
// 50 of 500 bees is 10 %: red.
await record('varroa-measurements', {
method: 'POWDERED_SUGAR',
miteCount: 50,
sampleBees: 500,
date: '2026-07-11T00:00:00.000Z',
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
const red = await prisma.suggestion.findMany({
where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' },
});
expect(red).toHaveLength(1);
expect(red[0].evidence).toMatchObject({ unit: 'PERCENT', value: 10 });
});
it('re-evaluates a corrected varroa measurement against the threshold', async () => {
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
const before = await prisma.task.count({ where: { relatedId: colonyId } });
// Five mites per day: below the threshold.
it('re-evaluates a corrected varroa measurement against the limit', async () => {
await dismissOpen();
const before = await ruleSuggestionCount();
// Five mites per day: yellow in August, no proposal.
const id = await record('varroa-measurements', {
method: 'NATURAL_MITE_FALL',
miteCount: 5,
date: '2026-08-20T00:00:00.000Z',
durationMinutes: 24 * 60,
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before,
);
expect(await ruleSuggestionCount()).toBe(before);
// The count was miscounted: 25 mites per day.
await request(app.getHttpServer())
@@ -370,16 +417,35 @@ describe('Automation rules (e2e)', () => {
.expect(200);
await drainDomainEvents(app, [id]);
const tasks = await prisma.task.findMany({
where: {
relatedId: colonyId,
sourceAutomationTrigger: 'VARROA_THRESHOLD',
},
const open = await prisma.suggestion.findMany({
where: { colonyId, status: 'OPEN', ruleKey: 'VARROA_THRESHOLD' },
});
expect(tasks.filter((task) => task.status === 'OPEN')).toHaveLength(1);
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
before + 1,
);
expect(open).toHaveLength(1);
expect(open[0].dedupeKey).toBe(`VARROA_THRESHOLD:${colonyId}:${id}`);
expect(await ruleSuggestionCount()).toBe(before + 1);
// Corrected again while still red: the open suggestion follows the value.
const correct = async (miteCount: number) => {
await request(app.getHttpServer())
.patch(`/api/v1/varroa-measurements/${id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ miteCount })
.expect(200);
await drainDomainEvents(app, [id]);
};
await correct(30);
const refreshed = await prisma.suggestion.findUniqueOrThrow({
where: { id: open[0].id },
});
expect(refreshed.status).toBe('OPEN');
expect(refreshed.evidence).toMatchObject({ value: 30 });
// Corrected below the limit: the condition is gone, the suggestion resolves.
await correct(1);
const resolved = await prisma.suggestion.findUniqueOrThrow({
where: { id: open[0].id },
});
expect(resolved).toMatchObject({ status: 'RESOLVED', dedupeKey: null });
});
it('lets an admin clear a rule config, which falls back to its defaults', async () => {
@@ -430,25 +496,19 @@ describe('Automation rules (e2e)', () => {
swarmMood: 'HIGH',
})
.expect(201);
const swarmTasks = () =>
prisma.task.count({
where: {
relatedId: colonyId,
sourceAutomationTrigger: 'SWARM_MOOD_HIGH',
},
const swarmSuggestions = () =>
prisma.suggestion.count({
where: { colonyId, ruleKey: 'SWARM_MOOD_HIGH' },
});
// While active, high swarm mood leaves a task - the rule really fires.
// (Swarm checks stay on the post-commit path: evaluated before the
// response, no event to drain.)
// While active, high swarm mood leaves a suggestion - the rule really
// fires. (Swarm checks stay on the post-commit path: evaluated before
// the response, no event to drain.)
await highSwarmMood();
expect(await swarmTasks()).toBe(1);
// Close it so the one-open-task guard cannot be what keeps a new task
expect(await swarmSuggestions()).toBe(1);
// Settle it so the one-open-thing guard cannot be what keeps a new one
// from appearing - only the deactivation may.
await prisma.task.updateMany({
where: { relatedId: colonyId },
data: { status: 'DONE' },
});
await dismissOpen();
try {
await request(app.getHttpServer())
@@ -459,7 +519,7 @@ describe('Automation rules (e2e)', () => {
await highSwarmMood();
expect(await swarmTasks()).toBe(1);
expect(await swarmSuggestions()).toBe(1);
} finally {
await prisma.automationRule.update({
where: { id: swarmRuleId },
+409
View File
@@ -0,0 +1,409 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { PrismaService } from '../src/prisma/prisma.service';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** Fixed day inside the swarm season by calendar; 2001 keeps it apart from other files. */
const TODAY = '2001-05-12';
/**
* The autopilot sync against a real PostgreSQL, on fixed days in 2001. The
* colonies are created 2001-04-20, so no other file's data is reached.
*/
describe('Autopilot (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let autopilot: AutopilotService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let userId: string;
const colonyIds: string[] = [];
const roleIds: string[] = [];
const userIds: string[] = [];
/** A living colony inspected 2001-05-03 (9 days before TODAY) and measured 2001-05-10. */
async function colonyWithLateInspection(name: string): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-${name}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-04-20'),
},
});
colonyIds.push(colony.id);
await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: d('2001-05-03') },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId,
date: d('2001-05-10'),
method: 'NATURAL_MITE_FALL',
},
});
return colony.id;
}
const sync = (colonyId: string, day = TODAY) =>
prisma.$transaction((tx) => autopilot.syncColonyIn(tx, colonyId, day));
const suggestionsOf = (colonyId: string, intent = 'INSPECT') =>
prisma.suggestion.findMany({
where: { colonyId, source: 'AUTOPILOT', intent: intent as 'INSPECT' },
orderBy: { createdAt: 'asc' },
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
autopilot = app.get(AutopilotService);
apiaryId = (
await prisma.apiary.create({ data: { name: `Autopilot e2e ${suffix}` } })
).id;
userId = (
await prisma.user.create({
data: {
email: `autopilot-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Autopilot e2e',
role: { connect: { key: 'STAFF' } },
},
})
).id;
});
afterAll(async () => {
const inspections = await prisma.inspection.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspections.map((i) => i.id) } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
it('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', async () => {
const colonyId = await colonyWithLateInspection('overdue');
const inspection = await prisma.inspection.findFirstOrThrow({
where: { colonyId },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 1, refreshed: 0, resolved: 0 });
const [suggestion] = await suggestionsOf(colonyId);
expect(suggestion).toMatchObject({
status: 'OPEN',
mode: 'DO',
priority: 'HIGH',
ruleKey: 'INSPECTION_INTERVAL',
reasonCode: 'INSPECTION_DUE',
requiredPermission: 'inspections:read',
relatedType: 'COLONY',
relatedId: colonyId,
dedupeKey: `INSPECTION_INTERVAL:${colonyId}:${inspection.id}`,
});
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-10');
expect(suggestion.expiresAt?.toISOString().slice(0, 10)).toBe('2001-06-11');
});
it('a second sync refreshes instead of creating another suggestion', async () => {
const colonyId = await colonyWithLateInspection('refresh');
await sync(colonyId);
const again = await sync(colonyId, '2001-05-13');
expect(again).toEqual({ opened: 0, refreshed: 1, resolved: 0 });
expect(await suggestionsOf(colonyId)).toHaveLength(1);
});
it('after a new inspection the INSPECT suggestion is RESOLVED', async () => {
const colonyId = await colonyWithLateInspection('resolved');
await sync(colonyId);
await prisma.inspection.create({
data: { colonyId, userId, date: d(TODAY) },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 1 });
const [suggestion] = await suggestionsOf(colonyId);
expect(suggestion).toMatchObject({
status: 'RESOLVED',
decisionNote: 'Bedingung entfällt',
dedupeKey: null,
});
});
it('two colony syncs in parallel leave one INSPECT suggestion', async () => {
const colonyId = await colonyWithLateInspection('parallel');
await Promise.all([sync(colonyId), sync(colonyId), sync(colonyId)]);
const open = (await suggestionsOf(colonyId)).filter(
(suggestion) => suggestion.status === 'OPEN',
);
expect(open).toHaveLength(1);
});
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
const colonyId = await colonyWithLateInspection('returns');
await sync(colonyId);
const fresh = await prisma.inspection.create({
data: { colonyId, userId, date: d(TODAY) },
});
await sync(colonyId);
await prisma.inspection.delete({ where: { id: fresh.id } });
const result = await sync(colonyId);
expect(result.opened).toBe(1);
expect(
(await suggestionsOf(colonyId)).map((suggestion) => suggestion.status),
).toEqual(['RESOLVED', 'OPEN']);
});
it('a dismissed suggestion keeps its anchor: the same condition is not proposed again', async () => {
const colonyId = await colonyWithLateInspection('dismissed');
await sync(colonyId);
await prisma.suggestion.updateMany({
where: { colonyId, intent: 'INSPECT' },
data: { status: 'DISMISSED' },
});
const result = await sync(colonyId);
expect(result).toEqual({ opened: 0, refreshed: 0, resolved: 0 });
expect(await suggestionsOf(colonyId)).toHaveLength(1);
});
it('an ended colony gets nothing', async () => {
const colonyId = await colonyWithLateInspection('ended');
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DECEASED' },
});
expect(await sync(colonyId)).toEqual({
opened: 0,
refreshed: 0,
resolved: 0,
});
expect(await suggestionsOf(colonyId)).toHaveLength(0);
});
it('proposes VARROA_MEASURE (A2) when the last measurement is older than 28 days', async () => {
const colonyId = await colonyWithLateInspection('varroa');
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-04-07') },
});
await sync(colonyId);
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
expect(measure).toMatchObject({
status: 'OPEN',
ruleKey: 'SEASON_AUTOPILOT',
reasonCode: 'VARROA_MEASURE_DUE',
requiredPermission: 'varroa:read',
});
expect(measure.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-05');
});
it('autopilot.colony-sync resolves a stale autopilot suggestion when an inspection event is delivered', async () => {
// Real today: a colony inspected and measured today has no candidate.
const colony = await prisma.colony.create({
data: {
code: `AP-E2E-${suffix}-event`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyIds.push(colony.id);
const inspection = await prisma.inspection.create({
data: { colonyId: colony.id, userId, date: new Date() },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId,
date: new Date(),
method: 'NATURAL_MITE_FALL',
},
});
const stale = await prisma.suggestion.create({
data: {
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
relatedId: colony.id,
colonyId: colony.id,
reasonCode: 'INSPECTION_DUE',
evidence: {},
dedupeKey: `INSPECTION_INTERVAL:${colony.id}:stale`,
},
});
await prisma.$transaction((tx) =>
app.get(DomainEventsService).record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId: colony.id },
}),
);
await drainDomainEvents(app, [inspection.id]);
const after = await prisma.suggestion.findUniqueOrThrow({
where: { id: stale.id },
});
expect(after.status).toBe('RESOLVED');
expect(after.decisionNote).toBe('Bedingung entfällt');
});
/** A colony with a treatment that ended 2001-04-28 and an older measurement. */
async function colonyAfterTreatment(
name: string,
measurements: string[],
): Promise<string> {
const colonyId = await colonyWithLateInspection(name);
await prisma.varroaMeasurement.deleteMany({ where: { colonyId } });
for (const date of measurements) {
await prisma.varroaMeasurement.create({
data: { colonyId, userId, date: d(date), method: 'NATURAL_MITE_FALL' },
});
}
await prisma.treatment.create({
data: {
colonyId,
userId,
date: d('2001-04-14'),
endDate: d('2001-04-28'),
product: 'Oxuvar',
},
});
return colonyId;
}
it('A3: treatment ended 14 days ago -> VARROA_RECHECK for varroa readers, and no A2 beside it', async () => {
const colonyId = await colonyAfterTreatment('recheck', ['2001-04-01']);
await sync(colonyId);
const [recheck] = await suggestionsOf(colonyId, 'VARROA_RECHECK');
expect(recheck).toMatchObject({
status: 'OPEN',
mode: 'DO',
reasonCode: 'VARROA_RECHECK_DUE',
requiredPermission: 'varroa:read',
});
expect(recheck.dueDate?.toISOString().slice(0, 10)).toBe('2001-05-12');
expect(await suggestionsOf(colonyId, 'VARROA_MEASURE')).toHaveLength(0);
});
it('A3: a measurement on the end day does not count, one the day after does', async () => {
const onEndDay = await colonyAfterTreatment('endday', ['2001-04-28']);
const dayAfter = await colonyAfterTreatment('dayafter', ['2001-04-29']);
await sync(onEndDay);
await sync(dayAfter);
expect(await suggestionsOf(onEndDay, 'VARROA_RECHECK')).toHaveLength(1);
expect(await suggestionsOf(dayAfter, 'VARROA_RECHECK')).toHaveLength(0);
});
it('A2 winter window: no measurement since 11-01 -> VARROA_WINTER_MEASURE with anchor winter-2001', async () => {
const colonyId = await colonyWithLateInspection('winter');
await prisma.varroaMeasurement.updateMany({
where: { colonyId },
data: { date: d('2001-09-01') },
});
await sync(colonyId, '2001-11-20');
const [measure] = await suggestionsOf(colonyId, 'VARROA_MEASURE');
expect(measure).toMatchObject({
status: 'OPEN',
reasonCode: 'VARROA_WINTER_MEASURE',
dedupeKey: `SEASON_AUTOPILOT:${colonyId}:winter-2001`,
});
expect(await suggestionsOf(colonyId, 'INSPECT')).toHaveLength(0);
});
it('a user without varroa:read sees neither the A2 nor the A3 suggestion', async () => {
const password = 'SuperSecret123!';
const tokenFor = async (label: string, keys: string[]) => {
const role = await prisma.role.create({
data: {
key: `AP_E2E_${label.toUpperCase()}_${suffix}`,
name: `Autopilot e2e ${label}`,
permissions: {
create: keys.map((key) => ({ permission: { connect: { key } } })),
},
},
});
roleIds.push(role.id);
const email = `autopilot-e2e-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Autopilot e2e ${label}`,
role: { connect: { id: role.id } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
};
const full = await tokenFor('full', ['tasks:read', 'varroa:read']);
const noVarroa = await tokenFor('novarroa', ['tasks:read']);
const colonyId = await colonyAfterTreatment('visible', ['2001-04-01']);
await sync(colonyId);
const list = (token: string) =>
request(app.getHttpServer())
.get(`/api/v1/suggestions?colonyId=${colonyId}`)
.set('Authorization', `Bearer ${token}`);
const seen = await list(full);
const hidden = await list(noVarroa);
expect((seen.body as unknown[]).length).toBeGreaterThanOrEqual(1);
expect(hidden.body).toEqual([]);
});
});
+365
View File
@@ -0,0 +1,365 @@
import { INestApplication } from '@nestjs/common';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { ChainEventHandlers } from '../src/suggestions/chains/chains.handlers';
import { SuggestionsService } from '../src/suggestions/suggestions.service';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
/**
* The biotechnical chains against a real PostgreSQL: an inspection, a queen
* assignment or a split leaves one PLAN suggestion; later inspections clean
* up the checks they answer. The events are recorded directly (the services
* that record them have their own e2e tests) and drained like the worker.
*/
describe('Chains (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let events: DomainEventsService;
let suggestions: SuggestionsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let colonyId: string;
let userId: string;
let aggregateIds: string[] = [];
// Only the chains: the season autopilot writes its own suggestions for the
// same colony when the inspection event is delivered.
const chainSuggestions = () =>
prisma.suggestion.findMany({
where: { colonyId, source: 'CHAIN' },
orderBy: { createdAt: 'asc' },
});
const openOf = (intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN') =>
prisma.suggestion.findMany({
where: { colonyId, source: 'CHAIN', status: 'OPEN', intent },
});
const dayOf = (date: Date | null) => date?.toISOString().slice(0, 10);
/** Records an inspection and runs the handlers of its event. */
async function inspect(
day: string,
findings: {
queenCellsPresent?: boolean | null;
queenSeen?: boolean | null;
eggsPresent?: boolean | null;
} = {},
): Promise<string> {
const inspection = await prisma.inspection.create({
data: {
colonyId,
userId,
date: new Date(`${day}T10:00:00.000Z`),
queenCellsPresent: findings.queenCellsPresent ?? null,
queenSeen: findings.queenSeen ?? null,
eggsPresent: findings.eggsPresent ?? null,
},
});
await recordAndDrain(inspection.id, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: inspection.id,
payload: { colonyId },
}),
),
);
return inspection.id;
}
async function assignQueen(assignedOn: string): Promise<string> {
const queenId = `queen-${suffix}-${aggregateIds.length}`;
const historyId = `hist-${suffix}-${aggregateIds.length}`;
await recordAndDrain(queenId, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: queenId,
payload: { colonyId, historyId, assignedOn, previousColonyId: null },
}),
),
);
return historyId;
}
async function split(
queenSource: 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN' | null,
): Promise<void> {
await recordAndDrain(colonyId, () =>
prisma.$transaction((tx) =>
events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: {
parentColonyId: `parent-${suffix}`,
lineageId: `lineage-${suffix}-${aggregateIds.length}`,
queenSource,
splitOn: '2026-05-01',
},
}),
),
);
}
async function recordAndDrain(
aggregateId: string,
record: () => Promise<void>,
): Promise<void> {
aggregateIds = [...aggregateIds, aggregateId];
await record();
await drainDomainEvents(app, [aggregateId]);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
events = app.get(DomainEventsService);
suggestions = app.get(SuggestionsService);
const apiary = await prisma.apiary.create({
data: { name: `Chains e2e ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `CHN-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
const user = await prisma.user.create({
data: {
email: `chains-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Chains e2e',
role: { connect: { key: 'STAFF' } },
},
});
userId = user.id;
});
async function cleanUp(): Promise<void> {
await prisma.task.deleteMany({
where: {
OR: [{ relatedId: colonyId }, { sourceSuggestion: { colonyId } }],
},
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
aggregateIds = [];
}
afterEach(async () => {
await cleanUp();
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'ACTIVE' },
});
});
afterAll(async () => {
await cleanUp();
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{
entityType: 'Suggestion',
afterData: { path: ['colonyId'], equals: colonyId },
},
],
},
});
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.user.delete({ where: { id: userId } });
await app.close();
});
it('queen cells -> one PLAN suggestion due +7', async () => {
const inspectionId = await inspect('2026-05-05', {
queenCellsPresent: true,
});
const open = await openOf('SWARM_CHECK');
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
ruleKey: 'QUEEN_CELLS_SWARM_CHECK',
source: 'CHAIN',
mode: 'PLAN',
relatedType: 'COLONY',
relatedId: colonyId,
requiredPermission: 'inspections:read',
dedupeKey: `QUEEN_CELLS_SWARM_CHECK:${colonyId}:${inspectionId}`,
});
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(0);
});
it('an inspection without queen cells proposes nothing', async () => {
await inspect('2026-05-05', { queenCellsPresent: false });
await inspect('2026-05-06');
expect(await chainSuggestions()).toHaveLength(0);
});
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', async () => {
await split('RAISE_OWN');
const [suggestion] = await openOf('SPLIT_PLAN');
expect(suggestion).toMatchObject({
ruleKey: 'SPLIT_SCHEDULE',
mode: 'PLAN',
requiredPermission: 'colonies:read',
});
expect(dayOf(suggestion.dueDate)).toBe('2026-05-10');
const result = await suggestions.accept(suggestion.id, {}, userId);
expect(
result.tasks.map((task) => [task.title, dayOf(task.dueDate)]),
).toEqual([
['Nachschaffungszellen prüfen', '2026-05-10'],
['Begattung prüfen', '2026-05-22'],
['Stifte prüfen', '2026-05-29'],
]);
});
it('a split without queenSource leaves nothing', async () => {
await split(null);
expect(await chainSuggestions()).toHaveLength(0);
});
it('a queen assignment -> acceptance check +7', async () => {
const historyId = await assignQueen('2026-05-05');
const open = await openOf('QUEEN_ACCEPTANCE');
expect(open).toHaveLength(1);
expect(open[0]).toMatchObject({
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
source: 'CHAIN',
mode: 'PLAN',
requiredPermission: 'queens:read',
dedupeKey: `QUEEN_ACCEPTANCE_CHECK:${colonyId}:${historyId}`,
});
expect(dayOf(open[0].dueDate)).toBe('2026-05-12');
});
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', async () => {
await split('QUEEN');
await assignQueen('2026-05-01');
const open = await openOf('QUEEN_ACCEPTANCE');
expect(open).toHaveLength(1);
expect(open[0].ruleKey).toBe('SPLIT_SCHEDULE');
expect(dayOf(open[0].dueDate)).toBe('2026-05-08');
});
it('an inspection on the due day resolves the open swarm check', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
await inspect('2026-05-12', { queenCellsPresent: false });
const [resolved] = await chainSuggestions();
expect(resolved).toMatchObject({
status: 'RESOLVED',
dedupeKey: null,
decisionNote: 'Mit Durchsicht erledigt',
});
});
it('an inspection before the due day leaves the swarm check open', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
await inspect('2026-05-08', { queenCellsPresent: false });
expect(await openOf('SWARM_CHECK')).toHaveLength(1);
});
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', async () => {
await inspect('2026-05-05', { queenCellsPresent: true });
const secondId = await inspect('2026-05-08', { queenCellsPresent: true });
const all = await chainSuggestions();
expect(all.map((row) => row.status)).toEqual(['RESOLVED', 'OPEN']);
expect(all[1].dedupeKey).toBe(
`QUEEN_CELLS_SWARM_CHECK:${colonyId}:${secondId}`,
);
expect(dayOf(all[1].dueDate)).toBe('2026-05-15');
});
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', async () => {
await assignQueen('2026-05-05');
await inspect('2026-05-12');
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(1);
await inspect('2026-05-13', { eggsPresent: true });
expect(await openOf('QUEEN_ACCEPTANCE')).toHaveLength(0);
});
it('delivering the same InspectionRecorded and ColonySplit twice leaves exactly one suggestion each', async () => {
const inspectionId = await inspect('2026-05-05', {
queenCellsPresent: true,
});
await split('RAISE_OWN');
const handlers = app.get(ChainEventHandlers);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: { in: aggregateIds } },
});
expect(events).toHaveLength(2);
// Redelivery after a crash: the handlers run again with the stored events.
for (const event of events) {
await prisma.$transaction(async (tx) => {
const envelope = { ...event, attempts: 1 } as never;
if (event.type === 'InspectionRecorded') {
await handlers.queenCells(envelope, tx);
} else {
await handlers.splitSchedule(envelope, tx);
}
});
}
const all = await chainSuggestions();
expect(all.map((row) => [row.intent, row.status])).toEqual([
['SWARM_CHECK', 'OPEN'],
['SPLIT_PLAN', 'OPEN'],
]);
expect(all[0].dedupeKey).toContain(inspectionId);
});
it('a backdated inspection with queen cells does not replace the newer open check', async () => {
await inspect('2026-05-08', { queenCellsPresent: true });
await inspect('2026-05-02', { queenCellsPresent: true });
const all = await chainSuggestions();
expect(all.map((row) => row.status)).toEqual(['OPEN']);
expect(dayOf(all[0].dueDate)).toBe('2026-05-15');
});
it('nothing for an ended colony', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DISSOLVED' },
});
await inspect('2026-05-05', { queenCellsPresent: true });
await assignQueen('2026-05-05');
await split('QUEEN');
expect(await chainSuggestions()).toHaveLength(0);
});
});
+29 -19
View File
@@ -86,28 +86,30 @@ describe('Colonies (e2e)', () => {
raceColonyId,
...splitColonyIds,
].filter(Boolean);
// Rule tasks are written without a user - their audit entry and
// TaskCreated are not covered by the userId cleanup below.
const ruleTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
// Rule suggestions are written without a user - their audit entry and
// warning are not covered by the userId cleanup below.
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((task) => task.id);
).map((suggestion) => suggestion.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: ruleTaskIds } },
where: { entityType: 'Suggestion', entityId: { in: suggestionIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.notification.deleteMany({
where: {
OR: [
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
{ aggregateType: 'Task', aggregateId: { in: ruleTaskIds } },
{ relatedId: { in: colonyIds } },
{ relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
],
},
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await prisma.notification.deleteMany({
where: { relatedId: { in: colonyIds } },
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
await prisma.colonyLineage.deleteMany({
where: { OR: [{ fromColonyId: colonyId }, { toColonyId: colonyId }] },
@@ -330,7 +332,7 @@ describe('Colonies (e2e)', () => {
expect(colony.status).toBe('WEAK');
});
it('creates the COLONY_HEALTH_CRITICAL task only after the event is processed, and only on the transition', async () => {
it('proposes the COLONY_HEALTH_CRITICAL check only after the event is processed, and only on the transition', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -356,15 +358,19 @@ describe('Colonies (e2e)', () => {
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
});
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(0);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
// Already critical: a further edit is no transition and opens no second task.
// Already critical: a further edit is no transition and proposes nothing a second time.
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${healthColonyId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -372,11 +378,13 @@ describe('Colonies (e2e)', () => {
.expect(200);
await drainDomainEvents(app, [healthColonyId]);
expect(
await prisma.task.count({ where: { relatedId: healthColonyId } }),
await prisma.suggestion.count({
where: { colonyId: healthColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
});
it('two concurrent PATCHes into CRITICAL produce one transition event and one task', async () => {
it('two concurrent PATCHes into CRITICAL produce one transition event and one suggestion', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/colonies')
.set('Authorization', `Bearer ${beekeeperToken}`)
@@ -405,7 +413,9 @@ describe('Colonies (e2e)', () => {
).toBe(1);
await drainDomainEvents(app, [raceColonyId]);
expect(
await prisma.task.count({ where: { relatedId: raceColonyId } }),
await prisma.suggestion.count({
where: { colonyId: raceColonyId, ruleKey: 'COLONY_HEALTH_CRITICAL' },
}),
).toBe(1);
});
});
@@ -0,0 +1,170 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface ImportReportBody {
created: number;
}
describe('Colony strength in occupied frames (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
let adminToken: string;
let adminId: string;
let apiaryId: string;
let apiaryName: string;
let colonyId: string;
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const admin = await prisma.user.create({
data: {
email: `strength-admin-${suffix}@example.com`,
passwordHash: await bcrypt.hash(password, 12),
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
adminToken = (await login(app, admin.email, password)).accessToken;
apiaryName = `Strength ${suffix}`;
apiaryId = (await prisma.apiary.create({ data: { name: apiaryName } })).id;
colonyId = (
await prisma.colony.create({
data: {
code: `ST-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
strength: 7,
},
})
).id;
});
afterAll(async () => {
const colonies = await prisma.colony.findMany({
where: { apiaryId },
select: { id: true },
});
const ids = colonies.map((row) => row.id);
const inspections = await prisma.inspection.findMany({
where: { colonyId: { in: ids } },
select: { id: true },
});
const inspectionIds = inspections.map((row) => row.id);
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateType: 'Inspection', aggregateId: { in: inspectionIds } },
{ aggregateType: 'Colony', aggregateId: { in: ids } },
],
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId: { in: ids } } });
await prisma.colony.deleteMany({ where: { id: { in: ids } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({ where: { userId: adminId } });
await prisma.auditLog.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
await app.close();
});
it('PATCH occupiedFrames keeps the legacy strength', async () => {
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(adminToken))
.send({ occupiedFrames: 16 })
.expect(200);
expect(
await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }),
).toMatchObject({ occupiedFrames: 16, strength: 7 });
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(adminToken))
.send({ occupiedFrames: null })
.expect(200);
expect(
(await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }))
.occupiedFrames,
).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/colonies/${colonyId}`)
.set(auth(adminToken))
.send({ occupiedFrames: 41 })
.expect(400);
});
it('a takeover from an inspection writes only occupiedFrames', async () => {
await request(app.getHttpServer())
.post('/api/v1/inspections')
.set(auth(adminToken))
.send({
colonyId,
date: '2026-06-01T10:00:00.000Z',
colonyStrength: 12,
applyToColony: ['occupiedFrames'],
})
.expect(201);
expect(
await prisma.colony.findUniqueOrThrow({ where: { id: colonyId } }),
).toMatchObject({
occupiedFrames: 12,
strength: 7,
broodStatus: null,
feedStatus: null,
});
});
it('colonyStrength 41 answers 400 "Höchstens 40 besetzte Waben."', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/inspections')
.set(auth(adminToken))
.send({ colonyId, date: '2026-06-02T10:00:00.000Z', colonyStrength: 41 })
.expect(400);
expect(JSON.stringify(response.body)).toContain(
'Höchstens 40 besetzte Waben.',
);
});
it('CSV export and import carry strength and occupiedFrames', async () => {
const exported = await request(app.getHttpServer())
.get('/api/v1/export/colonies.csv')
.set(auth(adminToken))
.expect(200);
const lines = exported.text.split('\n');
expect(lines[0].trim().endsWith(',occupiedFrames')).toBe(true);
expect(lines[0]).toContain('strength,healthStatus');
const row = lines.find((line) => line.includes(`ST-${suffix}`)) ?? '';
expect(row).toContain(',7,');
expect(row.trim().endsWith(',12')).toBe(true);
const code = `ST-IMP-${suffix}`;
const csv = `code,apiaryName,colonyType,strength,occupiedFrames\n${code},${apiaryName},PRODUCTION_COLONY,5,20`;
const response = await request(app.getHttpServer())
.post('/api/v1/import/colonies')
.set(auth(adminToken))
.attach('file', Buffer.from(csv), {
filename: 'colonies.csv',
contentType: 'text/csv',
})
.expect(201);
expect((response.body as ImportReportBody).created).toBe(1);
expect(
await prisma.colony.findUniqueOrThrow({ where: { code } }),
).toMatchObject({ strength: 5, occupiedFrames: 20 });
});
});
@@ -1,6 +1,9 @@
import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { AutopilotStep } from '../src/autopilot/autopilot.step';
import { SuggestionsExpiryStep } from '../src/autopilot/suggestions-expiry.step';
import { AutopilotService } from '../src/autopilot/autopilot.service';
import { JobsModule } from '../src/jobs/jobs.module';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
@@ -27,7 +30,11 @@ describe('daily-evaluation (e2e)', () => {
let app: INestApplicationContext;
let prisma: PrismaService;
let step: TimeEventsStep;
let autopilotStep: AutopilotStep;
let expiryStep: SuggestionsExpiryStep;
const ids: Record<string, string> = {};
/** Colonies of the autopilot tests; the steps only see these. */
const autopilotColonyIds: string[] = [];
const eventsOf = (aggregateId: string) =>
prisma.domainEvent.findMany({
@@ -49,6 +56,25 @@ describe('daily-evaluation (e2e)', () => {
app = await moduleRef.init();
prisma = app.get(PrismaService);
step = app.get(TimeEventsStep);
expiryStep = app.get(SuggestionsExpiryStep);
// The real AutopilotStep, but on the colonies of this file only: other
// files share the database and must not be reconciled on a 2001 day.
autopilotStep = new AutopilotStep(
{
colony: {
findMany: (args: Prisma.ColonyFindManyArgs) =>
prisma.colony.findMany({
...args,
where: {
AND: [args.where ?? {}, { id: { in: autopilotColonyIds } }],
},
}),
},
automationRule: prisma.automationRule,
$transaction: prisma.$transaction.bind(prisma) as unknown,
} as unknown as PrismaService,
app.get(AutopilotService),
);
const user = await prisma.user.create({
data: {
@@ -155,7 +181,15 @@ describe('daily-evaluation (e2e)', () => {
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
const colonyIds = [ids.living, ids.dead].filter(Boolean);
const colonyIds = [ids.living, ids.dead, ...autopilotColonyIds].filter(
Boolean,
);
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.varroaMeasurement.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
@@ -305,4 +339,128 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['queen:young'])).toEqual([]);
expect(await eventsOf(ids['queen:reserve'])).toEqual([]);
});
describe('autopilot and suggestions-expiry steps', () => {
/** Living colony, inspected 2001-05-03 and measured 2001-05-10. */
async function lateColony(name: string): Promise<string> {
const colony = await prisma.colony.create({
data: {
code: `DE-E2E-${suffix}-ap-${name}`,
apiaryId: ids.apiary,
colonyType: 'PRODUCTION_COLONY',
createdAt: d('2001-04-20'),
},
});
autopilotColonyIds.push(colony.id);
await prisma.inspection.create({
data: { colonyId: colony.id, userId: ids.user, date: d('2001-05-03') },
});
await prisma.varroaMeasurement.create({
data: {
colonyId: colony.id,
userId: ids.user,
date: d('2001-05-10'),
method: 'NATURAL_MITE_FALL',
},
});
return colony.id;
}
const inspectSuggestions = (colonyId: string) =>
prisma.suggestion.findMany({
where: { colonyId, intent: 'INSPECT', source: 'AUTOPILOT' },
orderBy: { createdAt: 'asc' },
});
it('running autopilot and expiry twice yields the same suggestion counts', async () => {
const colonyId = await lateColony('twice');
const first = await autopilotStep.run('2001-05-12');
const firstExpiry = await expiryStep.run('2001-05-12');
const second = await autopilotStep.run('2001-05-12');
const secondExpiry = await expiryStep.run('2001-05-12');
expect(first).toMatchObject({
autopilotColonies: 1,
suggestionsOpened: 1,
suggestionsResolved: 0,
});
expect(second).toMatchObject({
autopilotColonies: 1,
suggestionsOpened: 0,
suggestionsResolved: 0,
});
expect(firstExpiry).toEqual({ suggestionsExpired: 0 });
expect(secondExpiry).toEqual({ suggestionsExpired: 0 });
expect(await inspectSuggestions(colonyId)).toHaveLength(1);
});
it('an OPEN suggestion past expiresAt becomes EXPIRED', async () => {
const colonyId = await lateColony('expires');
await autopilotStep.run('2001-05-12');
// expiresAt = 2001-06-11
expect(await expiryStep.run('2001-06-11')).toEqual({
suggestionsExpired: 0,
});
expect(await expiryStep.run('2001-06-12')).toEqual({
suggestionsExpired: expect.any(Number) as number,
});
const [suggestion] = await inspectSuggestions(colonyId);
expect(suggestion).toMatchObject({ status: 'EXPIRED', dedupeKey: null });
});
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', async () => {
const colonyId = await lateColony('again');
await autopilotStep.run('2001-05-12');
await expiryStep.run('2001-06-12');
const result = await autopilotStep.run('2001-06-12');
expect(result.suggestionsOpened).toBeGreaterThanOrEqual(1);
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
['EXPIRED', 'OPEN'],
);
});
it('a colony overdue for 20+ days gets its step from the daily step although no new event is recorded', async () => {
const colonyId = await lateColony('overdue20');
await prisma.inspection.updateMany({
where: { colonyId },
data: { date: d('2001-04-10') },
});
await step.run('2001-05-12');
await autopilotStep.run('2001-05-12');
// Due 2001-04-17: the event of 04-18 is outside the 7-day catch-up.
expect(
await prisma.domainEvent.count({
where: { aggregateId: colonyId, type: 'InspectionOverdue' },
}),
).toBe(0);
const [suggestion] = await inspectSuggestions(colonyId);
expect(suggestion).toMatchObject({ status: 'OPEN', priority: 'HIGH' });
expect(suggestion.dueDate?.toISOString().slice(0, 10)).toBe('2001-04-17');
});
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', async () => {
const colonyId = await lateColony('returns');
await autopilotStep.run('2001-05-12');
const fresh = await prisma.inspection.create({
data: { colonyId, userId: ids.user, date: d('2001-05-12') },
});
const resolved = await autopilotStep.run('2001-05-12');
await prisma.inspection.delete({ where: { id: fresh.id } });
const returned = await autopilotStep.run('2001-05-12');
expect(resolved.suggestionsResolved).toBeGreaterThanOrEqual(1);
expect(returned.suggestionsOpened).toBeGreaterThanOrEqual(1);
expect((await inspectSuggestions(colonyId)).map((s) => s.status)).toEqual(
['RESOLVED', 'OPEN'],
);
});
});
});
@@ -136,11 +136,8 @@ describe('Data integrity (e2e)', () => {
await drainDomainEvents(app, [(first.body as { id: string }).id]);
// The automation fired once, not once per delivery attempt.
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: { not: null },
},
await prisma.suggestion.count({
where: { colonyId: colony.id, source: 'RULE' },
}),
).toBe(1);
});
@@ -261,7 +258,7 @@ describe('Data integrity (e2e)', () => {
expect(after.quantity).toBe(5);
});
it('creates only one open reorder task while the item stays below minimum', async () => {
it('proposes only one open reorder while the item stays below minimum', async () => {
const item = await prisma.stockItem.create({
data: {
name: `Deckel ${suffix}`,
@@ -279,11 +276,11 @@ describe('Data integrity (e2e)', () => {
.expect(201);
}
expect(
await prisma.task.count({
await prisma.suggestion.count({
where: {
relatedId: item.id,
sourceAutomationTrigger: 'STOCK_ITEM_LOW',
status: { in: ['OPEN', 'IN_PROGRESS'] },
ruleKey: 'STOCK_ITEM_LOW',
status: 'OPEN',
},
}),
).toBe(1);
@@ -92,6 +92,7 @@ describe('Feedings (e2e)', () => {
where: { aggregateType: 'Feeding', aggregateId: { in: feedingIds } },
});
await prisma.feeding.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
@@ -254,6 +255,9 @@ describe('Feedings (e2e)', () => {
await prisma.feeding.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
});
@@ -95,16 +95,30 @@ describe('Follow-up controls (e2e)', () => {
},
data: { active: false, config: {} },
});
// Follow-up tasks are written without a user: remove their audit entry
// and TaskCreated (deliveries go with the event, ON DELETE CASCADE).
const followUpTaskIds = (
await prisma.task.findMany({
where: { relatedId: { in: colonyIds } },
// Follow-up suggestions are written without a user: remove them with
// their audit entries (deliveries go with the event, ON DELETE CASCADE).
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((task) => task.id);
).map((suggestion) => suggestion.id);
await prisma.auditLog.deleteMany({
where: { entityType: 'Task', entityId: { in: followUpTaskIds } },
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...colonyIds.map((id) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: id },
})),
],
},
});
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
const measureIds = [
...(await prisma.treatment.findMany({
@@ -122,9 +136,6 @@ describe('Follow-up controls (e2e)', () => {
await prisma.auditLog.deleteMany({
where: { entityId: { in: measureIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Task', aggregateId: { in: followUpTaskIds } },
});
await app.close();
});
@@ -136,9 +147,9 @@ describe('Follow-up controls (e2e)', () => {
const colony = await newColony('OFF');
await treat(colony.id);
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
).toBe(0);
});
it('suggests a check due the configured days after the treatment ended', async () => {
@@ -155,25 +166,30 @@ describe('Follow-up controls (e2e)', () => {
endDate: '2026-08-10T00:00:00.000Z',
});
const tasks = await prisma.task.findMany({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
const suggestions = await prisma.suggestion.findMany({
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
});
expect(tasks).toHaveLength(1);
expect(tasks[0].dueDate?.toISOString()).toBe('2026-08-24T00:00:00.000Z');
expect(tasks[0].description).toContain('Testmittel');
expect(tasks[0].status).toBe('OPEN');
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
status: 'OPEN',
intent: 'FOLLOW_UP',
reasonCode: 'TREATMENT_FOLLOW_UP',
requiredPermission: 'treatments:read',
evidence: { colonyId: colony.id, endedOn: '2026-08-10' },
});
expect(suggestions[0].dueDate?.toISOString()).toBe(
'2026-08-24T00:00:00.000Z',
);
// The rule proposes; no task exists before the suggestion is accepted.
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
// A second treatment while the check is still open adds nothing.
await treat(colony.id, { date: '2026-08-12T00:00:00.000Z' });
expect(
await prisma.task.count({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'TREATMENT_FOLLOW_UP',
},
await prisma.suggestion.count({
where: { colonyId: colony.id, ruleKey: 'TREATMENT_FOLLOW_UP' },
}),
).toBe(1);
});
@@ -199,17 +215,15 @@ describe('Follow-up controls (e2e)', () => {
})
.expect(201);
// Not before the FeedingRecorded handler ran.
expect(await prisma.task.count({ where: { relatedId: colony.id } })).toBe(
0,
);
expect(
await prisma.suggestion.count({ where: { colonyId: colony.id } }),
).toBe(0);
await drainDomainEvents(app, [(response.body as { id: string }).id]);
const task = await prisma.task.findFirstOrThrow({
where: {
relatedId: colony.id,
sourceAutomationTrigger: 'FEEDING_FOLLOW_UP',
},
const suggestion = await prisma.suggestion.findFirstOrThrow({
where: { colonyId: colony.id, ruleKey: 'FEEDING_FOLLOW_UP' },
});
expect(task.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
expect(suggestion.dueDate?.toISOString()).toBe('2026-09-08T00:00:00.000Z');
expect(suggestion.evidence).toMatchObject({ fedOn: '2026-09-01' });
});
});
@@ -0,0 +1,272 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface TakeoverBody {
id: string;
colonyTakeover: {
applied: string[];
skipped: { field: string; reason: string }[];
};
}
describe('Inspection takeover (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
let staffToken: string;
let narrowToken: string;
let staffId: string;
let narrowId: string;
let narrowRoleId: string;
let apiaryId: string;
const colonyIds: string[] = [];
// One colony per test: no test depends on what another one wrote.
let colonyId: string;
const post = (token: string, body: object) =>
request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${token}`)
.send({ colonyId, ...body });
const colony = () =>
prisma.colony.findUniqueOrThrow({ where: { id: colonyId } });
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({
data: {
email: `takeover-staff-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
staffId = staff.id;
const narrowRole = await prisma.role.create({
data: {
key: `TAKEOVER_NARROW_${suffix}`,
name: 'Nur Durchsicht',
permissions: {
create: ['inspections:create', 'colonies:read'].map((key) => ({
permission: { connect: { key } },
})),
},
},
});
narrowRoleId = narrowRole.id;
const narrow = await prisma.user.create({
data: {
email: `takeover-narrow-${suffix}@example.com`,
passwordHash,
name: 'Narrow',
roleId: narrowRole.id,
},
});
narrowId = narrow.id;
staffToken = (await login(app, staff.email, password)).accessToken;
narrowToken = (await login(app, narrow.email, password)).accessToken;
apiaryId = (
await prisma.apiary.create({ data: { name: `Takeover ${suffix}` } })
).id;
});
beforeEach(async () => {
const created = await prisma.colony.create({
data: {
code: `TK-${suffix}-${colonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
strength: 6,
},
});
colonyId = created.id;
colonyIds.push(created.id);
});
afterAll(async () => {
const ids = (
await prisma.inspection.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((row) => row.id);
await prisma.domainEvent.deleteMany({
where: {
OR: [
{ aggregateType: 'Inspection', aggregateId: { in: ids } },
{ aggregateType: 'Colony', aggregateId: { in: colonyIds } },
],
},
});
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceType: 'INSPECTION', sourceId: { in: ids } },
});
await prisma.inspection.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
const userIds = [staffId, narrowId];
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: narrowRoleId } });
await app.close();
});
it('takes over feed and brood in the same request and writes an audit entry Colony UPDATE', async () => {
const response = await post(staffToken, {
date: '2026-06-01T10:00:00.000Z',
feedStatus: 'WEAK',
broodPattern: 4,
colonyStrength: 18,
applyToColony: ['feedStatus', 'broodStatus', 'occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.applied).toEqual([
'feedStatus',
'broodStatus',
'occupiedFrames',
]);
expect(body.colonyTakeover.skipped).toEqual([]);
expect(await colony()).toMatchObject({
feedStatus: 'WEAK',
broodStatus: 'GOOD',
occupiedFrames: 18,
strength: 6,
});
const audit = await prisma.auditLog.findFirstOrThrow({
where: {
entityType: 'Colony',
entityId: colonyId,
action: 'UPDATE',
userId: staffId,
},
});
expect(audit.beforeData).toMatchObject({
feedStatus: null,
broodStatus: null,
occupiedFrames: null,
});
expect(audit.afterData).toMatchObject({
feedStatus: 'WEAK',
broodStatus: 'GOOD',
occupiedFrames: 18,
});
});
it('skips with NEWER_INSPECTION when a newer inspection of the colony exists', async () => {
await post(staffToken, { date: '2026-06-01T10:00:00.000Z' }).expect(201);
const response = await post(staffToken, {
date: '2026-05-01T10:00:00.000Z',
colonyStrength: 22,
applyToColony: ['occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.applied).toEqual([]);
expect(body.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
]);
expect((await colony()).occupiedFrames).toBeNull();
});
it('answers 201 and skips with NO_PERMISSION without colonies:update', async () => {
const response = await post(narrowToken, {
date: '2026-07-01T10:00:00.000Z',
colonyStrength: 25,
applyToColony: ['occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
]);
expect((await colony()).occupiedFrames).toBeNull();
expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1);
});
it('skips with COLONY_ENDED for a dissolved colony and still saves the inspection', async () => {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'DISSOLVED', deactivatedAt: new Date() },
});
const response = await post(staffToken, {
date: '2026-07-01T10:00:00.000Z',
colonyStrength: 12,
applyToColony: ['occupiedFrames'],
}).expect(201);
const body = response.body as TakeoverBody;
expect(body.colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
]);
expect(await prisma.inspection.count({ where: { id: body.id } })).toBe(1);
expect((await colony()).occupiedFrames).toBeNull();
});
it('takes values only from the new inspection, never from an older stored row (even an out-of-range legacy value)', async () => {
await prisma.inspection.create({
data: {
colonyId,
date: new Date('2026-04-01T10:00:00.000Z'),
colonyStrength: 45,
userId: staffId,
},
});
const response = await post(staffToken, {
date: '2026-06-01T10:00:00.000Z',
applyToColony: ['occupiedFrames'],
}).expect(201);
expect((response.body as TakeoverBody).colonyTakeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_VALUE' },
]);
expect((await colony()).occupiedFrames).toBeNull();
});
it('a correction (PATCH) takes nothing over', async () => {
const created = await post(staffToken, {
date: '2026-06-01T10:00:00.000Z',
}).expect(201);
const inspectionId = (created.body as TakeoverBody).id;
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${inspectionId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ colonyStrength: 30 })
.expect(200);
expect((await colony()).occupiedFrames).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${inspectionId}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ applyToColony: ['occupiedFrames'] })
.expect(400);
});
it.each([
[['feedStatus', 'broodStatus', 'occupiedFrames', 'feedStatus']],
[['feedStatus', 'feedStatus']],
[['nonsense']],
])('applyToColony %p answers 400', async (applyToColony) => {
await post(staffToken, {
date: '2026-08-01T10:00:00.000Z',
applyToColony,
}).expect(400);
});
});
+9 -2
View File
@@ -98,6 +98,7 @@ describe('Inspections (e2e)', () => {
where: { sourceType: 'INSPECTION', sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
@@ -159,7 +160,7 @@ describe('Inspections (e2e)', () => {
]);
});
it('records InspectionCorrected for an edit, whose only follow-up is the calendar', async () => {
it('records InspectionCorrected for an edit, whose follow-ups are the calendar and the autopilot', async () => {
await drainDomainEvents(app, [inspectionId]);
await request(app.getHttpServer())
@@ -188,11 +189,17 @@ describe('Inspections (e2e)', () => {
});
await drainDomainEvents(app, [inspectionId]);
// A correction evaluates no automation rule and sends no webhook.
// A correction evaluates no automation rule and sends no webhook; the
// calendar and the autopilot of the colony follow.
const deliveries = await prisma.domainEventDelivery.findMany({
where: { eventId: correction.id },
orderBy: { handler: 'asc' },
});
expect(deliveries).toEqual([
expect.objectContaining({
handler: 'autopilot.colony-sync',
status: 'DONE',
}),
expect.objectContaining({
handler: 'calendar-sync.inspection',
status: 'DONE',
@@ -150,6 +150,38 @@ describe('Notifications (e2e)', () => {
expect(listed).not.toContain(hidden.id);
});
it('a row the user may not see cannot be read, marked or counted', async () => {
const hidden = await seedNotification(ids.bare, {
requiredPermissions: ['colonies:read'],
});
const open = await seedNotification(ids.bare);
await request(server())
.post(`/api/v1/notifications/${hidden.id}/read`)
.set(as('bare'))
.expect(404);
const response = await request(server())
.post('/api/v1/notifications/read-all')
.set(as('bare'))
.expect(201);
expect(
(response.body as { updated: number }).updated,
).toBeGreaterThanOrEqual(1);
expect(
(await prisma.notification.findUniqueOrThrow({ where: { id: open.id } }))
.read,
).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: hidden.id },
})
).read,
).toBe(false);
});
it('reading is per user: marking one copy read leaves the other unread', async () => {
const [first, second] = await notifications.recordForUsers(
[ids.beekeeper, ids.staff],
@@ -55,20 +55,31 @@ describe('Post-commit side effects (e2e)', () => {
return colony;
}
/** Open or not: a rule task that exists at all. */
const taskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: { sourceAutomationTrigger: trigger, relatedId },
/** Open or not: a rule suggestion that exists at all. */
const suggestionCount = (ruleKey: AutomationTriggerType, relatedId: string) =>
prisma.suggestion.count({ where: { ruleKey, relatedId } });
const openSuggestionCount = (
ruleKey: AutomationTriggerType,
relatedId: string,
) =>
prisma.suggestion.count({
where: { ruleKey, relatedId, status: 'OPEN' },
});
const openTaskCount = (trigger: AutomationTriggerType, relatedId: string) =>
prisma.task.count({
where: {
sourceAutomationTrigger: trigger,
relatedId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
/** The notifications written for the suggestions of the given objects. */
async function suggestionNotifications(relatedIds: string[]) {
const ids = (
await prisma.suggestion.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((suggestion) => suggestion.id);
return prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
select: { userId: true },
});
}
/** Everything this file wrote, scoped by its own ids. */
async function cleanUp(): Promise<void> {
@@ -86,13 +97,13 @@ describe('Post-commit side effects (e2e)', () => {
.flat()
.map((record) => record.id);
const relatedIds = [...colonyIds, ...stockItemIds];
const taskIds = (
await prisma.task.findMany({
const suggestionIds = (
await prisma.suggestion.findMany({
where: { relatedId: { in: relatedIds } },
select: { id: true },
})
).map((task) => task.id);
const aggregateIds = [...recordIds, ...taskIds];
).map((suggestion) => suggestion.id);
const aggregateIds = recordIds;
// Deliveries go with their event (ON DELETE CASCADE).
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
@@ -100,12 +111,12 @@ describe('Post-commit side effects (e2e)', () => {
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: aggregateIds } },
});
// Rule tasks and the rules' own entries are written without a user.
// Rule suggestions and the rules' own entries are written without a user.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ userId },
{ entityType: 'Task', entityId: { in: taskIds } },
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...relatedIds.map((relatedId) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: relatedId },
@@ -114,9 +125,11 @@ describe('Post-commit side effects (e2e)', () => {
},
});
await prisma.notification.deleteMany({
where: { relatedId: { in: relatedIds } },
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.suggestion.deleteMany({
where: { id: { in: suggestionIds } },
});
await prisma.task.deleteMany({ where: { id: { in: taskIds } } });
await prisma.inspection.deleteMany({ where: byColony.where });
await prisma.treatment.deleteMany({ where: byColony.where });
await prisma.varroaMeasurement.deleteMany({ where: byColony.where });
@@ -254,13 +267,11 @@ describe('Post-commit side effects (e2e)', () => {
attempts: 0,
}),
]);
// No rule ran in the request, not even one that failed: the task
// No rule ran in the request, not even one that failed: the suggestion
// follows when the handler runs.
expect(automationFailuresFor('Inspection', inspectionId)).toEqual([]);
expect(await taskCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colony.id } }),
).toBe(0);
expect(await suggestionCount('QUEENLESS_NO_EGGS', colony.id)).toBe(0);
expect(await suggestionNotifications([colony.id])).toEqual([]);
});
it('still answers 201 for a treatment when the rule evaluation would fail, and replays it', async () => {
@@ -286,7 +297,7 @@ describe('Post-commit side effects (e2e)', () => {
});
describe('when an automation handler fails', () => {
it('creates exactly one automation task when the handler fails once and is retried', async () => {
it('creates exactly one automation suggestion when the handler fails once and is retried', async () => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const colonyId = (await createColony('PC-RETRY')).id;
jest
@@ -302,20 +313,16 @@ describe('Post-commit side effects (e2e)', () => {
await drainDomainEvents(app, [body.id]);
// The warning failed, so the whole evaluation rolled back.
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(
await prisma.notification.count({ where: { relatedId: colonyId } }),
).toBe(0);
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(0);
expect(await suggestionNotifications([colonyId])).toEqual([]);
// Due again now instead of after the 30 s backoff.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "availableAt" = now() WHERE "aggregateId" = ${body.id}`;
await drainDomainEvents(app, [body.id]);
expect(await taskCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks), written once.
const warnings = await prisma.notification.findMany({
where: { relatedId: colonyId },
select: { userId: true },
});
expect(await suggestionCount('QUEENLESS_NO_EGGS', colonyId)).toBe(1);
// One copy per recipient (everybody who may read tasks and
// inspections), written once.
const warnings = await suggestionNotifications([colonyId]);
expect(warnings.length).toBeGreaterThan(0);
expect(new Set(warnings.map((w) => w.userId)).size).toBe(warnings.length);
@@ -337,11 +344,11 @@ describe('Post-commit side effects (e2e)', () => {
});
});
describe('automation tasks', () => {
it('leaves one open reorder task when several triggers for the same stock item arrive at once', async () => {
describe('automation suggestions', () => {
it('leaves one open reorder suggestion when several triggers for the same stock item arrive at once', async () => {
const automation = app.get(AutomationService);
// The same object crossing the threshold on several pods/requests at
// once: every evaluation sees "no open task yet" unless checking and
// once: every evaluation sees "nothing open yet" unless checking and
// creating are serialised.
const item = await prisma.stockItem.create({
data: {
@@ -358,10 +365,10 @@ describe('Post-commit side effects (e2e)', () => {
Array.from({ length: 8 }, () => automation.evaluateStockItem(item)),
);
expect(await openTaskCount('STOCK_ITEM_LOW', item.id)).toBe(1);
expect(await openSuggestionCount('STOCK_ITEM_LOW', item.id)).toBe(1);
});
it('leaves one open task when several triggers for the same colony arrive at once', async () => {
it('leaves one open suggestion when several triggers for the same colony arrive at once', async () => {
const colony = await createColony('PC-RACE');
const measurement = {
colonyId: colony.id,
@@ -384,7 +391,7 @@ describe('Post-commit side effects (e2e)', () => {
),
);
expect(await openTaskCount('VARROA_THRESHOLD', colony.id)).toBe(1);
expect(await openSuggestionCount('VARROA_THRESHOLD', colony.id)).toBe(1);
});
});
});
+50 -60
View File
@@ -59,8 +59,8 @@ describe('Raw materials and production (e2e)', () => {
let propolisId: string;
let productId: string;
let batchId: string;
// Tasks the RAW_MATERIAL_LOW handler created for this file.
const ruleTaskIds: string[] = [];
// Suggestions the RAW_MATERIAL_LOW handler created for this file.
const ruleSuggestionIds: string[] = [];
const server = () => app.getHttpServer();
const auth = (req: request.Test) =>
@@ -133,43 +133,35 @@ describe('Raw materials and production (e2e)', () => {
})
).map((movement) => movement.id);
const batchIds = [batchId].filter(Boolean);
// Rule tasks are written without a user: their TaskCreated, audit
// entries, warning and queued deliveries go by task id.
for (const taskId of ruleTaskIds) {
// Rule suggestions are written without a user: their audit entries and
// warnings go by suggestion id.
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: ruleSuggestionIds } },
...ruleSuggestionIds.map((suggestionId) => ({
entityType: 'AutomationRule',
afterData: { path: ['taskId'], equals: taskId },
afterData: { path: ['suggestionId'], equals: suggestionId },
})),
],
},
});
await prisma.webhookDelivery.deleteMany({
where: { event: 'task.created', body: { contains: taskId } },
});
}
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: ruleTaskIds } },
});
// Safe to scope by kind and time: RAW_MATERIAL_LOW is the only writer
// of GENERAL/PROPOLIS warnings, and only this file switches the rule on
// (default: off, no minimum) - so such a warning since this file
// started belongs to the task above.
if (ruleTaskIds.length > 0) {
await prisma.notification.deleteMany({
where: {
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
relatedType: 'Suggestion',
relatedId: { in: ruleSuggestionIds },
},
});
}
const aggregateIds = [...batchIds, ...movementIds, ...ruleTaskIds];
await prisma.suggestion.deleteMany({
where: { id: { in: ruleSuggestionIds } },
});
const aggregateIds = [...batchIds, ...movementIds];
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: aggregateIds } },
});
await prisma.auditLog.deleteMany({
where: { entityId: { in: [...lotIds, ...aggregateIds] } },
});
await prisma.task.deleteMany({ where: { id: { in: ruleTaskIds } } });
await app.close();
});
@@ -363,22 +355,22 @@ describe('Raw materials and production (e2e)', () => {
).expect(409);
});
it('creates the RAW_MATERIAL_LOW task only once its StockChanged event is dispatched', async () => {
it('proposes the RAW_MATERIAL_LOW reorder only once its StockChanged event is dispatched', async () => {
// The rule is shared by every e2e file: switch it on with a minimum no
// test database reaches, and put it back right after.
const before = await prisma.automationRule.findUnique({
where: { triggerType: 'RAW_MATERIAL_LOW' },
});
const config = { minGrams: { PROPOLIS: 1_000_000_000_000 } };
// One open task per kind: a leftover from an aborted earlier run would
// keep the handler from creating a new one - close it first.
await prisma.task.updateMany({
const config = { minGrams: { PROPOLIS: 1_000_000_000 } };
// One open suggestion per kind: a leftover from an aborted earlier run
// would keep the handler from proposing a new one - settle it first.
await prisma.suggestion.updateMany({
where: {
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
ruleKey: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
status: { in: ['OPEN', 'IN_PROGRESS'] },
status: 'OPEN',
},
data: { status: 'DONE', completedAt: new Date() },
data: { status: 'DISMISSED' },
});
await prisma.automationRule.upsert({
where: { triggerType: 'RAW_MATERIAL_LOW' },
@@ -400,23 +392,35 @@ describe('Raw materials and production (e2e)', () => {
.expect(201);
// Newest first: the withdrawal just booked.
const [movement] = (response.body as StockBody).movements;
const [event] = await prisma.domainEvent.findMany({
where: { type: 'StockChanged', aggregateId: movement.id },
select: { correlationId: true },
});
const ruleTasksOfThisChange = async () =>
(
await prisma.domainEvent.findMany({
const suggestionsOfThisChange = () =>
prisma.suggestion.findMany({
where: {
type: 'TaskCreated',
correlationId: event.correlationId,
ruleKey: 'RAW_MATERIAL_LOW',
relatedId: 'PROPOLIS',
createdAt: { gte: startedAt },
},
select: { aggregateId: true },
})
).map((created) => created.aggregateId);
});
// The request itself creates no task - the handler does.
expect(await ruleTasksOfThisChange()).toEqual([]);
// The request itself proposes nothing - the handler does.
expect(await suggestionsOfThisChange()).toEqual([]);
await drainDomainEvents(app, [movement.id]);
const suggestions = await suggestionsOfThisChange();
ruleSuggestionIds.push(...suggestions.map((found) => found.id));
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
ruleKey: 'RAW_MATERIAL_LOW',
intent: 'STOCK_REORDER',
reasonCode: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
colonyId: null,
requiredPermission: 'raw-material:read',
status: 'OPEN',
dedupeKey: `RAW_MATERIAL_LOW:PROPOLIS:${propolisId}`,
});
// The request creates no task either.
expect(
await prisma.task.count({
where: {
@@ -426,20 +430,6 @@ describe('Raw materials and production (e2e)', () => {
},
}),
).toBe(0);
await drainDomainEvents(app, [movement.id]);
const taskIds = await ruleTasksOfThisChange();
ruleTaskIds.push(...taskIds);
expect(taskIds).toHaveLength(1);
expect(
await prisma.task.findUniqueOrThrow({ where: { id: taskIds[0] } }),
).toMatchObject({
sourceAutomationTrigger: 'RAW_MATERIAL_LOW',
relatedType: 'GENERAL',
relatedId: 'PROPOLIS',
status: 'OPEN',
});
} finally {
// Leave the rule as found: restored, or gone again if this test
// created it (ensureDefaults then recreates it with its defaults).
@@ -245,30 +245,20 @@ describe('Suggestions API (e2e)', () => {
expect(other.body).toEqual([]);
});
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
await proposeForTest(app, {
it('a user without varroa:read does not get the varroa suggestion in the list', async () => {
const created = await proposeForTest(app, {
...proposal('v1'),
ruleKey: 'VARROA_THRESHOLD',
intent: 'VARROA_TREAT',
requiredPermission: 'varroa:read',
});
const hidden = await get(tokens.noVarroa, `?colonyId=${colonyId}`);
const hiddenSummary = await get(tokens.noVarroa, '/summary');
const seen = await get(tokens.full, `?colonyId=${colonyId}`);
const seenSummary = await get(tokens.full, '/summary');
expect(hidden.body).toEqual([]);
expect((hiddenSummary.body as { open: number }).open).toBe(0);
expect(seen.body).toHaveLength(1);
expect((seenSummary.body as { open: number }).open).toBeGreaterThanOrEqual(
1,
);
expect(
(seenSummary.body as { byPriority: Record<string, number> }).byPriority,
).toEqual(
expect.objectContaining({ LOW: 0, NORMAL: expect.any(Number) as number }),
);
const hidden = (await get(tokens.noVarroa, `?colonyId=${colonyId}`))
.body as ViewBody[];
const seen = (await get(tokens.full, `?colonyId=${colonyId}`))
.body as ViewBody[];
expect(hidden.map((view) => view.id)).not.toContain(created.id);
expect(seen.map((view) => view.id)).toContain(created.id);
});
it('answers 404 to a decision on a suggestion the user may not see', async () => {
+231 -7
View File
@@ -1,9 +1,14 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { SuggestionsService } from '../src/suggestions/suggestions.service';
import type { ProposeInput } from '../src/suggestions/suggestion.types';
import { berlinToday } from '../src/common/dates';
import { login } from './utils/api';
import { createTestApp } from './utils/create-test-app';
import { drainDomainEvents } from './utils/domain-events';
/**
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
@@ -17,6 +22,9 @@ describe('Suggestions (e2e)', () => {
let apiaryId: string;
let colonyId: string;
let userId: string;
const roleIds: string[] = [];
const extraUserIds: string[] = [];
const password = 'SuperSecret123!';
const proposal = (anchor: string): ProposeInput => ({
ruleKey: 'SPLIT_SCHEDULE',
source: 'CHAIN',
@@ -69,23 +77,64 @@ describe('Suggestions (e2e)', () => {
});
afterAll(async () => {
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
await clearSuggestions();
const inspectionIds = (
await prisma.inspection.findMany({
where: { colonyId },
select: { id: true },
})
).map((inspection) => inspection.id);
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: inspectionIds } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.calendarSyncOutboxEntry.deleteMany({
where: { sourceId: { in: inspectionIds } },
});
await prisma.inspection.deleteMany({ where: { colonyId } });
await prisma.colony.delete({ where: { id: colonyId } });
await prisma.apiary.delete({ where: { id: apiaryId } });
await prisma.auditLog.deleteMany({ where: { userId } });
await prisma.user.delete({ where: { id: userId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: extraUserIds } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [userId, ...extraUserIds] } },
});
await prisma.user.deleteMany({
where: { id: { in: [userId, ...extraUserIds] } },
});
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await app.close();
});
afterEach(async () => {
/** The colony's suggestions with what hangs on them: tasks, notifications, audit. */
async function clearSuggestions(): Promise<void> {
const ids = (
await prisma.suggestion.findMany({
where: { colonyId },
select: { id: true },
})
).map((suggestion) => suggestion.id);
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: ids } },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: ids } },
{
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: colonyId },
},
],
},
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
}
afterEach(clearSuggestions);
it('proposes once per anchor and writes an audit entry', async () => {
const created = await propose(proposal('a1'));
@@ -412,4 +461,179 @@ describe('Suggestions (e2e)', () => {
expect(obsolete.status).toBe('OBSOLETE');
expect(obsolete.dedupeKey).not.toBeNull();
});
describe('rule suggestions', () => {
let writer: { id: string; token: string };
let withoutInspections: { id: string };
let withoutTasks: { id: string };
async function createUser(
label: string,
permissions: string[],
): Promise<{ id: string; token: string }> {
const role = await prisma.role.create({
data: {
key: `SUG_RULES_${label.toUpperCase()}_${suffix}`,
name: `Suggestions rules ${label}`,
permissions: {
create: permissions.map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
const email = `suggestions-rules-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Suggestions rules ${label}`,
role: { connect: { id: role.id } },
},
});
extraUserIds.push(user.id);
return {
id: user.id,
token: (await login(app, email, password)).accessToken,
};
}
beforeAll(async () => {
writer = await createUser('writer', [
'inspections:create',
'inspections:read',
'tasks:read',
]);
withoutInspections = await createUser('noinsp', ['tasks:read']);
withoutTasks = await createUser('notasks', ['inspections:read']);
});
/** Records an inspection and runs the handlers of its event. */
async function inspect(body: object): Promise<string> {
const response = await request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${writer.token}`)
.send({ colonyId, date: new Date().toISOString(), ...body })
.expect(201);
const id = (response.body as { id: string }).id;
await drainDomainEvents(app, [id]);
return id;
}
const queenless = { queenSeen: false, eggsPresent: false };
const ruleSuggestions = () =>
prisma.suggestion.findMany({
where: { colonyId, ruleKey: 'QUEENLESS_NO_EGGS' },
});
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', async () => {
const inspectionId = await inspect(queenless);
const suggestions = await ruleSuggestions();
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
status: 'OPEN',
source: 'RULE',
mode: 'PLAN',
intent: 'QUEEN_CHECK',
dedupeKey: `QUEENLESS_NO_EGGS:${colonyId}:${inspectionId}`,
});
expect(await prisma.task.count({ where: { relatedId: colonyId } })).toBe(
0,
);
});
it('a dismissed suggestion is not proposed again for the same inspection', async () => {
const inspectionId = await inspect(queenless);
const [first] = await ruleSuggestions();
await service.dismiss(first.id, null, userId);
// The delivery runs again, as after a retry or a catch-up.
await prisma.domainEvent.updateMany({
where: { aggregateId: inspectionId },
data: { status: 'PENDING', processedAt: null, claimedAt: null },
});
await prisma.domainEventDelivery.updateMany({
where: {
handler: 'automation.inspection-rules',
event: { aggregateId: inspectionId },
},
data: { status: 'PENDING', attempts: 0, completedAt: null },
});
await drainDomainEvents(app, [inspectionId]);
const suggestions = await ruleSuggestions();
expect(suggestions).toHaveLength(1);
expect(suggestions[0].status).toBe('DISMISSED');
});
it('a later inspection with the same finding proposes again', async () => {
await inspect(queenless);
const [first] = await ruleSuggestions();
await service.dismiss(first.id, null, userId);
await inspect(queenless);
const suggestions = await ruleSuggestions();
expect(suggestions.map((suggestion) => suggestion.status).sort()).toEqual(
['DISMISSED', 'OPEN'],
);
});
it('accepting a rule suggestion without dueInDays creates a task due today', async () => {
await inspect(queenless);
const [open] = await ruleSuggestions();
expect(open.dueDate).toBeNull();
const result = await service.accept(open.id, {}, userId);
expect(result.tasks).toHaveLength(1);
expect(result.tasks[0].dueDate?.toISOString().slice(0, 10)).toBe(
berlinToday(),
);
expect(result.tasks[0]).toMatchObject({
sourceSuggestionId: open.id,
sourceAutomationTrigger: 'QUEENLESS_NO_EGGS',
priority: 'HIGH',
});
});
it('a chain suggestion proposed with notify true notifies like a rule suggestion', async () => {
const created = (await prisma.$transaction((tx) =>
service.proposeIn(tx, {
...proposal('notify-chain'),
notify: true,
requiredPermission: 'colonies:read',
}),
))!;
const rows = await prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: created.id },
});
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.category === 'SUGGESTION')).toBe(true);
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
expect(rows.every((row) => row.link === `/colonies/${colonyId}`)).toBe(
true,
);
});
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', async () => {
await inspect(queenless);
const [open] = await ruleSuggestions();
const rows = await prisma.notification.findMany({
where: { relatedType: 'Suggestion', relatedId: open.id },
});
const recipients = new Set(rows.map((row) => row.userId));
expect(recipients.has(writer.id)).toBe(true);
expect(recipients.has(withoutInspections.id)).toBe(false);
expect(recipients.has(withoutTasks.id)).toBe(false);
expect(rows.every((row) => row.priority === 'WARNUNG')).toBe(true);
expect(
rows.every((row) => row.message.startsWith(`SUG-${suffix}: `)),
).toBe(true);
});
});
});
@@ -89,6 +89,7 @@ describe('Treatments (e2e)', () => {
where: { aggregateType: 'Treatment', aggregateId: { in: treatmentIds } },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
await prisma.suggestion.deleteMany({ where: { colonyId } });
await prisma.colony.deleteMany({ where: { id: colonyId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
@@ -482,6 +483,9 @@ describe('Treatments (e2e)', () => {
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { id: { in: colonyIds } } });
await prisma.apiary.deleteMany({ where: { id: secondApiaryId } });
await prisma.idempotencyRecord.deleteMany({
@@ -5,6 +5,7 @@ import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
interface BandBody {
yellowFrom: number | null;
@@ -41,6 +42,9 @@ describe('Varroa seasonal bands (e2e)', () => {
let apiaryId: string;
let productionColonyId: string;
let youngColonyId: string;
// Colonies of the rule cases: one per case, so no open suggestion of an
// earlier case keeps a later one from appearing.
const ruleColonyIds: string[] = [];
const get = (path: string) =>
request(app.getHttpServer())
@@ -80,7 +84,39 @@ describe('Varroa seasonal bands (e2e)', () => {
});
afterAll(async () => {
const colonyIds = [productionColonyId, youngColonyId];
const colonyIds = [productionColonyId, youngColonyId, ...ruleColonyIds];
const measurementIds = (
await prisma.varroaMeasurement.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((measurement) => measurement.id);
const suggestionIds = (
await prisma.suggestion.findMany({
where: { colonyId: { in: colonyIds } },
select: { id: true },
})
).map((suggestion) => suggestion.id);
await prisma.notification.deleteMany({
where: { relatedType: 'Suggestion', relatedId: { in: suggestionIds } },
});
await prisma.auditLog.deleteMany({
where: {
OR: [
{ entityType: 'Suggestion', entityId: { in: suggestionIds } },
...colonyIds.map((id) => ({
entityType: 'AutomationRule',
afterData: { path: ['relatedId'], equals: id },
})),
],
},
});
await prisma.suggestion.deleteMany({
where: { colonyId: { in: colonyIds } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { in: measurementIds } },
});
await prisma.treatment.deleteMany({
where: { colonyId: { in: colonyIds } },
});
@@ -137,6 +173,53 @@ describe('Varroa seasonal bands (e2e)', () => {
await get('/varroa-measurements/thresholds?date=10.08.2026').expect(400);
});
describe('range form', () => {
interface SegmentsBody {
segments: {
from: string;
to: string;
bands: Record<string, unknown>;
postTreatmentUntil: string | null;
active: boolean;
}[];
}
const range = (query: string) =>
get(`/varroa-measurements/thresholds?${query}`);
it('returns merged segments split at the season edges', async () => {
const body = (
await range(
`colonyId=${productionColonyId}&from=2026-10-30&to=2026-11-02`,
).expect(200)
).body as SegmentsBody;
expect(body.segments.map((s) => [s.from, s.to, s.active])).toEqual([
['2026-10-30', '2026-10-31', true],
['2026-11-01', '2026-11-02', true],
]);
expect(body.segments[0].bands.NATURAL_MITE_FALL).toMatchObject({
reason: 'NATURAL_FALL_SEASON',
});
expect(body.segments[1].bands.NATURAL_MITE_FALL).toMatchObject({
reason: 'NATURAL_FALL_WINTER',
});
});
it('accepts 731 days and rejects 732', async () => {
await range('from=2025-01-01&to=2027-01-01').expect(200);
await range('from=2025-01-01&to=2027-01-02').expect(400);
});
it('rejects from after to, a missing bound, a mix with date and a bad day', async () => {
await range('from=2026-06-02&to=2026-06-01').expect(400);
await range('from=2026-06-02').expect(400);
await range('to=2026-06-02').expect(400);
await range('date=2026-06-01&from=2026-06-01&to=2026-06-02').expect(400);
await range('from=2026-02-30&to=2026-03-05').expect(400);
await range('colonyId=unknown&from=2026-06-01&to=2026-06-02').expect(404);
});
});
it('the old /threshold route is gone (404)', async () => {
await get('/varroa-measurements/threshold').expect(404);
});
@@ -221,4 +304,87 @@ describe('Varroa seasonal bands (e2e)', () => {
postTreatmentUntil: '2026-08-16',
});
});
describe('the varroa rule', () => {
const newColony = async (): Promise<string> => {
const colony = await prisma.colony.create({
data: {
code: `VS-R-${suffix}-${ruleColonyIds.length}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
ruleColonyIds.push(colony.id);
return colony.id;
};
/** Records a measurement and runs the handlers of its event. */
const measure = async (
colonyId: string,
date: string,
miteCount: number,
): Promise<string> => {
const created = (
await request(app.getHttpServer())
.post('/api/v1/varroa-measurements')
.set('Authorization', `Bearer ${token}`)
.send({
colonyId,
date,
method: 'NATURAL_MITE_FALL',
miteCount,
// One day on the board: the count is the rate per day.
durationMinutes: 24 * 60,
})
.expect(201)
).body as { id: string };
await drainDomainEvents(app, [created.id]);
return created.id;
};
const ruleSuggestions = (colonyId: string) =>
prisma.suggestion.findMany({
where: { colonyId, ruleKey: 'VARROA_THRESHOLD' },
});
it('12 mites/day in August proposes a treatment check', async () => {
const colonyId = await newColony();
const id = await measure(colonyId, '2026-08-10T00:00:00.000Z', 12);
const suggestions = await ruleSuggestions(colonyId);
expect(suggestions).toHaveLength(1);
expect(suggestions[0]).toMatchObject({
intent: 'VARROA_TREAT',
reasonCode: 'VARROA_RED',
status: 'OPEN',
dedupeKey: `VARROA_THRESHOLD:${colonyId}:${id}`,
evidence: { value: 12, redAbove: 10, unit: 'MITES_PER_DAY' },
});
});
it('12 mites/day five days after treatment end proposes nothing', async () => {
const colonyId = await newColony();
await prisma.treatment.create({
data: {
colonyId,
userId,
date: new Date('2026-08-01T00:00:00.000Z'),
endDate: new Date('2026-08-05T00:00:00.000Z'),
product: 'Oxuvar',
},
});
await measure(colonyId, '2026-08-10T00:00:00.000Z', 12);
expect(await ruleSuggestions(colonyId)).toHaveLength(0);
});
it('10.0 mites/day in August proposes nothing (yellow)', async () => {
const colonyId = await newColony();
await measure(colonyId, '2026-08-10T00:00:00.000Z', 10);
expect(await ruleSuggestions(colonyId)).toHaveLength(0);
});
});
});
@@ -2,12 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ColonyVarroaCard } from "./colony-varroa";
import { fetchColonyVarroaMeasurements, fetchVarroaThreshold } from "@/lib/varroa/api";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
import type { VarroaBand, VarroaRating, VarroaThresholdSegments } from "@/lib/varroa/bands";
import type { VarroaMeasurement, VarroaMethod } from "@/lib/varroa/types";
vi.mock("@/lib/varroa/api", () => ({
fetchColonyVarroaMeasurements: vi.fn(),
fetchVarroaThreshold: vi.fn(),
fetchVarroaThresholdRange: vi.fn(),
}));
vi.mock("./varroa-edit-dialog", () => ({ VarroaEditDialog: () => null }));
@@ -16,6 +17,7 @@ function measurement(
date: string,
method: VarroaMethod,
calculatedValue: number | null,
rating: VarroaRating | null = null,
): VarroaMeasurement {
return {
id,
@@ -25,13 +27,39 @@ function measurement(
method,
durationMinutes: 4320,
miteCount: 12,
sampleBees: null,
calculatedValue,
assessment: null,
notes: null,
createdAt: `${date}T00:00:00.000Z`,
rating,
};
}
const SEASON: VarroaBand = {
method: "NATURAL_MITE_FALL",
unit: "MITES_PER_DAY",
yellowFrom: 5,
redAbove: 10,
reason: "NATURAL_FALL_SEASON",
youngColony: false,
};
const WINTER: VarroaBand = { ...SEASON, yellowFrom: null, redAbove: 0.5, reason: "NATURAL_FALL_WINTER" };
/** Winter from 1 November, season before; one segment per side of the change inside the range. */
function segmentsFor(from: string, to: string): VarroaThresholdSegments {
const part = (start: string, end: string, band: VarroaBand) => ({
from: start,
to: end,
bands: { NATURAL_MITE_FALL: band },
postTreatmentUntil: null,
active: true,
});
if (to < "2026-11-01") return { segments: [part(from, to, SEASON)] };
if (from >= "2026-11-01") return { segments: [part(from, to, WINTER)] };
return { segments: [part(from, "2026-10-31", SEASON), part("2026-11-01", to, WINTER)] };
}
const MEASUREMENTS = [
measurement("a", "2026-06-01", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-06-08", "POWDERED_SUGAR", 99),
@@ -50,7 +78,10 @@ function renderCard() {
describe("ColonyVarroaCard trend", () => {
beforeEach(() => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue(MEASUREMENTS);
vi.mocked(fetchVarroaThreshold).mockResolvedValue({ thresholdMitesPerDay: 10 });
vi.mocked(fetchVarroaThresholdRange).mockReset();
vi.mocked(fetchVarroaThresholdRange).mockImplementation(async (_colonyId, from, to) =>
segmentsFor(from, to),
);
});
it("the chart ignores powdered sugar counts", async () => {
@@ -59,18 +90,62 @@ describe("ColonyVarroaCard trend", () => {
expect(container.querySelectorAll("svg circle")).toHaveLength(2);
});
it("draws the threshold line", async () => {
it("the chart draws the band as a monthly step line with an accessible summary", async () => {
const { container } = renderCard();
expect(await screen.findByText("Schwelle 10/Tag")).toBeInTheDocument();
expect(container.querySelector("svg line[stroke-dasharray]")).not.toBeNull();
expect(await screen.findByText("Grenze 10/Tag")).toBeInTheDocument();
const red = container.querySelector("svg path[data-band='red']");
expect(red).not.toBeNull();
expect(container.querySelector("svg path[data-band='yellow']")).not.toBeNull();
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-06-01", "2026-06-30");
const chart = screen.getByRole("img");
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Grenze 10\/Tag/);
expect(chart).toHaveAccessibleDescription(/2 Messungen.*Rote Grenze je Monat: Juni 10/);
});
it("has an accessible summary", async () => {
it("steps down in winter and leaves a gap where there is no yellow stage", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2026-10-20", "NATURAL_MITE_FALL", 4),
measurement("c", "2026-11-20", "NATURAL_MITE_FALL", 0.2),
]);
const { container } = renderCard();
await screen.findByText("Grenze 0,5/Tag");
const red = container.querySelector("svg path[data-band='red']")?.getAttribute("d") ?? "";
expect(red).toMatch(/^M[\d.]+,[\d.]+ H[\d.]+ V[\d.]+ H[\d.]+$/);
const yellow = container.querySelector("svg path[data-band='yellow']")?.getAttribute("d") ?? "";
expect(yellow.match(/M/g)).toHaveLength(1);
expect(yellow).not.toContain("V");
expect(screen.getByRole("img")).toHaveAccessibleDescription(/Oktober 10, November 0,5/);
});
it("asks once for a 24-month trend", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2024-08-10", "NATURAL_MITE_FALL", 4),
measurement("b", "2026-07-20", "NATURAL_MITE_FALL", 6),
]);
renderCard();
await screen.findByText("Schwelle 10/Tag");
const chart = screen.getByRole("img");
expect(chart).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026, Schwelle 10\/Tag/);
expect(chart).toHaveAccessibleDescription(/2 Messungen/);
await screen.findByText("Grenze 10/Tag");
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledTimes(1);
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2024-08-01", "2026-07-31");
});
it("rates a late-evening UTC measurement on its Berlin day and month", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
{ ...measurement("a", "2026-08-31", "NATURAL_MITE_FALL", 4), date: "2026-08-31T22:30:00.000Z" },
measurement("b", "2026-09-10", "NATURAL_MITE_FALL", 6),
]);
renderCard();
await screen.findByText("Grenze 10/Tag");
expect(vi.mocked(fetchVarroaThresholdRange)).toHaveBeenCalledWith("c-1", "2026-09-01", "2026-09-30");
expect(screen.getByRole("img")).toHaveAccessibleDescription(/2 Messungen von 01.09.2026 bis 10.09.2026/);
});
it("draws no band while the thresholds are unknown", async () => {
vi.mocked(fetchVarroaThresholdRange).mockRejectedValue(new Error("offline"));
const { container } = renderCard();
await screen.findByRole("img");
expect(container.querySelector("svg path[data-band]")).toBeNull();
expect(screen.getByRole("img")).toHaveAccessibleName(/Letzte Messung 8,0 Milben\/Tag am 15\.06\.2026$/);
});
it("shows only the value as text with fewer than two natural-fall points", async () => {
@@ -90,4 +165,27 @@ describe("ColonyVarroaCard trend", () => {
await screen.findByRole("img");
expect(container.querySelectorAll("svg circle")).toHaveLength(3);
});
it("names the stored rating of each measurement in words", async () => {
vi.mocked(fetchColonyVarroaMeasurements).mockResolvedValue([
measurement("a", "2026-08-01", "NATURAL_MITE_FALL", 12.4, {
light: "red",
reasonCode: "NATURAL_FALL_SEASON",
band: SEASON,
postTreatmentUntil: null,
}),
measurement("b", "2026-08-10", "NATURAL_MITE_FALL", 8, {
light: "POST_TREATMENT",
reasonCode: "POST_TREATMENT",
band: null,
postTreatmentUntil: "2026-08-16",
}),
{ ...measurement("c", "2026-08-12", "POWDERED_SUGAR", 2.4), sampleBees: 500 },
]);
renderCard();
expect(await screen.findByText("Kritisch (Grenze 10, LWG)")).toBeInTheDocument();
expect(screen.getByText("Behandlungseffekt – Nachkontrolle ab 16.08.")).toBeInTheDocument();
expect(screen.getByText(/Probe 500 Bienen · 2,4 %/)).toBeInTheDocument();
expect(screen.getByText(/12 Milben · 12,4 Milben\/Tag/)).toBeInTheDocument();
});
});
@@ -4,11 +4,13 @@ import Link from "next/link";
import { useId } from "react";
import { Bug } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { fetchColonyVarroaMeasurements } from "@/lib/varroa/api";
import { fetchColonyVarroaMeasurements, fetchVarroaThresholdRange } from "@/lib/varroa/api";
import { VarroaBand, ratingNote, segmentAt, valueText } from "@/lib/varroa/bands";
import { VarroaMeasurement } from "@/lib/varroa/types";
import { formatMitesPerDay, formatThreshold } from "@/lib/varroa/format";
import { useVarroaThreshold } from "@/lib/varroa/use-varroa-threshold";
import { berlinCalendarDay } from "@/lib/dates";
import { VARROA_METHOD_LABELS } from "@/lib/varroa/labels";
import { VarroaNoteLabel } from "@/components/varroa/varroa-rate-light";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { QueryState } from "@/components/query-state";
@@ -23,42 +25,147 @@ const PAD_BOTTOM = 12;
const MARKER_RADIUS = 4;
const LABEL_GAP = 4;
const MAX_BAND_MONTHS = 24;
const ONE_HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
interface TrendPoint {
id: string;
time: number;
rate: number;
}
/** The band of the natural mite fall in force during one calendar month. */
interface MonthLimit {
/** First day of the month, UTC, in ms. */
start: number;
/** First day of the next month, UTC, in ms. */
end: number;
band: VarroaBand | null;
}
interface TimeRange {
from: number;
to: number;
}
type LimitKey = "redAbove" | "yellowFrom";
const DAY_FORMAT = new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const MONTH_FORMAT = new Intl.DateTimeFormat("de-DE", { timeZone: "UTC", month: "long" });
function formatDate(time: number): string {
return DAY_FORMAT.format(new Date(time));
}
/** Only the natural mite fall is a rate per day; other methods are not comparable. */
/**
* Only the natural mite fall is a rate per day; other methods are not comparable.
* A point sits at midnight UTC of its Berlin calendar day, the day the backend rates it on.
*/
function trendPoints(measurements: VarroaMeasurement[]): TrendPoint[] {
return measurements
.filter((m) => m.method === "NATURAL_MITE_FALL" && m.calculatedValue !== null)
.map((m) => ({ id: m.id, time: new Date(m.date).getTime(), rate: m.calculatedValue as number }))
.map((m) => ({ id: m.id, time: Date.parse(`${berlinCalendarDay(m.date)}T00:00:00Z`), rate: m.calculatedValue as number }))
.sort((left, right) => left.time - right.time);
}
function summaryOf(last: TrendPoint, threshold: number | null): string {
function nextMonthStart(start: number): number {
const date = new Date(start);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
}
/** The first days of the months the points span, the latest MAX_BAND_MONTHS at most. */
function monthStarts(points: TrendPoint[]): number[] {
if (points.length === 0) return [];
const first = new Date(points[0].time);
const last = new Date(points[points.length - 1].time);
const stop = Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), 1);
const starts: number[] = [];
let cursor = Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), 1);
while (cursor <= stop) {
starts.push(cursor);
cursor = nextMonthStart(cursor);
}
return starts.slice(-MAX_BAND_MONTHS);
}
function isoDay(time: number): string {
return new Date(time).toISOString().slice(0, 10);
}
/** The natural-fall band of each month of the trend: one request for the whole range. */
function useMonthLimits(colonyId: string, points: TrendPoint[]): MonthLimit[] {
const starts = monthStarts(points);
const from = starts.length > 0 ? isoDay(starts[0]) : "";
const to = starts.length > 0 ? isoDay(nextMonthStart(starts[starts.length - 1]) - DAY_MS) : "";
const query = useQuery({
queryKey: ["varroa", "thresholds", colonyId, from, to],
queryFn: () => fetchVarroaThresholdRange(colonyId, from, to),
enabled: starts.length > 0,
staleTime: ONE_HOUR_MS,
});
const segments = query.data?.segments ?? [];
return starts.map((start) => ({
start,
end: nextMonthStart(start),
band: segmentAt(segments, isoDay(start))?.bands.NATURAL_MITE_FALL ?? null,
}));
}
/**
* A step line through the monthly limits: flat inside a month, a vertical
* step at the month change, a gap where a month has no such limit.
*/
function stepPath(
limits: MonthLimit[],
key: LimitKey,
xOf: (time: number) => number,
yOf: (value: number) => number,
range: TimeRange,
): string {
const parts: string[] = [];
let open = false;
for (const { start, end, band } of limits) {
const value = band ? band[key] : null;
const from = Math.max(start, range.from);
const to = Math.min(end, range.to);
if (value === null || to < from) {
open = false;
continue;
}
const y = yOf(value);
parts.push(open ? `V${y} H${xOf(to)}` : `M${xOf(from)},${y} H${xOf(to)}`);
open = true;
}
return parts.join(" ");
}
function monthLimitsText(limits: MonthLimit[], range: TimeRange): string {
return limits
.filter(({ band, start, end }) => band && end > range.from && start <= range.to)
.map(
({ start, band }) =>
`${MONTH_FORMAT.format(new Date(start))} ${formatThreshold((band as VarroaBand).redAbove)}`,
)
.join(", ");
}
function summaryOf(last: TrendPoint, lastLimit: VarroaBand | null): string {
const base = `Letzte Messung ${formatMitesPerDay(last.rate)} Milben/Tag am ${formatDate(last.time)}`;
return threshold === null ? base : `${base}, Schwelle ${formatThreshold(threshold)}/Tag`;
return lastLimit ? `${base}, Grenze ${formatThreshold(lastLimit.redAbove)}/Tag` : base;
}
function VarroaTrendChart({
points,
threshold,
limits,
}: {
points: TrendPoint[];
threshold: number | null;
limits: MonthLimit[];
}) {
const titleId = useId();
const descId = useId();
@@ -66,19 +173,26 @@ function VarroaTrendChart({
if (!last) {
return null;
}
const summary = summaryOf(last, threshold);
const lastLimit =
limits.find(({ start, end }) => last.time >= start && last.time < end)?.band ?? null;
const summary = summaryOf(last, lastLimit);
if (points.length < 2) {
return <p className="font-medium">{summary}</p>;
}
const first = points[0];
const yMax = Math.max(...points.map((p) => p.rate), threshold ?? 0, 1);
const range = { from: first.time, to: last.time };
const limitValues = limits.flatMap(({ band }) => (band ? [band.redAbove] : []));
const yMax = Math.max(...points.map((p) => p.rate), ...limitValues, 1);
const timeRange = last.time - first.time || 1;
const usableWidth = CHART_WIDTH - PAD_X * 2;
const usableHeight = CHART_HEIGHT - PAD_TOP - PAD_BOTTOM;
const xOf = (time: number) => PAD_X + ((time - first.time) / timeRange) * usableWidth;
const yOf = (rate: number) => PAD_TOP + usableHeight - (rate / yMax) * usableHeight;
const line = points.map((p) => `${xOf(p.time)},${yOf(p.rate)}`).join(" ");
const redPath = stepPath(limits, "redAbove", xOf, yOf, range);
const yellowPath = stepPath(limits, "yellowFrom", xOf, yOf, range);
const monthly = monthLimitsText(limits, range);
return (
<svg
@@ -90,29 +204,30 @@ function VarroaTrendChart({
>
<title id={titleId}>{`Milbenfall pro Tag. ${summary}`}</title>
<desc id={descId}>
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}`}
{`${points.length} Messungen von ${formatDate(first.time)} bis ${formatDate(last.time)}. ${summary}.${
monthly ? ` Rote Grenze je Monat: ${monthly}.` : ""
}`}
</desc>
{threshold !== null ? (
<g className="text-muted-foreground">
<line
x1={PAD_X}
x2={CHART_WIDTH - PAD_X}
y1={yOf(threshold)}
y2={yOf(threshold)}
stroke="currentColor"
strokeWidth={1.5}
strokeDasharray="6 4"
/>
{redPath || yellowPath ? (
<g className="text-muted-foreground" fill="none" stroke="currentColor">
{yellowPath ? (
<path data-band="yellow" d={yellowPath} strokeWidth={1} strokeDasharray="2 3" />
) : null}
{redPath ? (
<path data-band="red" d={redPath} strokeWidth={1.5} strokeDasharray="6 4" />
) : null}
</g>
) : null}
{lastLimit ? (
<text
x={CHART_WIDTH - PAD_X}
y={yOf(threshold) - LABEL_GAP}
y={yOf(lastLimit.redAbove) - LABEL_GAP}
textAnchor="end"
fontSize={10}
fill="currentColor"
className="fill-muted-foreground"
>
{`Schwelle ${formatThreshold(threshold)}/Tag`}
{`Grenze ${formatThreshold(lastLimit.redAbove)}/Tag`}
</text>
</g>
) : null}
<polyline points={line} fill="none" stroke="currentColor" strokeWidth={2} />
{points.map((p) => (
@@ -130,13 +245,28 @@ function VarroaTrendChart({
);
}
function MeasurementDetails({ measurement }: { measurement: VarroaMeasurement }) {
const note = ratingNote(measurement.rating);
return (
<>
<p className="text-muted-foreground">
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
{measurement.sampleBees !== null ? ` · Probe ${measurement.sampleBees} Bienen` : ""}
{measurement.calculatedValue !== null
? ` · ${valueText(measurement.method, measurement.calculatedValue, formatMitesPerDay)}`
: ""}
</p>
{note ? <VarroaNoteLabel note={note} className="mt-2" /> : null}
</>
);
}
export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
const measurementsQuery = useQuery({
queryKey: ["colonies", colonyId, "varroa-measurements"],
queryFn: () => fetchColonyVarroaMeasurements(colonyId),
});
const threshold = useVarroaThreshold();
const limits = useMonthLimits(colonyId, trendPoints(measurementsQuery.data ?? []));
return (
<Card>
@@ -165,7 +295,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
>
{(measurements) => (
<>
<VarroaTrendChart points={trendPoints(measurements)} threshold={threshold} />
<VarroaTrendChart points={trendPoints(measurements)} limits={limits} />
{measurements.map((measurement) => (
<div key={measurement.id} className="rounded-md border p-3">
<div className="flex items-center justify-between">
@@ -175,12 +305,7 @@ export function ColonyVarroaCard({ colonyId }: { colonyId: string }) {
</p>
<VarroaEditDialog measurement={measurement} />
</div>
<p className="text-muted-foreground">
{measurement.miteCount !== null ? `${measurement.miteCount} Milben` : "—"}
{measurement.calculatedValue !== null
? ` · ${measurement.calculatedValue.toFixed(1)} Milben/Tag`
: ""}
</p>
<MeasurementDetails measurement={measurement} />
{measurement.assessment ? <p>Einschätzung: {measurement.assessment}</p> : null}
</div>
))}
@@ -3,6 +3,7 @@
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTreatment } from "@/lib/treatments/api";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { Treatment } from "@/lib/treatments/types";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { errorMessage } from "@/lib/forms/error-message";
@@ -81,6 +82,8 @@ export function TreatmentEditDialog({ treatment }: { treatment: Treatment }) {
queryKey: ["colonies", affectedColonyId, "treatments"],
});
}
// A treatment moves the post-treatment window of the varroa lights.
void invalidateVarroaBands(queryClient);
setOpen(false);
},
onError: (error) =>
@@ -18,6 +18,7 @@ const measurement: VarroaMeasurement = {
method: "NATURAL_MITE_FALL",
durationMinutes: 4320,
miteCount: 12,
sampleBees: null,
calculatedValue: 4,
assessment: "mittel",
notes: "Windel verrutscht",
@@ -90,11 +91,46 @@ describe("VarroaEditDialog", () => {
expect(input).toMatchObject({ durationMinutes: 2000 });
});
it("keeps the minutes field for other methods", async () => {
await openDialog({ ...measurement, method: "WASHING_METHOD" });
it("keeps the minutes field for the drone brood check", async () => {
await openDialog({ ...measurement, method: "DRONE_BROOD_CONTROL" });
expect(screen.getByLabelText(/^Zeitraum \(Minuten\)/)).toHaveValue(4320);
});
it("the sample methods have no minutes field, a stored value is sent back", async () => {
await openDialog({ ...measurement, method: "WASHING_METHOD", sampleBees: 300 });
expect(screen.queryByLabelText(/^Zeitraum \(Minuten\)/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ durationMinutes: 4320 });
});
it.each(["0", "2001", "2.5"])("refuses a sample of %s and sends nothing", async (bad) => {
await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300 });
const field = screen.getByLabelText(/Probe \(Bienen\)/);
await userEvent.clear(field);
await userEvent.type(field, bad);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(screen.getByText("Bitte 1 bis 2000 Bienen angeben.")).toBeInTheDocument();
expect(updateVarroaMeasurement).not.toHaveBeenCalled();
});
it("a stored sampleBees is sent back unchanged from the edit dialog", async () => {
await openDialog({ ...measurement, method: "POWDERED_SUGAR", sampleBees: 300, calculatedValue: 4 });
expect(screen.getByLabelText(/Probe \(Bienen\)/)).toHaveValue(300);
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
const [, input] = vi.mocked(updateVarroaMeasurement).mock.calls[0];
expect(input).toMatchObject({ sampleBees: 300, miteCount: 12 });
});
it("shows no sample field for the natural mite fall and sends null", async () => {
await openDialog();
expect(screen.queryByLabelText(/Probe \(Bienen\)/)).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
expect(vi.mocked(updateVarroaMeasurement).mock.calls[0][1]).toMatchObject({ sampleBees: null });
});
it("explains that a manual mites-per-day value takes precedence", async () => {
await openDialog();
expect(screen.getByText(/beim Speichern .* neu berechnet/)).toBeInTheDocument();
@@ -28,6 +28,8 @@ import {
} from "./edit-dialog-parts";
import { toApiDate } from "@/lib/dates";
import { VarroaPeriodField } from "@/components/varroa/varroa-period-field";
import { SampleBeesField } from "@/components/varroa/sample-bees-field";
import { sampleBeesError, usesSample } from "@/lib/varroa/bands";
export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasurement }) {
const queryClient = useQueryClient();
@@ -36,6 +38,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
const [date, setDate] = useState(measurement.date.slice(0, 10));
const [method, setMethod] = useState<VarroaMethod>(measurement.method);
const [miteCount, setMiteCount] = useState(measurement.miteCount?.toString() ?? "");
const [sampleBees, setSampleBees] = useState(measurement.sampleBees?.toString() ?? "");
const [durationMinutes, setDurationMinutes] = useState(
measurement.durationMinutes?.toString() ?? "",
);
@@ -45,8 +48,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
const [assessment, setAssessment] = useState(measurement.assessment ?? "");
const [notes, setNotes] = useState(measurement.notes ?? "");
const [dateError, setDateError] = useState<string | null>(null);
const [sampleError, setSampleError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const isNaturalFall = method === "NATURAL_MITE_FALL";
const hasSample = usesSample(method);
const mutation = useMutation({
mutationFn: () =>
updateVarroaMeasurement(measurement.id, {
@@ -54,6 +61,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
date: toApiDate(date),
method,
miteCount: emptyToNull(miteCount, Number),
sampleBees: hasSample ? emptyToNull(sampleBees, Number) : null,
durationMinutes: emptyToNull(durationMinutes, Number),
calculatedValue: emptyToNull(calculatedValue, Number),
assessment: emptyToNull(assessment),
@@ -73,7 +81,6 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
setSubmitError(errorMessage(error, "Varroamessung konnte nicht gespeichert werden.")),
});
const isNaturalFall = method === "NATURAL_MITE_FALL";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -82,6 +89,12 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
setDateError(DATE_REQUIRED_MESSAGE);
return;
}
const invalidSample = hasSample ? sampleBeesError(sampleBees) : null;
setSampleError(invalidSample);
if (invalidSample) {
document.querySelector<HTMLElement>('[data-field="sampleBees"]')?.focus();
return;
}
mutation.mutate();
}
@@ -147,7 +160,7 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
/>
)}
</Field>
{isNaturalFall ? null : (
{isNaturalFall || hasSample ? null : (
<Field label="Zeitraum (Minuten)">
{({ id }) => (
<Input
@@ -163,9 +176,19 @@ export function VarroaEditDialog({ measurement }: { measurement: VarroaMeasureme
</Field>
)}
</div>
{hasSample ? (
<SampleBeesField
value={sampleBees}
onChange={(next) => {
setSampleBees(next);
setSampleError(null);
}}
error={sampleError}
/>
) : null}
<Field
label="Milben pro Tag"
hint="Wird beim Speichern aus Milbenzahl und Zeitraum neu berechnet – ein eigener, geänderter Wert hat Vorrang."
label={hasSample ? "Befall (%)" : "Milben pro Tag"}
hint={`Wird beim Speichern aus Milbenzahl und ${hasSample ? "Probe" : "Zeitraum"} neu berechnet – ein eigener, geänderter Wert hat Vorrang.`}
>
{({ id }) => (
<Input
@@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
import { Menu } from "lucide-react";
import { SheetTrigger } from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { NavCounter } from "@/components/suggestions/nav-counter";
import type { AuthUser } from "@/lib/auth/auth-context";
import { BOTTOM_NAV_HREFS, isNavActive, visibleNavGroups } from "./nav-config";
import { NAV_SHEET_ROW_CLASS, NavSheet } from "@/components/nav/nav-sheet";
@@ -70,6 +71,7 @@ export function MoreSheet({ user }: { user: Pick<AuthUser, "permissions"> }) {
>
<Icon className="size-6 shrink-0" aria-hidden="true" />
{item.label}
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
</Link>
);
})}
@@ -55,4 +55,14 @@ describe("nav-config", () => {
expect(hrefs([])).not.toContain("/winter");
expect(hrefs(["winter-checks:read"])).toContain("/winter");
});
it("„Vorschläge“ needs tasks:read and shows the open count", () => {
expect(hrefs([])).not.toContain("/suggestions");
const work = visibleNavGroups({ permissions: ["tasks:read"] }).find(
(group) => group.label === "Arbeit",
);
const item = work?.items.find((entry) => entry.href === "/suggestions");
expect(item?.label).toBe("Vorschläge");
expect(item?.counter).toBe("suggestions");
});
});
@@ -11,6 +11,7 @@ import {
History,
Layers,
LayoutDashboard,
Lightbulb,
ListTodo,
type LucideIcon,
MapPin,
@@ -41,6 +42,8 @@ export interface NavItem {
// together several previously-separate settings pages, each gated by its
// own permission.
permission?: string | string[];
/** A number shown next to the label; the data comes from the matching hook. */
counter?: "suggestions";
}
export interface NavGroup {
@@ -79,6 +82,13 @@ export const NAV_GROUPS: NavGroup[] = [
label: "Arbeit",
items: [
{ label: "Aufgaben", href: "/tasks", icon: ListTodo },
{
label: "Vorschläge",
href: "/suggestions",
icon: Lightbulb,
permission: "tasks:read",
counter: "suggestions",
},
{ label: "Kalender", href: "/calendar", icon: CalendarDays },
],
},
@@ -31,6 +31,8 @@ function rule(
};
}
let lastClient: QueryClient;
function renderTab(rules: AutomationRule[]) {
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
vi.mocked(updateAutomationRule).mockImplementation(
@@ -38,6 +40,7 @@ function renderTab(rules: AutomationRule[]) {
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
lastClient = client;
return render(
<QueryClientProvider client={client}>
<AutomationRulesTab />
@@ -93,6 +96,19 @@ describe("varroa rule form", () => {
expect(config.notify).toBe(true);
});
it("saving the varroa rule marks the cached thresholds stale", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
lastClient.setQueryData(["varroa", "thresholds", "c-1", "2026-08-10"], { bands: {} });
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() =>
expect(lastClient.getQueryState(["varroa", "thresholds", "c-1", "2026-08-10"])?.isInvalidated).toBe(true),
);
});
it("a 400 issue appears under its field", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
@@ -18,6 +18,7 @@ import { RuleFormProps } from "./rule-forms/rule-form-parts";
import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form";
import { SplitScheduleForm } from "./rule-forms/split-schedule-form";
import { RuleConfigValue } from "./rule-forms/use-rule-draft";
import { invalidateVarroaBands } from "@/lib/varroa/use-varroa-bands";
import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form";
import { WaterContentForm } from "./rule-forms/water-content-form";
@@ -54,6 +55,9 @@ export function SpecialRuleCard({ rule }: { rule: AutomationRule }) {
onSuccess: () => {
setIssues({});
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
if (rule.triggerType === "VARROA_THRESHOLD") {
void invalidateVarroaBands(queryClient);
}
toast.success("Regel gespeichert");
},
onError: (error) => {
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { FileClock, Hexagon } from "lucide-react";
import { cn } from "@/lib/utils";
import { NavCounter } from "@/components/suggestions/nav-counter";
import type { AuthUser } from "@/lib/auth/auth-context";
import { isNavActive, type NavItem, visibleNavGroups } from "./nav-config";
@@ -28,6 +29,7 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
)}
/>
<span className="truncate">{item.label}</span>
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
</Link>
);
}
@@ -0,0 +1,457 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "sonner";
import { suggestionFixture } from "@/components/suggestions/suggestion-fixtures";
import { Toaster } from "@/components/ui/sonner";
import { ApiError } from "@/lib/auth/api-client";
import {
acceptSuggestion,
bulkAcceptSuggestions,
dismissSuggestion,
fetchSuggestions,
fetchSuggestionSummary,
reopenSuggestion,
} from "@/lib/suggestions/api";
import { readRound } from "@/lib/rounds/round-state";
import type { Suggestion } from "@/lib/suggestions/types";
import SuggestionsPage from "./page";
const auth = vi.hoisted(() => ({
user: { id: "user-1" } as { id: string } | null,
permissions: new Set(["tasks:read", "tasks:create", "tasks:update"]),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({
user: auth.user,
hasPermission: (permission: string) => auth.permissions.has(permission),
}),
}));
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
fetchSuggestions: vi.fn(),
fetchSuggestionSummary: vi.fn(),
acceptSuggestion: vi.fn(),
bulkAcceptSuggestions: vi.fn(),
dismissSuggestion: vi.fn(),
bulkDismissSuggestions: vi.fn(),
reopenSuggestion: vi.fn(),
}));
const OPEN: Suggestion[] = [
suggestionFixture({ id: "s1", colonyId: "c1", colonyCode: "A1", intent: "HONEY_SUPER" }),
suggestionFixture({ id: "s2", colonyId: "c2", colonyCode: "A2", intent: "HONEY_SUPER" }),
suggestionFixture({
id: "s3",
mode: "PLAN",
intent: "STOCK_REORDER",
apiaryId: null,
apiaryName: null,
colonyId: null,
colonyCode: null,
title: "Gläser nachbestellen",
reasons: [],
}),
];
const SINGLE = OPEN[2];
function mockLists(
open: Suggestion[],
options: { dismissed?: Suggestion[]; pageSize?: number; total?: number } = {},
) {
const { dismissed = [], pageSize = Number.POSITIVE_INFINITY, total = open.length } = options;
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
query?.status === "DISMISSED"
? dismissed
: open.slice(query?.offset ?? 0, (query?.offset ?? 0) + pageSize),
);
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
open: total,
byPriority: { LOW: 0, NORMAL: total, HIGH: 0, URGENT: 0 },
});
}
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={client}>
<SuggestionsPage />
<Toaster />
</QueryClientProvider>,
);
}
function manyPlan(count: number): Suggestion[] {
return Array.from({ length: count }, (_, index) =>
suggestionFixture({ id: `m${index}`, colonyId: `c${index}`, colonyCode: `B${index}` }),
);
}
// jsdom has no matchMedia; Sonner's Toaster asks for the color scheme.
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}),
});
// Sonner captures the pointer for swiping a toast.
HTMLElement.prototype.setPointerCapture = () => undefined;
HTMLElement.prototype.releasePointerCapture = () => undefined;
beforeEach(() => {
vi.clearAllMocks();
toast.dismiss();
window.sessionStorage.clear();
auth.user = { id: "user-1" };
auth.permissions = new Set(["tasks:read", "tasks:create", "tasks:update"]);
});
describe("SuggestionsPage", () => {
it("shows „3 Vorschläge“ and the explanation", async () => {
mockLists(OPEN);
renderPage();
expect(await screen.findByText("3 Vorschläge")).toBeInTheDocument();
expect(
screen.getByText("Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus."),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Hang" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Betrieb" })).toBeInTheDocument();
});
it("shows a row once when two pages overlap", async () => {
const rows = manyPlan(201);
vi.mocked(fetchSuggestions).mockImplementation(async (query) =>
query?.offset ? [rows[199], rows[200]] : rows.slice(0, 200),
);
vi.mocked(fetchSuggestionSummary).mockResolvedValue({
open: 201,
byPriority: { LOW: 0, NORMAL: 201, HIGH: 0, URGENT: 0 },
});
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
});
it("does not offer more when the last page was short", async () => {
mockLists(OPEN, { total: 5 });
renderPage();
expect(await screen.findByText("3 von 5 Vorschlägen")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
});
it("keeps the count region mounted while loading and when empty", async () => {
mockLists([]);
renderPage();
expect(screen.getByTestId("suggestion-count")).toHaveAttribute("role", "status");
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
expect(screen.getByTestId("suggestion-count")).toBeInTheDocument();
});
it("counts from the summary and loads more pages with an offset", async () => {
mockLists(manyPlan(201), { pageSize: 200 });
renderPage();
expect(await screen.findByText("200 von 201 Vorschlägen")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Mehr laden" }));
expect(await screen.findByText("201 Vorschläge")).toBeInTheDocument();
expect(fetchSuggestions).toHaveBeenCalledWith({ status: "OPEN", limit: 200, offset: 200 });
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
});
it("„Alle übernehmen“ sends one bulk-accept with the group ids", async () => {
mockLists(OPEN);
vi.mocked(bulkAcceptSuggestions).mockResolvedValue([]);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
await waitFor(() => expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(1));
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toEqual(["s1", "s2"]);
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][1]).toEqual({
idempotencyKey: expect.any(String),
});
expect(await screen.findByText("2 Aufgaben angelegt")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
});
it("reports how many were created when a later bulk chunk fails", async () => {
mockLists(manyPlan(51));
vi.mocked(bulkAcceptSuggestions)
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Alle übernehmen" }));
expect(
await screen.findByText("50 von 51 angelegt. Rest nicht übernommen – bitte erneut versuchen."),
).toBeInTheDocument();
expect(bulkAcceptSuggestions).toHaveBeenCalledTimes(2);
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[0][0]).toHaveLength(50);
expect(vi.mocked(bulkAcceptSuggestions).mock.calls[1][0]).toHaveLength(1);
const keys = vi.mocked(bulkAcceptSuggestions).mock.calls.map((call) => call[1]?.idempotencyKey);
expect(new Set(keys).size).toBe(2);
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
});
it("accepting a single suggestion says „Aufgabe angelegt“ with „Ansehen“", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Aufgabe angelegt")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ansehen" })).toBeInTheDocument();
expect(acceptSuggestion).toHaveBeenCalledWith("s3", {}, { idempotencyKey: expect.any(String) });
});
it("the due-date sheet accepts with the chosen day", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion).mockResolvedValue({ suggestion: SINGLE, tasks: [] });
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Weitere Aktionen" }));
await userEvent.click(await screen.findByRole("menuitem", { name: "Termin ändern" }));
const dialog = await screen.findByRole("dialog");
const input = within(dialog).getByLabelText("Termin");
await userEvent.clear(input);
await userEvent.type(input, "2026-06-01");
await userEvent.click(within(dialog).getByRole("button", { name: "Übernehmen" }));
await waitFor(() =>
expect(acceptSuggestion).toHaveBeenCalledWith(
"s3",
{ dueDate: "2026-06-01" },
{ idempotencyKey: expect.any(String) },
),
);
});
it("„Verwerfen“ shows „Verworfen“ and the tapped „Rückgängig“ reopens", async () => {
mockLists([SINGLE]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
vi.mocked(reopenSuggestion).mockResolvedValue(SINGLE);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
expect(await screen.findByText("Verworfen")).toBeInTheDocument();
expect(dismissSuggestion).toHaveBeenCalledWith("s3", null, {
idempotencyKey: expect.any(String),
});
const undo = screen.getByRole("button", { name: "Rückgängig" });
expect(undo.className).toContain("min-h-11");
await userEvent.click(undo);
await waitFor(() =>
expect(reopenSuggestion).toHaveBeenCalledWith("s3", { idempotencyKey: expect.any(String) }),
);
});
it("explains it when „Rückgängig“ answers 409", async () => {
mockLists([SINGLE]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
vi.mocked(reopenSuggestion).mockRejectedValue(
new ApiError(409, "Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" }));
await userEvent.click(await screen.findByRole("button", { name: "Rückgängig" }));
expect(
await screen.findByText("Für dieses Volk ist schon ein Vorschlag „Honigraum prüfen“ offen."),
).toBeInTheDocument();
});
it("explains a 409 in the server's words and reloads", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion).mockRejectedValue(
new ApiError(409, "Der Vorschlag ist nicht mehr offen."),
);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Der Vorschlag ist nicht mehr offen.")).toBeInTheDocument();
await waitFor(() => expect(vi.mocked(fetchSuggestions).mock.calls.length).toBeGreaterThan(1));
});
it("shows German texts for a 400 array, a 404 and a lost connection", async () => {
mockLists([SINGLE]);
vi.mocked(acceptSuggestion)
.mockRejectedValueOnce(new ApiError(400, "dueDate must match", { message: ["dueDate must match"] }))
.mockRejectedValueOnce(new ApiError(404, "Not Found"))
.mockRejectedValueOnce(new TypeError("Failed to fetch"));
renderPage();
const accept = await screen.findByRole("button", { name: "Übernehmen" });
await userEvent.click(accept);
expect(await screen.findByText("Die Eingabe ist ungültig.")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Der Vorschlag ist nicht mehr da.")).toBeInTheDocument();
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
expect(await screen.findByText("Keine Verbindung – nichts gespeichert.")).toBeInTheDocument();
});
it("moves focus to the next card after a decision", async () => {
mockLists([SINGLE, OPEN[0]]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
renderPage();
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
mockLists([SINGLE]);
await userEvent.click(buttons[0]);
await waitFor(() =>
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).toHaveFocus(),
);
});
it("does not move focus after a failed decision, even when the list changes", async () => {
mockLists([SINGLE, OPEN[0]]);
vi.mocked(dismissSuggestion).mockRejectedValue(new ApiError(409, "Der Vorschlag ist nicht mehr offen."));
renderPage();
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
mockLists([SINGLE]);
await userEvent.click(buttons[0]);
await screen.findByText("Der Vorschlag ist nicht mehr offen.");
await waitFor(() => expect(screen.queryByRole("heading", { name: "Honigraum prüfen" })).not.toBeInTheDocument());
expect(screen.getByTestId("suggestion-count")).not.toHaveFocus();
expect(screen.getByRole("heading", { name: "Gläser nachbestellen" })).not.toHaveFocus();
});
it("moves focus to the count region when the last card is gone", async () => {
mockLists([SINGLE]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...SINGLE, status: "DISMISSED" });
renderPage();
const dismiss = await screen.findByRole("button", { name: "Verwerfen" });
mockLists([]);
await userEvent.click(dismiss);
await waitFor(() => expect(screen.getByTestId("suggestion-count")).toHaveFocus());
});
it("focuses the restored card after „Rückgängig“", async () => {
mockLists([SINGLE, OPEN[0]]);
vi.mocked(dismissSuggestion).mockResolvedValue({ ...OPEN[0], status: "DISMISSED" });
vi.mocked(reopenSuggestion).mockResolvedValue(OPEN[0]);
renderPage();
const buttons = await screen.findAllByRole("button", { name: "Verwerfen" });
mockLists([SINGLE]);
await userEvent.click(buttons[0]);
const undo = await screen.findByRole("button", { name: "Rückgängig" });
mockLists([SINGLE, OPEN[0]]);
await userEvent.click(undo);
await waitFor(() =>
expect(screen.getByRole("heading", { name: "Honigraum prüfen" })).toHaveFocus(),
);
});
it("lists dismissed suggestions with „Wieder öffnen“", async () => {
const dismissed = suggestionFixture({
id: "d1",
status: "DISMISSED",
title: "Varroa messen",
decidedAt: "2026-05-10T08:00:00.000Z",
});
mockLists(OPEN, { dismissed: [dismissed] });
vi.mocked(reopenSuggestion).mockResolvedValue(dismissed);
renderPage();
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
const list = await screen.findByRole("list", { name: "Verworfene Vorschläge" });
expect(within(list).getByText("Varroa messen")).toBeInTheDocument();
await userEvent.click(within(list).getByRole("button", { name: "Wieder öffnen" }));
await waitFor(() =>
expect(reopenSuggestion).toHaveBeenCalledWith("d1", { idempotencyKey: expect.any(String) }),
);
});
it("shows „Keine offenen Vorschläge.“ when empty", async () => {
mockLists([]);
renderPage();
expect(await screen.findByText("Keine offenen Vorschläge.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Verworfene (30 Tage)" })).toBeInTheDocument();
});
it("shows an error with retry instead of an empty list when loading fails", async () => {
vi.mocked(fetchSuggestions).mockRejectedValue(new Error("boom"));
vi.mocked(fetchSuggestionSummary).mockRejectedValue(new Error("boom"));
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("Vorschläge konnten nicht geladen werden");
expect(screen.queryByText("Keine offenen Vorschläge.")).not.toBeInTheDocument();
});
it("„Runde starten“ starts a round with the due colonies in code order", async () => {
mockLists([
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
]);
renderPage();
const link = await screen.findByRole("link", { name: "Runde starten" });
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2&round=a1");
await userEvent.click(link);
const round = readRound("user-1");
expect(round?.apiaryId).toBe("a1");
expect(round?.apiaryName).toBe("Hang");
expect(round?.colonies.map((c) => c.code)).toEqual(["A2", "A10"]);
});
it("„Runde starten“ without a user is a plain link to the first inspection", async () => {
auth.user = null;
mockLists([
suggestionFixture({ id: "i1", mode: "DO", intent: "INSPECT", colonyId: "c10", colonyCode: "A10" }),
suggestionFixture({ id: "i2", mode: "DO", intent: "INSPECT", colonyId: "c2", colonyCode: "A2" }),
]);
renderPage();
const link = await screen.findByRole("link", { name: "Runde starten" });
expect(link).toHaveAttribute("href", "/inspections/new?colonyId=c2");
});
it("hides the buttons the user may not use", async () => {
auth.permissions = new Set(["tasks:read"]);
mockLists(OPEN);
renderPage();
await screen.findByText("3 Vorschläge");
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Alle übernehmen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Verwerfen" })).not.toBeInTheDocument();
});
});
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more