feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms

- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

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 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11207 -769

No files matched your search

+3
View File
@@ -18,3 +18,6 @@ prisma/seed.d.ts
# Locally uploaded documents/images (Modul 29) and their test-run counterpart
/uploads/
/test-uploads/
# T4 migration test harness: scratch copies of the migration folders
/.tmp-migrations/
@@ -0,0 +1,19 @@
-- T4 migration 1/2: new enum values only. PostgreSQL cannot use a value in
-- the transaction that adds it, so everything that uses them (the Suggestion
-- table, rule rows) comes in later migrations.
-- AlterEnum
ALTER TYPE "AutomationTriggerType" ADD VALUE 'INSPECTION_INTERVAL';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_CELLS_SWARM_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'QUEEN_ACCEPTANCE_CHECK';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SPLIT_SCHEDULE';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'COLONY_ENDED_CLEANUP';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'WITHDRAWAL_ENDED';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'HONEY_WATER_CONTENT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'SEASON_AUTOPILOT';
ALTER TYPE "AutomationTriggerType" ADD VALUE 'PHENOLOGY';
-- AlterEnum
ALTER TYPE "BatchMovementType" ADD VALUE 'BOTTLING';
-- AlterEnum
ALTER TYPE "CalendarSyncSourceType" ADD VALUE 'WITHDRAWAL';
@@ -0,0 +1,71 @@
-- T4 migration 2/2: suggestions (rule, chain and autopilot proposals the
-- beekeeper decides on). dedupeKey is nullable and unique: Postgres allows many
-- NULLs, so EXPIRED/RESOLVED rows release their key.
-- CreateEnum
CREATE TYPE "SuggestionStatus" AS ENUM ('OPEN', 'ACCEPTED', 'DISMISSED', 'RESOLVED', 'EXPIRED', 'OBSOLETE');
-- CreateEnum
CREATE TYPE "SuggestionMode" AS ENUM ('PLAN', 'DO');
-- CreateEnum
CREATE TYPE "SuggestionSource" AS ENUM ('RULE', 'CHAIN', 'AUTOPILOT');
-- CreateEnum
CREATE TYPE "SuggestionIntent" AS ENUM ('INSPECT', 'SWARM_CHECK', 'QUEEN_CHECK', 'QUEEN_ACCEPTANCE', 'QUEEN_REPLACE', 'SPLIT_PLAN', 'VARROA_MEASURE', 'VARROA_RECHECK', 'VARROA_TREAT', 'SUMMER_TREATMENT', 'FEED', 'HONEY_SUPER', 'WINTER_CHECK', 'HEALTH_CHECK', 'FOLLOW_UP', 'STOCK_REORDER', 'CLEANUP');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "sourceSuggestionId" TEXT;
-- CreateTable
CREATE TABLE "Suggestion" (
"id" TEXT NOT NULL,
"ruleKey" "AutomationTriggerType" NOT NULL,
"source" "SuggestionSource" NOT NULL,
"mode" "SuggestionMode" NOT NULL,
"intent" "SuggestionIntent" NOT NULL,
"status" "SuggestionStatus" NOT NULL DEFAULT 'OPEN',
"priority" "TaskPriority" NOT NULL DEFAULT 'NORMAL',
"relatedType" "TaskRelatedType" NOT NULL,
"relatedId" TEXT,
"colonyId" TEXT,
"reasonCode" TEXT NOT NULL,
"evidence" JSONB NOT NULL,
"requiredPermission" TEXT,
"dueDate" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"plan" JSONB,
"dedupeKey" TEXT,
"sourceEventId" TEXT,
"decidedAt" TIMESTAMP(3),
"decidedByUserId" TEXT,
"decisionNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Suggestion_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Suggestion_dedupeKey_key" ON "Suggestion"("dedupeKey");
-- CreateIndex
CREATE INDEX "Suggestion_status_colonyId_idx" ON "Suggestion"("status", "colonyId");
-- CreateIndex
CREATE INDEX "Suggestion_status_dueDate_idx" ON "Suggestion"("status", "dueDate");
-- CreateIndex
CREATE INDEX "Suggestion_ruleKey_relatedType_relatedId_status_idx" ON "Suggestion"("ruleKey", "relatedType", "relatedId", "status");
-- CreateIndex
CREATE INDEX "Task_sourceSuggestionId_idx" ON "Task"("sourceSuggestionId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_sourceSuggestionId_fkey" FOREIGN KEY ("sourceSuggestionId") REFERENCES "Suggestion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_decidedByUserId_fkey" FOREIGN KEY ("decidedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,34 @@
-- T4 migration 3/n: one vocabulary for the feed condition. Inspection.feedStatus
-- was free text; it becomes ColonyCondition, like the colony card. Known words
-- (ausreichend, niedrig, kritisch; trimmed of spaces, tabs and line breaks like
-- JS trim, any case) map to the enum, any other text moves to the end of
-- "notes" as 'Futter (alt): <text>'. Nothing is lost.
-- AlterTable
ALTER TABLE "Inspection" ADD COLUMN "feedCondition" "ColonyCondition";
-- Known words map to the enum.
UPDATE "Inspection"
SET "feedCondition" = CASE lower(btrim("feedStatus", E' \t\r\n'))
WHEN 'ausreichend' THEN 'GOOD'::"ColonyCondition"
WHEN 'niedrig' THEN 'WEAK'::"ColonyCondition"
WHEN 'kritisch' THEN 'CRITICAL'::"ColonyCondition"
END
WHERE "feedStatus" IS NOT NULL;
-- Every other non-empty text goes to the notes.
DO $$
DECLARE
moved integer;
BEGIN
UPDATE "Inspection"
SET "notes" = concat_ws(E'\n', NULLIF("notes", ''), 'Futter (alt): ' || btrim("feedStatus", E' \t\r\n'))
WHERE "feedCondition" IS NULL
AND btrim("feedStatus", E' \t\r\n') <> '';
GET DIAGNOSTICS moved = ROW_COUNT;
RAISE NOTICE 't4_feed_vocabulary: % free-text feed values moved into notes', moved;
END $$;
-- AlterTable
ALTER TABLE "Inspection" DROP COLUMN "feedStatus";
ALTER TABLE "Inspection" RENAME COLUMN "feedCondition" TO "feedStatus";
@@ -0,0 +1,8 @@
-- T4 migration 5/n: where the queen of a split colony comes from. Optional:
-- an observation is never filled in for old rows.
-- CreateEnum
CREATE TYPE "SplitQueenSource" AS ENUM ('RAISE_OWN', 'QUEEN_CELL', 'QUEEN');
-- AlterTable
ALTER TABLE "ColonyLineage" ADD COLUMN "queenSource" "SplitQueenSource";
@@ -0,0 +1,5 @@
-- T4 migration 7/n (part sampleBees): number of bees in a powdered-sugar or
-- washing sample, so that the infestation percentage can be computed.
-- AlterTable
ALTER TABLE "VarroaMeasurement" ADD COLUMN "sampleBees" INTEGER;
+110 -1
View File
@@ -115,6 +115,7 @@ model User {
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
decidedSuggestions Suggestion[] @relation("SuggestionDecider")
assignedTaskTemplates TaskTemplate[] @relation("TaskTemplateAssignee")
createdTaskTemplates TaskTemplate[] @relation("TaskTemplateCreator")
colonyMoves ColonyApiaryHistory[]
@@ -312,6 +313,7 @@ model Colony {
feedings Feeding[]
harvests HarvestColony[]
queenEvaluations QueenEvaluation[]
suggestions Suggestion[]
@@index([apiaryId])
@@index([status])
@@ -340,6 +342,12 @@ enum LineageType {
// Ableger bilden (SPLIT: from=Muttervolk, to=Ableger) und Völker vereinigen
// (MERGE: mehrere from-Zeilen auf denselben to=Zielvolk) – Abschnitt 4.4.
enum SplitQueenSource {
RAISE_OWN
QUEEN_CELL
QUEEN
}
model ColonyLineage {
id String @id @default(cuid())
type LineageType
@@ -348,6 +356,7 @@ model ColonyLineage {
toColonyId String
toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id])
date DateTime
queenSource SplitQueenSource?
notes String?
createdAt DateTime @default(now())
@@ -467,7 +476,7 @@ model Inspection {
swarmMood Int?
gentleness Int?
combBehavior Int?
feedStatus String?
feedStatus ColonyCondition?
honeySuperStatus String?
healthIssues String?
varroaIssues String?
@@ -529,12 +538,99 @@ model Task {
// rule see that it already left an open task for the same object instead
// of piling up a duplicate on every further trigger.
sourceAutomationTrigger AutomationTriggerType?
// Set when the task was created by accepting a suggestion.
sourceSuggestionId String?
sourceSuggestion Suggestion? @relation(fields: [sourceSuggestionId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
completedAt DateTime?
@@index([status, dueDate])
@@index([relatedType, relatedId])
@@index([sourceAutomationTrigger, relatedType, relatedId, status])
@@index([sourceSuggestionId])
}
// ---------------------------------------------------------------------------
// Vorschlaege (T4): Regeln, Ketten und der Saison-Autopilot schlagen vor, der
// Imker entscheidet. Uebernehmen legt eine Aufgabe an (Task.sourceSuggestionId).
// ---------------------------------------------------------------------------
enum SuggestionStatus {
OPEN
ACCEPTED
DISMISSED
RESOLVED
EXPIRED
OBSOLETE
}
enum SuggestionMode {
PLAN
DO
}
enum SuggestionSource {
RULE
CHAIN
AUTOPILOT
}
enum SuggestionIntent {
INSPECT
SWARM_CHECK
QUEEN_CHECK
QUEEN_ACCEPTANCE
QUEEN_REPLACE
SPLIT_PLAN
VARROA_MEASURE
VARROA_RECHECK
VARROA_TREAT
SUMMER_TREATMENT
FEED
HONEY_SUPER
WINTER_CHECK
HEALTH_CHECK
FOLLOW_UP
STOCK_REORDER
CLEANUP
}
model Suggestion {
id String @id @default(cuid())
ruleKey AutomationTriggerType
source SuggestionSource
mode SuggestionMode
intent SuggestionIntent
status SuggestionStatus @default(OPEN)
priority TaskPriority @default(NORMAL)
relatedType TaskRelatedType
relatedId String?
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
reasonCode String
// Only IDs, numbers and YYYY-MM-DD.
evidence Json
// e.g. 'varroa:read'; without that permission the suggestion is invisible.
requiredPermission String?
dueDate DateTime?
expiresAt DateTime?
// PLAN with several dates: [{ step, offsetDays, windowDays? }]
plan Json?
// '<ruleKey>:<relatedId|none>:<anchor>'; EXPIRED/RESOLVED clear it.
dedupeKey String? @unique
sourceEventId String?
decidedAt DateTime?
decidedByUserId String?
decidedByUser User? @relation("SuggestionDecider", fields: [decidedByUserId], references: [id])
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@index([status, colonyId])
@@index([status, dueDate])
@@index([ruleKey, relatedType, relatedId, status])
}
enum TaskTemplateRecurrenceType {
@@ -589,6 +685,7 @@ model VarroaMeasurement {
method VarroaMethod
durationMinutes Int?
miteCount Int?
sampleBees Int?
calculatedValue Float?
assessment String?
notes String?
@@ -1172,6 +1269,8 @@ enum BatchMovementType {
// Rohstoff in einer Herstellungscharge verbraucht (minus) bzw. beim
// Loeschen einer irrtuemlich angelegten Charge zurueckgegeben (plus).
CONSUMPTION
// Abfuellung entnimmt Honig aus der Charge (Task 23: Bewegungsbuch)
BOTTLING
}
enum WithdrawalReason {
@@ -1358,6 +1457,15 @@ enum AutomationTriggerType {
RAW_MATERIAL_LOT_NUMBER_FORMAT
PRODUCTION_BATCH_NUMBER_FORMAT
RAW_MATERIAL_LOW
INSPECTION_INTERVAL
QUEEN_CELLS_SWARM_CHECK
QUEEN_ACCEPTANCE_CHECK
SPLIT_SCHEDULE
COLONY_ENDED_CLEANUP
WITHDRAWAL_ENDED
HONEY_WATER_CONTENT
SEASON_AUTOPILOT
PHENOLOGY
}
model AutomationRule {
@@ -1446,6 +1554,7 @@ enum CalendarSyncSourceType {
FEEDING
HARVEST
QUEEN_ASSIGNMENT
WITHDRAWAL
}
enum CalendarSyncStatus {
+2
View File
@@ -53,6 +53,7 @@ import { BackupModule } from './backup/backup.module';
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 { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
@@ -106,6 +107,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
WebhooksModule,
DocumentsModule,
AutomationModule,
SuggestionsModule,
LabelTemplatesModule,
ImportModule,
PublicModule,
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { PermissionResolverService } from './permission-resolver.service';
/**
* PermissionResolverService for the job and worker context (BackupService
* clears its cache after a restore, which no job does). Not
* PermissionsModule: its PermissionCatalogSyncService would rewrite the
* permission catalog on every job or worker start. Global like
* PermissionsModule, so modules that inject the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
export class JobPermissionsModule {}
@@ -1,4 +1,4 @@
import { Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { AutomationService } from './automation.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -26,6 +26,7 @@ describe('AutomationService', () => {
createMany: jest.Mock;
findMany: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
task: { findFirst: jest.Mock };
$queryRaw: jest.Mock;
@@ -84,6 +85,7 @@ describe('AutomationService', () => {
createMany: jest.fn().mockResolvedValue({ count: 0 }),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
task: { findFirst: jest.fn().mockResolvedValue(null) },
$queryRaw: jest.fn().mockResolvedValue([]),
@@ -438,7 +440,7 @@ describe('AutomationService', () => {
expect(tasks.createInTx).not.toHaveBeenCalled();
});
it('triggers when feed status is kritisch, case-insensitive', async () => {
it('triggers when feed status is CRITICAL', async () => {
tx.automationRule.findMany.mockResolvedValue([feedRule]);
await service.evaluateInspectionIn(asTx(), {
@@ -446,7 +448,7 @@ describe('AutomationService', () => {
colonyId: 'colony-1',
queenSeen: true,
eggsPresent: true,
feedStatus: 'Kritisch',
feedStatus: 'CRITICAL',
} as never);
expect(tasks.createInTx).toHaveBeenCalledTimes(1);
@@ -461,7 +463,7 @@ describe('AutomationService', () => {
await service.evaluateInspectionIn(asTx(), {
...queenlessInspection,
feedStatus: 'kritisch',
feedStatus: 'CRITICAL',
} as never);
expect(tasks.createInTx).not.toHaveBeenCalled();
@@ -917,9 +919,55 @@ describe('AutomationService', () => {
});
describe('update', () => {
it('records an audit log entry with before/after data', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
// The rule row is locked first, then read, so "before" is the committed
// state at the moment of the write.
beforeEach(() => {
tx.$queryRaw.mockResolvedValue([{ locked: 1 }]);
});
it('locks the rule row and reads before inside the transaction', async () => {
const calls: string[] = [];
tx.$queryRaw.mockImplementation(() => {
calls.push('lock');
return Promise.resolve([{ locked: 1 }]);
});
tx.automationRule.findUnique.mockImplementation(() => {
calls.push('read');
return Promise.resolve(varroaRule);
});
tx.automationRule.update.mockImplementation(() => {
calls.push('write');
return Promise.resolve({ ...varroaRule, active: false });
});
auditLog.record.mockImplementation(() => {
calls.push('audit');
return Promise.resolve();
});
await service.update('rule-3', { active: false }, 'user-1');
expect(calls).toEqual(['lock', 'read', 'write', 'audit']);
const [[lockQuery]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(lockQuery.sql).toContain('FROM "AutomationRule"');
expect(lockQuery.sql).toContain('FOR UPDATE');
expect(lockQuery.values).toEqual(['rule-3']);
// Nothing is read or written outside the transaction.
expect(prisma.automationRule.findUnique).not.toHaveBeenCalled();
expect(prisma.automationRule.update).not.toHaveBeenCalled();
});
it('answers 404 when the rule does not exist', async () => {
tx.$queryRaw.mockResolvedValue([]);
await expect(
service.update('missing', { active: false }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.automationRule.update).not.toHaveBeenCalled();
});
it('records an audit log entry with before/after data in the transaction', async () => {
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue({
...varroaRule,
active: false,
});
@@ -931,20 +979,22 @@ describe('AutomationService', () => {
entityType: 'AutomationRule',
entityId: 'rule-3',
action: 'UPDATE',
beforeData: varroaRule,
}),
tx,
);
});
it('clears the config on null so the rule falls back to its defaults', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue({
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue({
...varroaRule,
config: null,
});
await service.update('rule-3', { config: null as never }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
const [[updateArg]] = tx.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
// A Json column is cleared with DbNull - a plain null fails the write.
@@ -952,16 +1002,60 @@ describe('AutomationService', () => {
});
it('leaves the config untouched when it is omitted', async () => {
prisma.automationRule.findUnique.mockResolvedValue(varroaRule);
prisma.automationRule.update.mockResolvedValue(varroaRule);
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue(varroaRule);
await service.update('rule-3', { active: true }, 'user-1');
const [[updateArg]] = prisma.automationRule.update.mock.calls as [
const [[updateArg]] = tx.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
expect(updateArg.data.config).toBeUndefined();
});
it('rejects an invalid known value with the field and writes nothing', async () => {
tx.automationRule.findUnique.mockResolvedValue({
...varroaRule,
id: 'rule-9',
triggerType: 'SEASON_AUTOPILOT',
config: {},
});
const result = service.update(
'rule-9',
{ config: { queenMaxAgeYears: 0, somethingElse: true } },
'user-1',
);
await expect(result).rejects.toBeInstanceOf(BadRequestException);
await expect(result).rejects.toMatchObject({
response: {
message: 'Die Einstellung ist ungültig.',
issues: [{ field: 'queenMaxAgeYears' }],
},
});
expect(tx.automationRule.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('keeps unknown keys of a valid config as sent', async () => {
tx.automationRule.findUnique.mockResolvedValue(varroaRule);
tx.automationRule.update.mockResolvedValue(varroaRule);
await service.update(
'rule-3',
{ config: { thresholdMitesPerDay: 7, somethingElse: true } },
'user-1',
);
const [[updateArg]] = tx.automationRule.update.mock.calls as [
[{ data: { config?: unknown } }],
];
expect(updateArg.data.config).toEqual({
thresholdMitesPerDay: 7,
somethingElse: true,
});
});
});
describe('generateNumber', () => {
@@ -1,5 +1,11 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
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';
@@ -21,6 +27,7 @@ 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';
type Tx = Prisma.TransactionClient;
@@ -56,84 +63,6 @@ function isUniqueViolation(error: unknown): boolean {
);
}
interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> = {
QUEENLESS_NO_EGGS: {
config: {},
actionTemplate:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
},
VARROA_THRESHOLD: {
config: { thresholdMitesPerDay: 10 },
actionTemplate: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
},
FEED_STATUS_CRITICAL: {
config: {},
actionTemplate: 'Fütterung erforderlich: Futterstatus kritisch.',
},
SWARM_MOOD_HIGH: {
config: {},
actionTemplate:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
},
COLONY_HEALTH_CRITICAL: {
config: {},
actionTemplate: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
},
STOCK_ITEM_LOW: {
config: {},
actionTemplate: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
},
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
BOTTLING_LOT_NUMBER_FORMAT: {
config: { format: 'WH-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: {
config: { format: '{ART}-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
PRODUCTION_BATCH_NUMBER_FORMAT: {
config: { format: 'HS-{YYYY}-{SEQ:4}' },
actionTemplate: '',
},
// The raw-material counterpart of STOCK_ITEM_LOW. Raw material lots carry
// no minimum of their own, so the minimum per kind (config.minGrams) lives
// here - and without one there is nothing to compare, hence off by default.
RAW_MATERIAL_LOW: {
config: {},
actionTemplate: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
active: false,
},
// Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
TREATMENT_FOLLOW_UP: {
config: {},
actionTemplate: 'Behandlungserfolg kontrollieren',
active: false,
},
FEEDING_FOLLOW_UP: {
config: {},
actionTemplate: 'Futterabnahme und Futtervorrat kontrollieren',
active: false,
},
};
/**
* 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
@@ -223,13 +152,34 @@ export class AutomationService {
return rule;
}
/**
* The row is locked before it is read, so "before" (and the audit entry)
* is the state this write replaces. A config with an invalid known value
* is refused with the fields named; unknown keys pass unchanged.
*/
async update(
id: string,
dto: UpdateAutomationRuleDto,
actingUserId: string,
): Promise<AutomationRule> {
const before = await this.findById(id);
const rule = await this.prisma.automationRule.update({
return this.prisma.$transaction(async (tx) => {
const locked = await lockRowForUpdate(tx, 'AutomationRule', id);
const before = locked
? await tx.automationRule.findUnique({ where: { id } })
: null;
if (!before) {
throw new NotFoundException('Automation rule not found');
}
if (typeof dto.config === 'object' && dto.config !== null) {
const issues = validateRuleConfig(before.triggerType, dto.config);
if (issues.length > 0) {
throw new BadRequestException({
message: 'Die Einstellung ist ungültig.',
issues,
});
}
}
const rule = await tx.automationRule.update({
where: { id },
data: {
...dto,
@@ -241,15 +191,19 @@ export class AutomationService {
: (dto.config as Prisma.InputJsonValue | undefined),
},
});
await this.auditLog.record({
await this.auditLog.record(
{
entityType: 'AutomationRule',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: rule,
});
},
tx,
);
return rule;
});
}
// -------------------------------------------------------------------------
@@ -287,7 +241,7 @@ export class AutomationService {
}
const feedRule = ruleByType.get('FEED_STATUS_CRITICAL');
if (feedRule && inspection.feedStatus?.toLowerCase() === 'kritisch') {
if (feedRule && inspection.feedStatus === 'CRITICAL') {
await this.triggerActionIn(
tx,
feedRule,
@@ -0,0 +1,40 @@
import { bool, intIn, makeParser } from './parse-helpers';
import type { ChainConfig } from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Follow-up chains (queen cells, queen acceptance): one check some days later. */
export function chainParser(trigger: string, defaults: ChainConfig) {
return makeParser<ChainConfig>(trigger, defaults, (scope) => ({
dueInDays: intIn(
scope,
'dueInDays',
defaults.dueInDays,
0,
MAX_DUE_IN_DAYS,
),
notify: bool(scope, 'notify', defaults.notify),
}));
}
export const DEFAULT_CHAIN_CONFIG: ChainConfig = {
dueInDays: 7,
notify: false,
};
export const DEFAULT_WITHDRAWAL_ENDED_CONFIG = { notify: true };
export const withdrawalEndedParser = makeParser<{ notify: boolean }>(
'WITHDRAWAL_ENDED',
DEFAULT_WITHDRAWAL_ENDED_CONFIG,
(scope) => ({
notify: bool(scope, 'notify', DEFAULT_WITHDRAWAL_ENDED_CONFIG.notify),
}),
);
/** The cleanup rule has no setting; it is only switched on or off. */
export const colonyEndedCleanupParser = makeParser<Record<string, never>>(
'COLONY_ENDED_CLEANUP',
{},
() => ({}),
);
@@ -0,0 +1,72 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import { RULE_CONFIG_PARSERS } from './rule-config.registry';
export interface RuleDefaults {
config: Record<string, unknown>;
actionTemplate: string;
/** Rules without a sensible built-in setting start switched off. */
active?: boolean;
}
/** Texts of the rules that create a task; the new rules have none yet. */
const ACTION_TEMPLATES: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS:
'Kontrolle empfohlen: Königin nicht gesehen und keine Stifte vorhanden.',
VARROA_THRESHOLD: 'Behandlung prüfen: Varroa-Schwellenwert überschritten.',
FEED_STATUS_CRITICAL: 'Fütterung erforderlich: Futterstatus kritisch.',
SWARM_MOOD_HIGH:
'Schwarmkontrolle dringend: hohe Schwarmstimmung festgestellt.',
COLONY_HEALTH_CRITICAL: 'Kontrolle erforderlich: Gesundheitsstatus kritisch.',
STOCK_ITEM_LOW: 'Nachbestellung prüfen: Lagerartikel unter Mindestbestand.',
// Not task-generating rules like the ones above - `active` here toggles
// whether HoneyBatchesService/BottlingService auto-fill the batch/lot
// number from `config.format` instead of requiring manual entry.
HONEY_BATCH_NUMBER_FORMAT: '',
BOTTLING_LOT_NUMBER_FORMAT: '',
RAW_MATERIAL_LOT_NUMBER_FORMAT: '',
PRODUCTION_BATCH_NUMBER_FORMAT: '',
RAW_MATERIAL_LOW: 'Rohstoff unter Mindestmenge: Nachschub einplanen.',
TREATMENT_FOLLOW_UP: 'Behandlungserfolg kontrollieren',
FEEDING_FOLLOW_UP: 'Futterabnahme und Futtervorrat kontrollieren',
INSPECTION_INTERVAL: '',
QUEEN_CELLS_SWARM_CHECK: '',
QUEEN_ACCEPTANCE_CHECK: '',
SPLIT_SCHEDULE: '',
COLONY_ENDED_CLEANUP: '',
WITHDRAWAL_ENDED: '',
HONEY_WATER_CONTENT: '',
SEASON_AUTOPILOT: '',
PHENOLOGY: '',
};
// - RAW_MATERIAL_LOW: raw material lots carry no minimum of their own, so
// the minimum per kind (config.minGrams) lives on the rule - and without
// one there is nothing to compare, hence off by default.
// - Follow-up checks: how many days after a treatment or feeding a check
// makes sense depends on the product and the season, so there is no
// built-in interval - the beekeeper switches the rule on and sets
// config.dueInDays.
const INACTIVE_BY_DEFAULT: ReadonlySet<AutomationTriggerType> = new Set([
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
'FEEDING_FOLLOW_UP',
]);
function defaultsOf(trigger: AutomationTriggerType): RuleDefaults {
return {
config: structuredClone(RULE_CONFIG_PARSERS[trigger].defaults) as Record<
string,
unknown
>,
actionTemplate: ACTION_TEMPLATES[trigger],
...(INACTIVE_BY_DEFAULT.has(trigger) ? { active: false } : {}),
};
}
/** The rule rows created on first use; the config is the parser's default. */
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults> =
Object.fromEntries(
(Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map(
(trigger) => [trigger, defaultsOf(trigger)],
),
) as Record<AutomationTriggerType, RuleDefaults>;
@@ -0,0 +1,8 @@
export * from './rule-config.types';
export {
RULE_CONFIG_PARSERS,
ruleConfigOf,
validateRuleConfig,
} from './rule-config.registry';
export { DEFAULT_RULES, type RuleDefaults } from './default-rules';
export { lockRuleIn, readRule, type ActiveRule } from './rule-config.reader';
@@ -0,0 +1,44 @@
import type { SeasonSegmentKey } from '../../season/season-types';
import { childScope, intIn, makeParser } from './parse-helpers';
import type { InspectionIntervalConfig } from './rule-config.types';
type IntervalSegment = Exclude<SeasonSegmentKey, 'WINTERRUHE'>;
const MIN_INTERVAL_DAYS = 7;
const MAX_INTERVAL_DAYS = 35;
/** In the swarm season a colony is looked at weekly, at the latest every 9 days. */
const MAX_SWARM_INTERVAL_DAYS = 9;
export const DEFAULT_INSPECTION_INTERVAL_CONFIG: InspectionIntervalConfig = {
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 14,
SPAETSOMMER: 21,
HERBST: 21,
},
};
const SEGMENTS = Object.keys(
DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays,
) as IntervalSegment[];
export const inspectionIntervalParser = makeParser<InspectionIntervalConfig>(
'INSPECTION_INTERVAL',
DEFAULT_INSPECTION_INTERVAL_CONFIG,
(scope) => {
const days = childScope(scope, 'intervalDays');
const defaults = DEFAULT_INSPECTION_INTERVAL_CONFIG.intervalDays;
const intervalDays = { ...defaults };
for (const segment of SEGMENTS) {
intervalDays[segment] = intIn(
days,
segment,
defaults[segment],
MIN_INTERVAL_DAYS,
segment === 'SCHWARMZEIT' ? MAX_SWARM_INTERVAL_DAYS : MAX_INTERVAL_DAYS,
);
}
return { intervalDays };
},
);
@@ -0,0 +1,349 @@
import { Logger } from '@nestjs/common';
import type { RuleConfigIssue, RuleConfigParser } from './rule-config.types';
/**
* Where a value is read from: the object, its field path (for messages) and
* the list the issues go to. Helpers never throw - an invalid or missing key
* gives the fallback, and an invalid one that is present is reported.
*/
export interface Scope {
readonly raw: Readonly<Record<string, unknown>>;
readonly path: string;
readonly issues: RuleConfigIssue[];
}
const NO_VALUES: Readonly<Record<string, unknown>> = Object.freeze({});
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
export function rootScope(json: unknown, issues: RuleConfigIssue[]): Scope {
return { raw: asRecord(json) ?? NO_VALUES, path: '', issues };
}
export function fieldPath(scope: Scope, key: string): string {
return scope.path === '' ? key : `${scope.path}.${key}`;
}
export function report(scope: Scope, key: string, message: string): void {
scope.issues.push({ field: fieldPath(scope, key), message });
}
/**
* A key counts as given when it is present - an explicit null too, which the
* readers then report as invalid instead of silently using the default.
*/
export function has(scope: Scope, key: string): boolean {
if (!Object.hasOwn(scope.raw, key)) {
return false;
}
return scope.raw[key] !== undefined;
}
/** An explicit null: on an optional key it clears the value. */
export function isCleared(scope: Scope, key: string): boolean {
return Object.hasOwn(scope.raw, key) && scope.raw[key] === null;
}
/** The nested object under `key`; a non-object is reported and read as empty. */
export function childScope(scope: Scope, key: string): Scope {
const path = fieldPath(scope, key);
if (!has(scope, key)) {
return { raw: NO_VALUES, path, issues: scope.issues };
}
const nested = asRecord(scope.raw[key]);
if (nested === null) {
report(scope, key, 'Bitte die Angaben als Gruppe senden.');
return { raw: NO_VALUES, path, issues: scope.issues };
}
return { raw: nested, path, issues: scope.issues };
}
/** Runs `read`; if it reported anything, `fallback()` replaces its result. */
export function orFallback<T>(
scope: Scope,
read: () => T,
fallback: () => T,
): T {
const before = scope.issues.length;
const value = read();
return scope.issues.length > before ? fallback() : value;
}
const numberFormat = new Intl.NumberFormat('de-DE', {
maximumFractionDigits: 3,
});
export function formatDe(value: number): string {
return numberFormat.format(value);
}
export function intIn(
scope: Scope,
key: string,
fallback: number,
min: number,
max: number,
): number {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= min &&
value <= max
) {
return value;
}
report(
scope,
key,
`Bitte eine ganze Zahl zwischen ${formatDe(min)} und ${formatDe(max)} wählen.`,
);
return fallback;
}
/** Like intIn, but an absent key stays undefined. */
export function optionalIntIn(
scope: Scope,
key: string,
min: number,
max: number,
): number | undefined {
if (!has(scope, key) || isCleared(scope, key)) {
return undefined;
}
const value = intIn(scope, key, Number.NaN, min, max);
return Number.isNaN(value) ? undefined : value;
}
export interface NumberOptions {
/** The lower limit itself is not allowed (min < x). */
exclusiveMin?: boolean;
}
export function numberIn(
scope: Scope,
key: string,
fallback: number,
min: number,
max: number,
options: NumberOptions = {},
): number {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'number' &&
Number.isFinite(value) &&
(options.exclusiveMin ? value > min : value >= min) &&
value <= max
) {
return value;
}
const lower = options.exclusiveMin
? `größer als ${formatDe(min)}`
: `mindestens ${formatDe(min)}`;
report(
scope,
key,
Number.isFinite(max)
? `Bitte eine Zahl ${lower} und höchstens ${formatDe(max)} angeben.`
: `Bitte eine Zahl ${lower} angeben.`,
);
return fallback;
}
/** Like numberIn, but an absent key stays undefined. */
export function optionalNumberIn(
scope: Scope,
key: string,
min: number,
max: number,
options: NumberOptions = {},
): number | undefined {
if (!has(scope, key) || isCleared(scope, key)) {
return undefined;
}
const value = numberIn(scope, key, Number.NaN, min, max, options);
return Number.isNaN(value) ? undefined : value;
}
export function bool(scope: Scope, key: string, fallback: boolean): boolean {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (typeof value === 'boolean') {
return value;
}
report(scope, key, 'Bitte Ja oder Nein wählen.');
return fallback;
}
export function stringIn(
scope: Scope,
key: string,
fallback: string,
minLength: number,
maxLength: number,
pattern?: RegExp,
): string {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'string' &&
value.length >= minLength &&
value.length <= maxLength &&
(pattern === undefined || pattern.test(value))
) {
return value;
}
report(
scope,
key,
`Bitte einen Text mit ${minLength} bis ${maxLength} Zeichen angeben.`,
);
return fallback;
}
const MONTH_DAY = /^(\d{2})-(\d{2})$/;
/** 'MM-DD' that exists in a leap year (02-29 is allowed). */
export function isMonthDay(value: unknown): value is string {
if (typeof value !== 'string') {
return false;
}
const match = MONTH_DAY.exec(value);
if (!match) {
return false;
}
const month = Number(match[1]);
const day = Number(match[2]);
const date = new Date(Date.UTC(2024, month - 1, day));
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
}
export function monthDay(scope: Scope, key: string, fallback: string): string {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (isMonthDay(value)) {
return value;
}
report(scope, key, 'Bitte ein Datum als MM-TT angeben, zum Beispiel 03-20.');
return fallback;
}
export function months(
scope: Scope,
key: string,
fallback: readonly number[],
): number[] {
if (!has(scope, key)) {
return [...fallback];
}
const value = scope.raw[key];
if (
Array.isArray(value) &&
value.every(
(month): month is number =>
typeof month === 'number' &&
Number.isInteger(month) &&
month >= 1 &&
month <= 12,
) &&
new Set(value).size === value.length
) {
return [...value];
}
report(scope, key, 'Bitte Monate von 1 bis 12 angeben, jeden nur einmal.');
return [...fallback];
}
export function enumValue<T extends string>(
scope: Scope,
key: string,
fallback: T,
allowed: readonly T[],
): T {
if (!has(scope, key)) {
return fallback;
}
const value = scope.raw[key];
if (
typeof value === 'string' &&
(allowed as readonly string[]).includes(value)
) {
return value as T;
}
report(scope, key, 'Bitte einen gültigen Wert wählen.');
return fallback;
}
export function enumList<T extends string>(
scope: Scope,
key: string,
fallback: readonly T[],
allowed: readonly T[],
): T[] {
if (!has(scope, key)) {
return [...fallback];
}
const value = scope.raw[key];
if (
Array.isArray(value) &&
value.every(
(entry): entry is T =>
typeof entry === 'string' &&
(allowed as readonly string[]).includes(entry),
) &&
new Set(value).size === value.length
) {
return [...value];
}
report(scope, key, 'Bitte nur gültige Werte wählen, jeden nur einmal.');
return [...fallback];
}
const logger = new Logger('RuleConfig');
/**
* Builds the parser of one rule from its reader. The reader turns the scope
* into a complete config and reports every invalid key; `parse` logs those
* (trigger and field, never the value) and returns the config, `validate`
* returns them.
*/
export function makeParser<T>(
trigger: string,
defaults: T,
read: (scope: Scope) => T,
): RuleConfigParser<T> {
return {
defaults,
parse(json) {
const issues: RuleConfigIssue[] = [];
const value = read(rootScope(json, issues));
for (const issue of issues) {
logger.warn(
`Invalid setting ${trigger}.${issue.field}; using the default`,
);
}
return value;
},
validate(json) {
const issues: RuleConfigIssue[] = [];
read(rootScope(json, issues));
return issues;
},
};
}
@@ -0,0 +1,158 @@
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
} from '../../season/season-types';
import {
bool,
childScope,
enumList,
enumValue,
has,
isCleared,
makeParser,
monthDay,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { PhenologyConfig } from './rule-config.types';
/** Upper limit of a growing-degree-day threshold; far above any phase. */
const MAX_GTS_THRESHOLD = 5000;
const MAX_GAP_SHARE = 0.5;
export const DEFAULT_PHENOLOGY_CONFIG: PhenologyConfig = {
useGts: true,
gtsThresholds: { SALWEIDE: 200 },
calibrateFromReports: true,
calibratePhases: ['KIRSCHE', 'APFEL'],
calendarFallback: {
SALWEIDE: '03-20',
KIRSCHE: '04-15',
APFEL: '04-28',
HOLUNDER: '05-30',
LINDE: '06-20',
TRACHTENDE: '08-01',
HERBST: '09-15',
WINTER: '11-15',
},
swarmSeason: { from: 'KIRSCHE', until: 'LINDE' },
maxGapShare: 0.1,
};
function readThresholds(scope: Scope): PhenologyConfig['gtsThresholds'] {
// Without the group the defaults apply; with it, only the phases given
// have a threshold - leaving one out removes it.
if (!has(scope, 'gtsThresholds')) {
return { ...DEFAULT_PHENOLOGY_CONFIG.gtsThresholds };
}
const thresholds = childScope(scope, 'gtsThresholds');
const result: PhenologyConfig['gtsThresholds'] = {};
for (const phase of PHENOLOGY_PHASES) {
if (isCleared(thresholds, phase)) {
continue;
}
const value = numberIn(
thresholds,
phase,
Number.NaN,
0,
MAX_GTS_THRESHOLD,
{ exclusiveMin: true },
);
if (!Number.isNaN(value)) {
result[phase] = value;
}
}
return result;
}
/**
* The calendar dates of the phases must not run backwards in phase order.
* One violation sends the whole map back to the defaults, so it stays
* consistent; the issue names a key the caller actually sent.
*/
function readCalendar(scope: Scope): PhenologyConfig['calendarFallback'] {
const calendar = childScope(scope, 'calendarFallback');
const defaults = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
const before = scope.issues.length;
const result = { ...defaults };
for (const phase of PHENOLOGY_PHASES) {
result[phase] = monthDay(calendar, phase, defaults[phase]);
}
if (scope.issues.length > before) {
return { ...defaults };
}
for (let index = 1; index < PHENOLOGY_PHASES.length; index += 1) {
const previous = PHENOLOGY_PHASES[index - 1];
const phase = PHENOLOGY_PHASES[index];
if (result[phase] < result[previous]) {
const culprit = has(calendar, phase) ? phase : previous;
report(
calendar,
culprit,
`Das Datum darf nicht vor dem der vorherigen Phase liegen (${previous}).`,
);
return { ...defaults };
}
}
return result;
}
function readSwarmSeason(scope: Scope): PhenologyConfig['swarmSeason'] {
const season = childScope(scope, 'swarmSeason');
const defaults = DEFAULT_PHENOLOGY_CONFIG.swarmSeason;
const from: PhenologyPhaseKey = enumValue(
season,
'from',
defaults.from,
PHENOLOGY_PHASES,
);
const until: PhenologyPhaseKey = enumValue(
season,
'until',
defaults.until,
PHENOLOGY_PHASES,
);
if (PHENOLOGY_PHASES.indexOf(from) >= PHENOLOGY_PHASES.indexOf(until)) {
report(
season,
has(season, 'from') ? 'from' : 'until',
'Der Beginn der Schwarmzeit muss vor ihrem Ende liegen.',
);
return { ...defaults };
}
return { from, until };
}
export const phenologyParser = makeParser<PhenologyConfig>(
'PHENOLOGY',
DEFAULT_PHENOLOGY_CONFIG,
(scope) => {
const d = DEFAULT_PHENOLOGY_CONFIG;
return {
useGts: bool(scope, 'useGts', d.useGts),
gtsThresholds: readThresholds(scope),
calibrateFromReports: bool(
scope,
'calibrateFromReports',
d.calibrateFromReports,
),
calibratePhases: enumList(
scope,
'calibratePhases',
d.calibratePhases,
PHENOLOGY_PHASES,
),
calendarFallback: readCalendar(scope),
swarmSeason: readSwarmSeason(scope),
maxGapShare: numberIn(
scope,
'maxGapShare',
d.maxGapShare,
0,
MAX_GAP_SHARE,
),
};
},
);
@@ -0,0 +1,100 @@
import { Prisma } from '../../../generated/prisma';
import type { AutomationTriggerType } from '../../../generated/prisma';
import { assertTransactionClient, type DbClient } from '../../prisma/db-client';
import { lockRowForUpdate } from '../../prisma/row-lock';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf } from './rule-config.registry';
import type { RuleConfigMap } from './rule-config.types';
export interface ActiveRule<K extends AutomationTriggerType> {
/** null while no row exists yet: the built-in setting applies. */
id: string | null;
active: boolean;
config: RuleConfigMap[K];
actionTemplate: string | null;
}
interface RuleRow {
id: string;
active: boolean;
config: Prisma.JsonValue;
actionTemplate: string | null;
}
const RULE_COLUMNS = {
id: true,
active: true,
config: true,
actionTemplate: true,
} as const;
function toActiveRule<K extends AutomationTriggerType>(
trigger: K,
row: RuleRow | null,
): ActiveRule<K> {
if (row === null) {
const defaults = DEFAULT_RULES[trigger];
return {
id: null,
active: defaults.active ?? true,
config: ruleConfigOf(trigger, defaults.config),
actionTemplate: defaults.actionTemplate,
};
}
return {
id: row.id,
active: row.active,
config: ruleConfigOf(trigger, row.config),
actionTemplate: row.actionTemplate,
};
}
/**
* The rule as it is in force: its row, or the built-in setting while there is
* none. The config is parsed, so every key is valid.
*/
export async function readRule<K extends AutomationTriggerType>(
db: DbClient,
trigger: K,
): Promise<ActiveRule<K>> {
const row = await db.automationRule.findUnique({
where: { triggerType: trigger },
select: RULE_COLUMNS,
});
return toActiveRule(trigger, row);
}
/**
* Creates the rule row if it is missing (without touching an existing one),
* locks it until the transaction ends and reads it. Serialises everything
* that decides "is there already an open task/suggestion" under the rule.
*/
export async function lockRuleIn<K extends AutomationTriggerType>(
tx: Prisma.TransactionClient,
trigger: K,
): Promise<ActiveRule<K> & { id: string }> {
assertTransactionClient(tx, 'lockRuleIn');
const defaults = DEFAULT_RULES[trigger];
await tx.automationRule.createMany({
data: [
{
triggerType: trigger,
active: defaults.active ?? true,
config: defaults.config as Prisma.InputJsonValue,
actionTemplate: defaults.actionTemplate,
},
],
skipDuplicates: true,
});
const { id } = await tx.automationRule.findUniqueOrThrow({
where: { triggerType: trigger },
select: { id: true },
});
await lockRowForUpdate(tx, 'AutomationRule', id);
// Read after the lock: a concurrent edit of the rule is visible now.
const row = await tx.automationRule.findUniqueOrThrow({
where: { id },
select: RULE_COLUMNS,
});
return { ...toActiveRule(trigger, row), id };
}
@@ -0,0 +1,91 @@
import type { AutomationTriggerType } from '../../../generated/prisma';
import {
chainParser,
colonyEndedCleanupParser,
DEFAULT_CHAIN_CONFIG,
withdrawalEndedParser,
} from './chain.config';
import { inspectionIntervalParser } from './inspection-interval.config';
import { phenologyParser } from './phenology.config';
import { seasonAutopilotParser } from './season-autopilot.config';
import { numberFormatParser, simpleParser } from './simple.config';
import { splitScheduleParser } from './split-schedule.config';
import { varroaThresholdParser } from './varroa-threshold.config';
import { waterContentParser } from './water-content.config';
import type {
RuleConfigIssue,
RuleConfigMap,
RuleConfigParser,
} from './rule-config.types';
/** The number-format rules keep the defaults they always had. */
const DEFAULT_BATCH_FORMAT = 'WH-{YYYY}-{SEQ:4}';
export const RULE_CONFIG_PARSERS: {
[K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]>;
} = {
VARROA_THRESHOLD: varroaThresholdParser,
INSPECTION_INTERVAL: inspectionIntervalParser,
QUEEN_CELLS_SWARM_CHECK: chainParser(
'QUEEN_CELLS_SWARM_CHECK',
DEFAULT_CHAIN_CONFIG,
),
QUEEN_ACCEPTANCE_CHECK: chainParser(
'QUEEN_ACCEPTANCE_CHECK',
DEFAULT_CHAIN_CONFIG,
),
SPLIT_SCHEDULE: splitScheduleParser,
COLONY_ENDED_CLEANUP: colonyEndedCleanupParser,
WITHDRAWAL_ENDED: withdrawalEndedParser,
HONEY_WATER_CONTENT: waterContentParser,
SEASON_AUTOPILOT: seasonAutopilotParser,
PHENOLOGY: phenologyParser,
HONEY_BATCH_NUMBER_FORMAT: numberFormatParser(
'HONEY_BATCH_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
BOTTLING_LOT_NUMBER_FORMAT: numberFormatParser(
'BOTTLING_LOT_NUMBER_FORMAT',
DEFAULT_BATCH_FORMAT,
),
// {ART} (WA/PR) gives wax and propolis a counter each.
RAW_MATERIAL_LOT_NUMBER_FORMAT: numberFormatParser(
'RAW_MATERIAL_LOT_NUMBER_FORMAT',
'{ART}-{YYYY}-{SEQ:4}',
),
PRODUCTION_BATCH_NUMBER_FORMAT: numberFormatParser(
'PRODUCTION_BATCH_NUMBER_FORMAT',
'HS-{YYYY}-{SEQ:4}',
),
QUEENLESS_NO_EGGS: simpleParser('QUEENLESS_NO_EGGS', { notify: true }),
FEED_STATUS_CRITICAL: simpleParser('FEED_STATUS_CRITICAL', {
notify: false,
}),
SWARM_MOOD_HIGH: simpleParser('SWARM_MOOD_HIGH', { notify: false }),
COLONY_HEALTH_CRITICAL: simpleParser('COLONY_HEALTH_CRITICAL', {
notify: true,
}),
STOCK_ITEM_LOW: simpleParser('STOCK_ITEM_LOW', { notify: false }),
RAW_MATERIAL_LOW: simpleParser('RAW_MATERIAL_LOW', {
notify: false,
withMinGrams: true,
}),
TREATMENT_FOLLOW_UP: simpleParser('TREATMENT_FOLLOW_UP', { notify: false }),
FEEDING_FOLLOW_UP: simpleParser('FEEDING_FOLLOW_UP', { notify: false }),
};
/** The effective setting of a rule: valid keys as stored, the rest default. */
export function ruleConfigOf<K extends AutomationTriggerType>(
trigger: K,
json: unknown,
): RuleConfigMap[K] {
return RULE_CONFIG_PARSERS[trigger].parse(json);
}
/** The invalid known keys of a config a client wants to save. */
export function validateRuleConfig(
trigger: AutomationTriggerType,
json: unknown,
): RuleConfigIssue[] {
return RULE_CONFIG_PARSERS[trigger].validate(json);
}
@@ -0,0 +1,440 @@
import { Logger } from '@nestjs/common';
import { AutomationTriggerType } from '../../../generated/prisma';
import { DEFAULT_RULES } from './default-rules';
import { ruleConfigOf, RULE_CONFIG_PARSERS, validateRuleConfig } from './index';
const ALL_TRIGGERS = Object.values(AutomationTriggerType);
function fieldsOf(trigger: AutomationTriggerType, config: unknown): string[] {
return validateRuleConfig(trigger, config).map((issue) => issue.field);
}
describe('rule config', () => {
beforeEach(() => {
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('every trigger has defaults and a parser', () => {
for (const t of ALL_TRIGGERS) {
expect(DEFAULT_RULES[t]).toBeDefined();
expect(RULE_CONFIG_PARSERS[t]).toBeDefined();
}
});
it.each(ALL_TRIGGERS)(
'the default config of %s parses to itself and validates without issues',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
expect(RULE_CONFIG_PARSERS[trigger].parse(config)).toEqual(config);
expect(RULE_CONFIG_PARSERS[trigger].defaults).toEqual(config);
expect(validateRuleConfig(trigger, config)).toEqual([]);
},
);
it.each(ALL_TRIGGERS)(
'%s falls back to its defaults for null, arrays and scalars',
(trigger) => {
const { config } = DEFAULT_RULES[trigger];
for (const junk of [null, undefined, [], 'x', 5]) {
expect(ruleConfigOf(trigger, junk)).toEqual(config);
expect(validateRuleConfig(trigger, junk)).toEqual([]);
}
},
);
it('reads a legacy thresholdMitesPerDay as naturalFall.redAbove', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 }).naturalFall,
).toEqual({
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 7,
winterRedAbove: 0.5,
}));
it('lets the band format win over the legacy key', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 7,
naturalFall: { redAbove: 9 },
}).naturalFall.redAbove,
).toBe(9));
it('ignores an invalid legacy threshold', () =>
expect(
ruleConfigOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 0 }).naturalFall
.redAbove,
).toBe(10));
it('reports an invalid legacy threshold with field and message', () => {
const issues = validateRuleConfig('VARROA_THRESHOLD', {
thresholdMitesPerDay: 0,
});
expect(issues).toEqual([
{
field: 'thresholdMitesPerDay',
message: expect.any(String) as string,
},
]);
expect(fieldsOf('VARROA_THRESHOLD', { thresholdMitesPerDay: 7 })).toEqual(
[],
);
});
it('keeps natural-fall redAbove above zero and bounded, percent bands up to 100', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ naturalFall: { redAbove: 0 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: 1001 } })).toEqual([
'naturalFall.redAbove',
]);
expect(
fields({ infestationPercent: { summer: { redAbove: 101 } } }),
).toEqual(['infestationPercent.summer.redAbove']);
expect(
fields({ infestationPercent: { autumn: { redAbove: 100 } } }),
).toEqual([]);
});
it('reports an explicit null on a known key', () => {
expect(fieldsOf('SEASON_AUTOPILOT', { queenMaxAgeYears: null })).toEqual([
'queenMaxAgeYears',
]);
expect(fieldsOf('PHENOLOGY', { swarmSeason: null })).toEqual([
'swarmSeason',
]);
});
it('accepts null on optional keys as clearing them', () => {
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual([]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: null, PROPOLIS: 50 },
}),
).toEqual({ notify: false, minGrams: { PROPOLIS: 50 } });
expect(ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: null })).toEqual({
notify: false,
});
expect(
ruleConfigOf('SPLIT_SCHEDULE', {
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7, windowDays: null }],
}).QUEEN,
).toEqual([{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }]);
expect(
fieldsOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } }),
).toEqual([]);
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { SALWEIDE: null } })
.gtsThresholds,
).toEqual({});
});
it('lets a client remove a gts threshold by leaving it out', () => {
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: { KIRSCHE: 400 } })
.gtsThresholds,
).toEqual({ KIRSCHE: 400 });
expect(
ruleConfigOf('PHENOLOGY', { gtsThresholds: {} }).gtsThresholds,
).toEqual({});
});
it('keeps yellowFrom at or below a legacy threshold under five', () => {
const { naturalFall } = ruleConfigOf('VARROA_THRESHOLD', {
thresholdMitesPerDay: 3,
});
expect(naturalFall.redAbove).toBe(3);
expect(naturalFall.yellowFrom).toBeLessThanOrEqual(3);
});
it('falls back per invalid key and keeps the valid ones', () =>
expect(
ruleConfigOf('SEASON_AUTOPILOT', {
queenMaxAgeYears: 'x',
honeySuperMinFrames: 10,
}),
).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
it('logs a warning with trigger and field but without the value', () => {
const warn = jest.spyOn(Logger.prototype, 'warn');
ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'secret-value' });
const logged = warn.mock.calls.map((call) => String(call[0])).join('\n');
expect(logged).toContain('SEASON_AUTOPILOT');
expect(logged).toContain('queenMaxAgeYears');
expect(logged).not.toContain('secret-value');
});
it('rejects SCHWARMZEIT 10 days with the field name', () =>
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SCHWARMZEIT: 10 },
}),
).toEqual([
{
field: 'intervalDays.SCHWARMZEIT',
message: expect.stringContaining('7') as string,
},
]));
it('allows 7 to 35 days outside the swarm season and 7 to 9 inside', () => {
expect(
validateRuleConfig('INSPECTION_INTERVAL', {
intervalDays: { SOMMER: 35, FRUEHJAHR: 7, SCHWARMZEIT: 9 },
}),
).toEqual([]);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 36 } }),
).toEqual(['intervalDays.SOMMER']);
expect(
fieldsOf('INSPECTION_INTERVAL', { intervalDays: { HERBST: 6.5 } }),
).toEqual(['intervalDays.HERBST']);
});
it('merges a partial interval config over the defaults', () =>
expect(
ruleConfigOf('INSPECTION_INTERVAL', { intervalDays: { SOMMER: 10 } }),
).toEqual({
intervalDays: {
FRUEHJAHR: 14,
SCHWARMZEIT: 7,
SOMMER: 10,
SPAETSOMMER: 21,
HERBST: 21,
},
}));
it('rejects yellowFrom above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
naturalFall: { yellowFrom: 12, redAbove: 8 },
}),
).toEqual(['naturalFall.yellowFrom']));
it('rejects percent bands where yellowFrom is above redAbove', () =>
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summer: { yellowFrom: 6, redAbove: 5 } },
}),
).toEqual(['infestationPercent.summer.yellowFrom']));
it('rejects duplicate or out-of-range months', () => {
expect(
fieldsOf('VARROA_THRESHOLD', { naturalFall: { seasonMonths: [3, 3] } }),
).toEqual(['naturalFall.seasonMonths']);
expect(
fieldsOf('VARROA_THRESHOLD', {
infestationPercent: { summerMonths: [0, 13] },
}),
).toEqual(['infestationPercent.summerMonths']);
});
it('checks the varroa limits', () => {
const fields = (config: unknown) => fieldsOf('VARROA_THRESHOLD', config);
expect(fields({ youngColonyFactor: 0 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1.5 })).toEqual(['youngColonyFactor']);
expect(fields({ youngColonyFactor: 1 })).toEqual([]);
expect(fields({ recheckAfterDays: 61 })).toEqual(['recheckAfterDays']);
expect(fields({ recheckAfterDays: 0 })).toEqual([]);
expect(fields({ youngColonyTypes: ['NOPE'] })).toEqual([
'youngColonyTypes',
]);
expect(fields({ naturalFall: { redAbove: -1 } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ naturalFall: { redAbove: Infinity } })).toEqual([
'naturalFall.redAbove',
]);
expect(fields({ notify: 'yes' })).toEqual(['notify']);
});
it('reports no issue for keys it does not know', () =>
expect(
validateRuleConfig('SEASON_AUTOPILOT', { somethingElse: 'x' }),
).toEqual([]));
it('rejects a calendar fallback that runs backwards', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } }),
).toEqual(['calendarFallback.KIRSCHE']);
// The whole map falls back so the phases stay in order.
expect(
ruleConfigOf('PHENOLOGY', { calendarFallback: { KIRSCHE: '03-01' } })
.calendarFallback,
).toEqual(DEFAULT_RULES.PHENOLOGY.config.calendarFallback);
});
it('rejects an invalid month-day', () => {
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '13-01' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '02-30' } }),
).toEqual(['calendarFallback.SALWEIDE']);
expect(
fieldsOf('PHENOLOGY', { calendarFallback: { SALWEIDE: '03-20' } }),
).toEqual([]);
});
it('checks the phenology limits', () => {
const fields = (config: unknown) => fieldsOf('PHENOLOGY', config);
expect(fields({ maxGapShare: 0.6 })).toEqual(['maxGapShare']);
expect(fields({ maxGapShare: 0.5 })).toEqual([]);
expect(fields({ gtsThresholds: { SALWEIDE: 0 } })).toEqual([
'gtsThresholds.SALWEIDE',
]);
expect(fields({ gtsThresholds: { NOPE: 5 } })).toEqual([]);
expect(fields({ calibratePhases: ['KIRSCHE', 'X'] })).toEqual([
'calibratePhases',
]);
expect(
fields({ swarmSeason: { from: 'LINDE', until: 'KIRSCHE' } }),
).toEqual(['swarmSeason.from']);
});
it('defaults gtsThresholds to SALWEIDE 200 only', () =>
expect(ruleConfigOf('PHENOLOGY', {}).gtsThresholds).toEqual({
SALWEIDE: 200,
}));
it('checks the season autopilot limits', () => {
const fields = (config: unknown) => fieldsOf('SEASON_AUTOPILOT', config);
expect(fields({ honeySuperMinFrames: 0 })).toEqual(['honeySuperMinFrames']);
expect(fields({ honeySuperMinFrames: 41 })).toEqual([
'honeySuperMinFrames',
]);
expect(fields({ varroaMeasureIntervalDays: 6 })).toEqual([
'varroaMeasureIntervalDays',
]);
expect(fields({ queenMaxAgeYears: 0 })).toEqual(['queenMaxAgeYears']);
expect(fields({ queenMaxAgeYears: 11 })).toEqual(['queenMaxAgeYears']);
expect(fields({ suggestionTtlDays: 366 })).toEqual(['suggestionTtlDays']);
expect(fields({ maxStepsPerColony: 4 })).toEqual(['maxStepsPerColony']);
expect(
fields({ winterVarroaWindow: { from: '12-20', to: '11-15' } }),
).toEqual(['winterVarroaWindow.from']);
expect(fields({ rules: { A3: 'no' } })).toEqual(['rules.A3']);
expect(fields({ rules: { A3: false } })).toEqual([]);
expect(
ruleConfigOf('SEASON_AUTOPILOT', { rules: { A3: false } }).rules,
).toEqual(expect.objectContaining({ A1: true, A3: false, A10: true }));
});
it('checks the water content limits', () => {
const fields = (config: unknown) => fieldsOf('HONEY_WATER_CONTENT', config);
expect(fields({ warnAbovePercent: 9 })).toEqual(['warnAbovePercent']);
expect(fields({ confirmAbovePercent: 31 })).toEqual([
'confirmAbovePercent',
]);
expect(fields({ warnAbovePercent: 21 })).toEqual(['warnAbovePercent']);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } } }),
).toEqual([]);
expect(
fields({ byHoneyType: { Heidehonig: { confirmAbovePercent: 5 } } }),
).toEqual(['byHoneyType.Heidehonig.confirmAbovePercent']);
});
it('checks the split schedule steps', () => {
const fields = (config: unknown) => fieldsOf('SPLIT_SCHEDULE', config);
expect(
fields({ QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 121 }] }),
).toEqual(['QUEEN[0].offsetDays']);
expect(
fields({
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21, windowDays: 31 }],
}),
).toEqual(['QUEEN_CELL[0].windowDays']);
expect(fields({ RAISE_OWN: [{ offsetDays: 3 }] })).toEqual([
'RAISE_OWN[0].step',
]);
expect(fields({ RAISE_OWN: 'nope' })).toEqual(['RAISE_OWN']);
expect(
fields({
RAISE_OWN: [{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }],
}),
).toEqual([]);
});
it('checks dueInDays of chains and simple rules', () => {
expect(fieldsOf('QUEEN_CELLS_SWARM_CHECK', { dueInDays: 366 })).toEqual([
'dueInDays',
]);
expect(fieldsOf('TREATMENT_FOLLOW_UP', { dueInDays: -1 })).toEqual([
'dueInDays',
]);
expect(ruleConfigOf('TREATMENT_FOLLOW_UP', { dueInDays: 14 })).toEqual({
notify: false,
dueInDays: 14,
});
});
it('reads minGrams per raw material kind', () => {
expect(
ruleConfigOf('RAW_MATERIAL_LOW', { minGrams: { BEESWAX: 500 } }),
).toEqual({ notify: false, minGrams: { BEESWAX: 500 } });
expect(
fieldsOf('RAW_MATERIAL_LOW', {
minGrams: { BEESWAX: -5, PROPOLIS: 'x' },
}),
).toEqual(['minGrams.BEESWAX', 'minGrams.PROPOLIS']);
});
it('keeps a number format and rejects an empty or oversized one', () => {
expect(
ruleConfigOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'HB-{SEQ:3}' }),
).toEqual({ format: 'HB-{SEQ:3}' });
expect(fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: '' })).toEqual([
'format',
]);
expect(
fieldsOf('HONEY_BATCH_NUMBER_FORMAT', { format: 'x'.repeat(101) }),
).toEqual(['format']);
});
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL (and WITHDRAWAL_ENDED)', () => {
const withNotify = ALL_TRIGGERS.filter(
(trigger) =>
(ruleConfigOf(trigger, {}) as { notify?: boolean }).notify === true,
);
expect([...withNotify].sort()).toEqual(
[
'COLONY_HEALTH_CRITICAL',
'QUEENLESS_NO_EGGS',
'VARROA_THRESHOLD',
'WITHDRAWAL_ENDED',
].sort(),
);
});
it('starts the optional rules switched off', () => {
const off = ALL_TRIGGERS.filter(
(trigger) => DEFAULT_RULES[trigger].active === false,
).sort();
expect(off).toEqual([
'FEEDING_FOLLOW_UP',
'RAW_MATERIAL_LOW',
'TREATMENT_FOLLOW_UP',
]);
});
it('gives every new rule an empty action template', () => {
for (const trigger of [
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
] as const) {
expect(DEFAULT_RULES[trigger].actionTemplate).toBe('');
}
});
});
@@ -0,0 +1,140 @@
import type { ColonyType, RawMaterialKind } from '../../../generated/prisma';
import type {
PhenologyPhaseKey,
SeasonSegmentKey,
} from '../../season/season-types';
/** One invalid setting of a rule: the field path and a German message. */
export interface RuleConfigIssue {
field: string;
message: string;
}
export interface RuleConfigParser<T> {
/** The built-in setting; also what an empty or unusable config reads as. */
readonly defaults: T;
/** Never throws: each invalid key falls back to its default (and is logged). */
parse(json: unknown): T;
/** Reports the known keys that are present and invalid; nothing else. */
validate(json: unknown): RuleConfigIssue[];
}
export interface SimpleRuleConfig {
notify: boolean;
dueInDays?: number;
minGrams?: Partial<Record<RawMaterialKind, number>>;
}
export interface NumberFormatConfig {
format: string;
}
export interface VarroaThresholdConfig {
naturalFall: {
seasonMonths: number[];
yellowFrom: number;
redAbove: number;
winterRedAbove: number;
};
infestationPercent: {
summerMonths: number[];
summer: { yellowFrom: number; redAbove: number };
autumnMonths: number[];
autumn: { yellowFrom: number; redAbove: number };
};
youngColonyFactor: number;
youngColonyTypes: ColonyType[];
recheckAfterDays: number;
notify: boolean;
}
export interface InspectionIntervalConfig {
intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number>;
}
export interface ChainConfig {
dueInDays: number;
notify: boolean;
}
export interface PlanStepConfig {
step: string;
offsetDays: number;
windowDays?: number;
}
export interface SplitScheduleConfig {
RAISE_OWN: PlanStepConfig[];
QUEEN_CELL: PlanStepConfig[];
QUEEN: PlanStepConfig[];
notify: boolean;
}
export interface WaterContentConfig {
warnAbovePercent: number;
confirmAbovePercent: number;
byHoneyType: Record<
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number }
>;
}
export const AUTOPILOT_RULE_IDS = [
'A1',
'A2',
'A3',
'A4',
'A5',
'A6',
'A7',
'A8',
'A9',
'A10',
] as const;
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
export interface SeasonAutopilotConfig {
honeySuperMinFrames: number;
varroaMeasureIntervalDays: number;
winterVarroaWindow: { from: string; to: string };
queenMaxAgeYears: number;
suggestionTtlDays: number;
maxStepsPerColony: number;
rules: Record<AutopilotRuleId, boolean>;
}
export interface PhenologyConfig {
useGts: boolean;
gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>;
calibrateFromReports: boolean;
calibratePhases: PhenologyPhaseKey[];
calendarFallback: Record<PhenologyPhaseKey, string>;
swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey };
maxGapShare: number;
}
/** One entry per AutomationTriggerType. */
export interface RuleConfigMap {
VARROA_THRESHOLD: VarroaThresholdConfig;
INSPECTION_INTERVAL: InspectionIntervalConfig;
QUEEN_CELLS_SWARM_CHECK: ChainConfig;
QUEEN_ACCEPTANCE_CHECK: ChainConfig;
SPLIT_SCHEDULE: SplitScheduleConfig;
COLONY_ENDED_CLEANUP: Record<string, never>;
WITHDRAWAL_ENDED: { notify: boolean };
HONEY_WATER_CONTENT: WaterContentConfig;
SEASON_AUTOPILOT: SeasonAutopilotConfig;
PHENOLOGY: PhenologyConfig;
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig;
BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig;
RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig;
PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
QUEENLESS_NO_EGGS: SimpleRuleConfig;
FEED_STATUS_CRITICAL: SimpleRuleConfig;
SWARM_MOOD_HIGH: SimpleRuleConfig;
COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
STOCK_ITEM_LOW: SimpleRuleConfig;
RAW_MATERIAL_LOW: SimpleRuleConfig;
TREATMENT_FOLLOW_UP: SimpleRuleConfig;
FEEDING_FOLLOW_UP: SimpleRuleConfig;
}
@@ -0,0 +1,102 @@
import {
bool,
childScope,
has,
intIn,
makeParser,
monthDay,
report,
} from './parse-helpers';
import {
AUTOPILOT_RULE_IDS,
type SeasonAutopilotConfig,
} from './rule-config.types';
export const DEFAULT_SEASON_AUTOPILOT_CONFIG: SeasonAutopilotConfig = {
honeySuperMinFrames: 8,
varroaMeasureIntervalDays: 28,
winterVarroaWindow: { from: '11-15', to: '12-20' },
queenMaxAgeYears: 2,
suggestionTtlDays: 30,
maxStepsPerColony: 3,
rules: {
A1: true,
A2: true,
A3: true,
A4: true,
A5: true,
A6: true,
A7: true,
A8: true,
A9: true,
A10: true,
},
};
export const seasonAutopilotParser = makeParser<SeasonAutopilotConfig>(
'SEASON_AUTOPILOT',
DEFAULT_SEASON_AUTOPILOT_CONFIG,
(scope) => {
const d = DEFAULT_SEASON_AUTOPILOT_CONFIG;
const windowScope = childScope(scope, 'winterVarroaWindow');
let winterVarroaWindow = {
from: monthDay(windowScope, 'from', d.winterVarroaWindow.from),
to: monthDay(windowScope, 'to', d.winterVarroaWindow.to),
};
if (winterVarroaWindow.from > winterVarroaWindow.to) {
report(
windowScope,
has(windowScope, 'from') ? 'from' : 'to',
'Das Fenster muss innerhalb eines Jahres liegen: Beginn vor Ende.',
);
winterVarroaWindow = { ...d.winterVarroaWindow };
}
const ruleScope = childScope(scope, 'rules');
const rules = { ...d.rules };
for (const id of AUTOPILOT_RULE_IDS) {
rules[id] = bool(ruleScope, id, d.rules[id]);
}
return {
honeySuperMinFrames: intIn(
scope,
'honeySuperMinFrames',
d.honeySuperMinFrames,
1,
40,
),
varroaMeasureIntervalDays: intIn(
scope,
'varroaMeasureIntervalDays',
d.varroaMeasureIntervalDays,
7,
90,
),
winterVarroaWindow,
queenMaxAgeYears: intIn(
scope,
'queenMaxAgeYears',
d.queenMaxAgeYears,
1,
10,
),
suggestionTtlDays: intIn(
scope,
'suggestionTtlDays',
d.suggestionTtlDays,
1,
365,
),
maxStepsPerColony: intIn(
scope,
'maxStepsPerColony',
d.maxStepsPerColony,
1,
3,
),
rules,
};
},
);
@@ -0,0 +1,93 @@
import { RawMaterialKind } from '../../../generated/prisma';
import {
bool,
childScope,
has,
isCleared,
makeParser,
numberIn,
optionalIntIn,
stringIn,
} from './parse-helpers';
import type {
NumberFormatConfig,
RuleConfigParser,
SimpleRuleConfig,
} from './rule-config.types';
const MAX_DUE_IN_DAYS = 365;
/** Far above any minimum stock of wax or propolis. */
const MAX_MIN_GRAMS = 1_000_000_000;
const MAX_NUMBER_FORMAT_LENGTH = 100;
const RAW_MATERIAL_KINDS = Object.values(RawMaterialKind);
export interface SimpleRuleOptions {
/** Whether the rule notifies by default. */
notify: boolean;
/** RAW_MATERIAL_LOW reads a minimum per kind. */
withMinGrams?: boolean;
}
/**
* Rules that create one task: notify, an optional follow-up interval
* (dueInDays) and, for the raw material rule, minGrams per kind.
*/
export function simpleParser(
trigger: string,
options: SimpleRuleOptions,
): RuleConfigParser<SimpleRuleConfig> {
return makeParser<SimpleRuleConfig>(
trigger,
{ notify: options.notify },
(scope) => {
const config: SimpleRuleConfig = {
notify: bool(scope, 'notify', options.notify),
};
const dueInDays = optionalIntIn(scope, 'dueInDays', 0, MAX_DUE_IN_DAYS);
if (dueInDays !== undefined) {
config.dueInDays = dueInDays;
}
if (
options.withMinGrams &&
has(scope, 'minGrams') &&
!isCleared(scope, 'minGrams')
) {
const minScope = childScope(scope, 'minGrams');
const minGrams: Partial<Record<RawMaterialKind, number>> = {};
for (const kind of RAW_MATERIAL_KINDS) {
if (isCleared(minScope, kind)) {
continue;
}
const grams = numberIn(minScope, kind, Number.NaN, 0, MAX_MIN_GRAMS);
if (!Number.isNaN(grams)) {
minGrams[kind] = grams;
}
}
if (Object.keys(minGrams).length > 0) {
config.minGrams = minGrams;
}
}
return config;
},
);
}
/** The batch/lot number rules: one format string with placeholders. */
export function numberFormatParser(
trigger: string,
defaultFormat: string,
): RuleConfigParser<NumberFormatConfig> {
return makeParser<NumberFormatConfig>(
trigger,
{ format: defaultFormat },
(scope) => ({
format: stringIn(
scope,
'format',
defaultFormat,
1,
MAX_NUMBER_FORMAT_LENGTH,
),
}),
);
}
@@ -0,0 +1,106 @@
import {
asRecord,
bool,
fieldPath,
has,
intIn,
makeParser,
optionalIntIn,
report,
stringIn,
type Scope,
} from './parse-helpers';
import type { PlanStepConfig, SplitScheduleConfig } from './rule-config.types';
type SplitVariant = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN';
const MAX_STEPS_PER_VARIANT = 10;
const MAX_OFFSET_DAYS = 120;
const MAX_WINDOW_DAYS = 30;
const STEP_NAME = /^[A-Z][A-Z_]{1,39}$/;
export const DEFAULT_SPLIT_SCHEDULE_CONFIG: SplitScheduleConfig = {
RAISE_OWN: [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
],
QUEEN_CELL: [{ step: 'EGGS_CHECK', offsetDays: 21 }],
QUEEN: [{ step: 'ACCEPTANCE_CHECK', offsetDays: 7 }],
notify: false,
};
function clonedSteps(variant: SplitVariant): PlanStepConfig[] {
return DEFAULT_SPLIT_SCHEDULE_CONFIG[variant].map((step) => ({ ...step }));
}
/** Scope of one list entry: its path is `VARIANT[index]`. */
function stepScope(
scope: Scope,
variant: SplitVariant,
index: number,
raw: unknown,
): Scope | null {
const record = asRecord(raw);
const path = `${fieldPath(scope, variant)}[${index}]`;
if (record === null) {
scope.issues.push({
field: path,
message: 'Bitte jeden Schritt als Gruppe senden.',
});
return null;
}
return { raw: record, path, issues: scope.issues };
}
function readSteps(scope: Scope, variant: SplitVariant): PlanStepConfig[] {
if (!has(scope, variant)) {
return clonedSteps(variant);
}
const list = scope.raw[variant];
if (!Array.isArray(list) || list.length > MAX_STEPS_PER_VARIANT) {
report(
scope,
variant,
`Bitte eine Liste mit höchstens ${MAX_STEPS_PER_VARIANT} Schritten senden.`,
);
return clonedSteps(variant);
}
const before = scope.issues.length;
const steps: PlanStepConfig[] = [];
list.forEach((entry: unknown, index) => {
const item = stepScope(scope, variant, index, entry);
if (item === null) {
return;
}
if (!has(item, 'step')) {
scope.issues.push({
field: `${item.path}.step`,
message: 'Bitte den Schritt angeben.',
});
return;
}
const step: PlanStepConfig = {
step: stringIn(item, 'step', '', 2, 40, STEP_NAME),
offsetDays: intIn(item, 'offsetDays', 0, 0, MAX_OFFSET_DAYS),
};
const windowDays = optionalIntIn(item, 'windowDays', 0, MAX_WINDOW_DAYS);
if (windowDays !== undefined) {
step.windowDays = windowDays;
}
steps.push(step);
});
// One bad entry makes the whole list unreliable: back to the default plan.
return scope.issues.length > before ? clonedSteps(variant) : steps;
}
export const splitScheduleParser = makeParser<SplitScheduleConfig>(
'SPLIT_SCHEDULE',
DEFAULT_SPLIT_SCHEDULE_CONFIG,
(scope) => ({
RAISE_OWN: readSteps(scope, 'RAISE_OWN'),
QUEEN_CELL: readSteps(scope, 'QUEEN_CELL'),
QUEEN: readSteps(scope, 'QUEEN'),
notify: bool(scope, 'notify', DEFAULT_SPLIT_SCHEDULE_CONFIG.notify),
}),
);
@@ -0,0 +1,188 @@
import { ColonyType } from '../../../generated/prisma';
import {
bool,
childScope,
enumList,
has,
intIn,
makeParser,
months,
numberIn,
report,
type Scope,
} from './parse-helpers';
import type { VarroaThresholdConfig } from './rule-config.types';
const MAX_RECHECK_DAYS = 60;
export const DEFAULT_VARROA_THRESHOLD_CONFIG: VarroaThresholdConfig = {
naturalFall: {
seasonMonths: [3, 4, 5, 6, 7, 8, 9, 10],
yellowFrom: 5,
redAbove: 10,
winterRedAbove: 0.5,
},
infestationPercent: {
summerMonths: [5, 6, 7],
summer: { yellowFrom: 1, redAbove: 5 },
autumnMonths: [8, 9, 10],
autumn: { yellowFrom: 2, redAbove: 5 },
},
youngColonyFactor: 0.5,
youngColonyTypes: ['YOUNG_COLONY', 'NUCLEUS_COLONY', 'MATING_NUCLEUS'],
recheckAfterDays: 14,
notify: true,
};
/** Upper limits: mites per day cannot get near 1000; a percentage stops at 100. */
const MAX_MITES_PER_DAY = 1000;
const MAX_PERCENT = 100;
/**
* The key before the bands existed: natural mite fall that counts as red.
* A present but unusable value is reported.
*/
function legacyThreshold(scope: Scope): number | undefined {
if (!has(scope, 'thresholdMitesPerDay')) {
return undefined;
}
const value = scope.raw.thresholdMitesPerDay;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return value;
}
report(
scope,
'thresholdMitesPerDay',
'Bitte eine Zahl größer als 0 angeben.',
);
return undefined;
}
interface Band {
yellowFrom: number;
redAbove: number;
}
interface BandLimits {
max: number;
/** The red limit itself must be above zero. */
redAboveZero?: boolean;
}
/**
* yellowFrom and redAbove of one band. An absent yellowFrom never ends above
* redAbove; a given one that does is reported and replaced.
*/
function readBand(
scope: Scope,
defaults: Band,
limits: BandLimits,
legacyRed?: number,
): Band {
const redAbove = numberIn(
scope,
'redAbove',
legacyRed ?? defaults.redAbove,
0,
limits.max,
{ exclusiveMin: limits.redAboveZero },
);
const yellowDefault = Math.min(defaults.yellowFrom, redAbove);
const yellowFrom = numberIn(
scope,
'yellowFrom',
yellowDefault,
0,
limits.max,
);
if (yellowFrom > redAbove) {
report(
scope,
'yellowFrom',
'Der gelbe Wert darf nicht über dem roten Wert liegen.',
);
return { yellowFrom: yellowDefault, redAbove };
}
return { yellowFrom, redAbove };
}
const COLONY_TYPES = Object.values(ColonyType);
export const varroaThresholdParser = makeParser<VarroaThresholdConfig>(
'VARROA_THRESHOLD',
DEFAULT_VARROA_THRESHOLD_CONFIG,
(scope) => {
const d = DEFAULT_VARROA_THRESHOLD_CONFIG;
const legacy = legacyThreshold(scope);
const fall = childScope(scope, 'naturalFall');
const fallBand = readBand(
fall,
d.naturalFall,
{ max: MAX_MITES_PER_DAY, redAboveZero: true },
legacy,
);
const naturalFall = {
seasonMonths: months(fall, 'seasonMonths', d.naturalFall.seasonMonths),
...fallBand,
winterRedAbove: numberIn(
fall,
'winterRedAbove',
d.naturalFall.winterRedAbove,
0,
MAX_MITES_PER_DAY,
),
};
const percent = childScope(scope, 'infestationPercent');
const infestationPercent = {
summerMonths: months(
percent,
'summerMonths',
d.infestationPercent.summerMonths,
),
summer: readBand(
childScope(percent, 'summer'),
d.infestationPercent.summer,
{ max: MAX_PERCENT },
),
autumnMonths: months(
percent,
'autumnMonths',
d.infestationPercent.autumnMonths,
),
autumn: readBand(
childScope(percent, 'autumn'),
d.infestationPercent.autumn,
{ max: MAX_PERCENT },
),
};
return {
naturalFall,
infestationPercent,
youngColonyFactor: numberIn(
scope,
'youngColonyFactor',
d.youngColonyFactor,
0,
1,
{ exclusiveMin: true },
),
youngColonyTypes: enumList(
scope,
'youngColonyTypes',
d.youngColonyTypes,
COLONY_TYPES,
),
recheckAfterDays: intIn(
scope,
'recheckAfterDays',
d.recheckAfterDays,
0,
MAX_RECHECK_DAYS,
),
notify: bool(scope, 'notify', d.notify),
};
},
);
@@ -0,0 +1,112 @@
import {
childScope,
has,
makeParser,
numberIn,
optionalNumberIn,
report,
type Scope,
} from './parse-helpers';
import type { WaterContentConfig } from './rule-config.types';
const MIN_WATER_PERCENT = 10;
const MAX_WATER_PERCENT = 30;
const MAX_HONEY_TYPES = 50;
const MAX_HONEY_TYPE_LENGTH = 100;
export const DEFAULT_WATER_CONTENT_CONFIG: WaterContentConfig = {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: {},
};
function percent(scope: Scope, key: string, fallback: number): number {
return numberIn(scope, key, fallback, MIN_WATER_PERCENT, MAX_WATER_PERCENT);
}
const ORDER_MESSAGE =
'Die Warnschwelle darf nicht über der Bestätigungsschwelle liegen.';
function readHoneyTypes(scope: Scope): WaterContentConfig['byHoneyType'] {
const types = childScope(scope, 'byHoneyType');
const names = Object.keys(types.raw);
if (names.length > MAX_HONEY_TYPES) {
report(scope, 'byHoneyType', `Höchstens ${MAX_HONEY_TYPES} Sorten.`);
return {};
}
const entries: [
string,
{ warnAbovePercent?: number; confirmAbovePercent?: number },
][] = [];
for (const name of names) {
if (name.length === 0 || name.length > MAX_HONEY_TYPE_LENGTH) {
report(types, name.slice(0, 30), 'Der Sortenname ist ungültig.');
continue;
}
const entry = childScope(types, name);
const before = entry.issues.length;
const value: { warnAbovePercent?: number; confirmAbovePercent?: number } =
{};
const warn = optionalNumberIn(
entry,
'warnAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
const confirm = optionalNumberIn(
entry,
'confirmAbovePercent',
MIN_WATER_PERCENT,
MAX_WATER_PERCENT,
);
if (warn !== undefined && confirm !== undefined && warn > confirm) {
report(entry, 'warnAbovePercent', ORDER_MESSAGE);
}
if (entry.issues.length > before) {
continue;
}
if (warn !== undefined) {
value.warnAbovePercent = warn;
}
if (confirm !== undefined) {
value.confirmAbovePercent = confirm;
}
entries.push([name, value]);
}
// fromEntries defines own properties, so a name like "__proto__" is harmless.
return Object.fromEntries(entries);
}
export const waterContentParser = makeParser<WaterContentConfig>(
'HONEY_WATER_CONTENT',
DEFAULT_WATER_CONTENT_CONFIG,
(scope) => {
const d = DEFAULT_WATER_CONTENT_CONFIG;
let warnAbovePercent = percent(
scope,
'warnAbovePercent',
d.warnAbovePercent,
);
let confirmAbovePercent = percent(
scope,
'confirmAbovePercent',
d.confirmAbovePercent,
);
if (warnAbovePercent > confirmAbovePercent) {
report(
scope,
has(scope, 'warnAbovePercent')
? 'warnAbovePercent'
: 'confirmAbovePercent',
ORDER_MESSAGE,
);
warnAbovePercent = d.warnAbovePercent;
confirmAbovePercent = d.confirmAbovePercent;
}
return {
warnAbovePercent,
confirmAbovePercent,
byHoneyType: readHoneyTypes(scope),
};
},
);
@@ -26,6 +26,24 @@ describe('varroaThresholdOf', () => {
).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 }],
@@ -1,14 +1,19 @@
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 = 10;
export const DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY =
DEFAULT_VARROA_THRESHOLD_CONFIG.naturalFall.redAbove;
/**
* Effective varroa threshold of the VARROA_THRESHOLD rule.
* 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 a
* positive finite number (a hand-edited config must never disable the check).
* - 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,
@@ -19,12 +24,5 @@ export function varroaThresholdOf(
if (!rule.active) {
return null;
}
const config = rule.config;
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
return DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
}
const value = (config as Record<string, unknown>).thresholdMitesPerDay;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: DEFAULT_VARROA_THRESHOLD_MITES_PER_DAY;
return ruleConfigOf('VARROA_THRESHOLD', rule.config).naturalFall.redAbove;
}
@@ -29,6 +29,7 @@ const BUSINESS_ENTITY_KEYS = [
'queenColonyHistory',
'inspection',
'taskTemplate',
'suggestion',
'task',
'varroaMeasurement',
'swarmControlCheck',
@@ -237,6 +238,157 @@ describe('BackupService', () => {
];
expect(call.create.broodFrames).toBe(5);
});
it('maps a legacy free-text feedStatus of an old bundle on import', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: ' Niedrig ', notes: null },
{ ...base, id: 'i-2', feedStatus: 'halb voll', notes: 'x' },
{ ...base, id: 'i-3', feedStatus: 'CRITICAL', notes: null },
{ ...base, id: 'i-4', feedStatus: '', notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ where: { id: string }; create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({ id: 'i-1', feedStatus: 'WEAK', notes: null }),
expect.objectContaining({
id: 'i-2',
feedStatus: null,
notes: 'x\nFutter (alt): halb voll',
}),
expect.objectContaining({
id: 'i-3',
feedStatus: 'CRITICAL',
notes: null,
}),
expect.objectContaining({ id: 'i-4', feedStatus: null, notes: 'y' }),
]);
});
});
describe('legacy feedStatus of unexpected type', () => {
it('keeps a non-string feedStatus in the notes as JSON instead of dropping it', async () => {
const base = { colonyId: 'colony-1', userId: 'known-user' };
const bundle = {
version: 3,
data: {
inspection: [
{ ...base, id: 'i-1', feedStatus: 3, notes: null },
{ ...base, id: 'i-2', feedStatus: { level: 'low' }, notes: 'x' },
{ ...base, id: 'i-3', feedStatus: null, notes: 'y' },
],
},
};
await service.importAll(bundle as never, 'admin-1', TRUSTED);
const created = (
tx.inspection.upsert.mock.calls as [
[{ create: Record<string, unknown> }],
]
).map(([call]) => call.create);
expect(created).toEqual([
expect.objectContaining({
feedStatus: null,
notes: 'Futter (alt): 3',
}),
expect.objectContaining({
feedStatus: null,
notes: 'x\nFutter (alt): {"level":"low"}',
}),
expect.objectContaining({ feedStatus: null, notes: 'y' }),
]);
});
});
describe('suggestions', () => {
it('exports and restores suggestions with their decision before tasks', async () => {
const suggestion = {
id: 'suggestion-1',
colonyId: 'colony-1',
status: 'ACCEPTED',
dedupeKey: 'INSPECTION_INTERVAL:colony-1:insp-9',
decidedAt: '2026-10-14T08:00:00.000Z',
decidedByUserId: 'known-user',
decisionNote: 'Passt',
};
const orphanDecision = {
...suggestion,
id: 'suggestion-2',
dedupeKey: null,
decidedByUserId: 'deleted-user',
};
const open = {
...suggestion,
id: 'suggestion-3',
status: 'OPEN',
decidedAt: null,
decidedByUserId: null,
decisionNote: null,
};
prisma.suggestion.findMany.mockResolvedValue([
suggestion,
orphanDecision,
open,
]);
prisma.colony.findMany.mockResolvedValue([{ id: 'colony-1' }]);
prisma.task.findMany.mockResolvedValue([
{ id: 'task-1', sourceSuggestionId: 'suggestion-1' },
]);
const bundle = await service.exportAll();
expect(bundle.data.suggestion).toEqual([
suggestion,
orphanDecision,
open,
]);
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(summary.imported.suggestion).toBe(3);
const creates = (
tx.suggestion.upsert.mock.calls as [
{ create: { id: string; decidedByUserId: string | null } },
][]
).map(([call]) => call.create);
// A decider that is gone resolves like any other user id; no decision
// stays without one.
expect(creates.map((row) => row.decidedByUserId)).toEqual([
'known-user',
'admin-1',
null,
]);
const suggestionOrder = tx.suggestion.upsert.mock.invocationCallOrder[0];
const taskOrder = tx.task.upsert.mock.invocationCallOrder[0];
expect(suggestionOrder).toBeLessThan(taskOrder);
const colonyOrder = tx.colony.upsert.mock.invocationCallOrder[0];
expect(colonyOrder).toBeLessThan(suggestionOrder);
});
it('does not restore suggestions from an unsealed file', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-10-14T00:00:00.000Z',
data: { suggestion: [{ id: 'suggestion-1' }] },
},
'admin-1',
{ trusted: false },
);
expect(tx.suggestion.upsert).not.toHaveBeenCalled();
});
});
describe('importAll', () => {
+50 -8
View File
@@ -9,6 +9,10 @@ import {
isAllowedDocumentMimeType,
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType, Prisma } from '../../generated/prisma';
import {
@@ -162,6 +166,28 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null;
}
// Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row {
if (!('feedStatus' in row)) {
return row;
}
const raw = row.feedStatus;
// A value that is neither text nor empty cannot map; keep it in the notes.
const { feedStatus, notesSuffix } =
raw === null || raw === undefined || typeof raw === 'string'
? feedConditionFromInput(raw)
: {
feedStatus: null,
notesSuffix: `Futter (alt): ${JSON.stringify(raw)}`,
};
return {
...row,
feedStatus,
notes: appendLegacyFeedNote(asString(row, 'notes'), notesSuffix),
};
}
function documentsStorageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
@@ -233,6 +259,7 @@ export class BackupService {
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.suggestion = await this.prisma.suggestion.findMany();
data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
@@ -538,16 +565,14 @@ export class BackupService {
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
for (const row of rowsOf('inspection')) {
const inspection = {
...withCurrentFeedVocabulary(row),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.inspection.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
create: inspection as never,
update: updateWith(inspection) as never,
});
}
imported.inspection = rowsOf('inspection').length;
@@ -577,6 +602,23 @@ export class BackupService {
}
imported.taskTemplate = rowsOf('taskTemplate').length;
// Before the tasks: Task.sourceSuggestionId points at them. After the
// colonies: a suggestion may hang on one.
for (const row of rowsOf('suggestion')) {
const data = {
...row,
decidedByUserId: resolveOptionalUserId(
asString(row, 'decidedByUserId'),
),
};
await tx.suggestion.upsert({
where: { id: row.id as string },
create: data as never,
update: updateWith(data) as never,
});
}
imported.suggestion = rowsOf('suggestion').length;
for (const row of rowsOf('task')) {
await tx.task.upsert({
where: { id: row.id as string },
@@ -122,6 +122,14 @@ describe('CalendarSyncService', () => {
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
it('does not enqueue a WITHDRAWAL entry yet (the switch arrives with Task 3)', async () => {
settingsService.get.mockResolvedValue(enabledSettings);
await service.enqueue('WITHDRAWAL', 'treatment-1');
expect(prisma.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
});
it('does nothing when the source type toggle is off', async () => {
settingsService.get.mockResolvedValue({
...enabledSettings,
@@ -640,6 +648,16 @@ describe('CalendarSyncService', () => {
expectSettled({ status: 'FAILED', attempts: 5, lastError: 'db error' });
});
it('writes no Graph event for a WITHDRAWAL entry before Task 12', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ sourceType: 'WITHDRAWAL', sourceId: 't1' }),
]);
await service.processOutbox();
expect(graphClient.createEvent).not.toHaveBeenCalled();
});
it('builds titles per source type matching the read-only calendar view', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry({ sourceType: 'INSPECTION', sourceId: 'i1' }),
@@ -103,6 +103,8 @@ export class CalendarSyncService {
FEEDING: settings.syncFeeding,
HARVEST: settings.syncHarvest,
QUEEN_ASSIGNMENT: settings.syncQueenAssignment,
// Switch in the settings arrives with Task 3.
WITHDRAWAL: false,
};
return flagBySourceType[sourceType];
}
@@ -499,6 +501,9 @@ export class CalendarSyncService {
date: assignment.startDate,
};
}
case 'WITHDRAWAL':
// Switch Task 3, content Task 12: nothing to write before that.
return null;
}
}
+100
View File
@@ -0,0 +1,100 @@
import {
addDays,
berlinDateTime,
berlinDayOf,
berlinToday,
dayToDate,
daysBetween,
} from './dates';
describe('dates', () => {
it('berlinToday at 2026-03-28T23:30Z is 2026-03-29', () => {
expect(berlinToday(new Date('2026-03-28T23:30:00Z'))).toBe('2026-03-29');
});
it('the 04:30 Berlin job time is the same calendar day', () => {
expect(berlinToday(new Date('2026-10-13T02:30:00Z'))).toBe('2026-10-13');
});
it('berlinToday defaults to the current instant', () => {
expect(berlinToday()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it('berlinDayOf uses the Berlin calendar day, not the UTC one', () => {
expect(berlinDayOf(new Date('2026-12-31T23:30:00Z'))).toBe('2027-01-01');
expect(berlinDayOf(new Date('2026-06-30T21:59:00Z'))).toBe('2026-06-30');
expect(berlinDayOf(new Date('2026-06-30T22:00:00Z'))).toBe('2026-07-01');
});
it('berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts', () => {
expect(berlinDateTime('2026-03-29', '06:30').toISOString()).toBe(
'2026-03-29T04:30:00.000Z',
);
});
it('berlinDateTime 2026-10-25 06:30 is 05:30Z after DST ends', () => {
expect(berlinDateTime('2026-10-25', '06:30').toISOString()).toBe(
'2026-10-25T05:30:00.000Z',
);
});
it('berlinDateTime uses winter and summer offsets on ordinary days', () => {
expect(berlinDateTime('2026-01-15', '06:30').toISOString()).toBe(
'2026-01-15T05:30:00.000Z',
);
expect(berlinDateTime('2026-07-15', '06:30').toISOString()).toBe(
'2026-07-15T04:30:00.000Z',
);
});
it('berlinDateTime handles midnight and the evening before a DST change', () => {
expect(berlinDateTime('2026-03-29', '00:00').toISOString()).toBe(
'2026-03-28T23:00:00.000Z',
);
expect(berlinDateTime('2026-10-25', '00:00').toISOString()).toBe(
'2026-10-24T22:00:00.000Z',
);
expect(berlinDateTime('2026-03-28', '21:00').toISOString()).toBe(
'2026-03-28T20:00:00.000Z',
);
});
it('berlinDateTime rejects malformed input', () => {
expect(() => berlinDateTime('2026-3-29', '06:30')).toThrow();
expect(() => berlinDateTime('2026-03-29', '6:30')).toThrow();
expect(() => berlinDateTime('2026-03-29', '25:00')).toThrow();
});
it('dayToDate is midnight UTC', () => {
expect(dayToDate('2026-05-05').toISOString()).toBe(
'2026-05-05T00:00:00.000Z',
);
});
it('dayToDate rejects impossible days', () => {
expect(() => dayToDate('2026-02-30')).toThrow();
expect(() => dayToDate('2026-13-01')).toThrow();
expect(() => dayToDate('2026-04-31')).toThrow();
expect(dayToDate('2024-02-29').toISOString()).toBe(
'2024-02-29T00:00:00.000Z',
);
});
it('addDays crosses month ends', () => {
expect(addDays('2026-02-27', 2)).toBe('2026-03-01');
});
it('addDays goes backwards and over year ends', () => {
expect(addDays('2026-01-01', -1)).toBe('2025-12-31');
expect(addDays('2026-12-31', 1)).toBe('2027-01-01');
});
it('daysBetween counts calendar days', () => {
expect(daysBetween('2026-05-01', '2026-05-09')).toBe(8);
expect(daysBetween('2026-05-09', '2026-05-01')).toBe(-8);
});
it('daysBetween counts whole days across a DST change', () => {
expect(daysBetween('2026-03-28', '2026-03-30')).toBe(2);
});
});
+101
View File
@@ -0,0 +1,101 @@
const BERLIN_TIME_ZONE = 'Europe/Berlin';
const MS_PER_DAY = 86_400_000;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
// en-CA formats a date as YYYY-MM-DD.
const berlinDayFormat = new Intl.DateTimeFormat('en-CA', {
timeZone: BERLIN_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const berlinPartsFormat = new Intl.DateTimeFormat('en-GB', {
timeZone: BERLIN_TIME_ZONE,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
/** The calendar day (YYYY-MM-DD) of an instant in Europe/Berlin. */
export function berlinDayOf(instant: Date): string {
return berlinDayFormat.format(instant);
}
/** Today's calendar day (YYYY-MM-DD) in Europe/Berlin. */
export function berlinToday(now: Date = new Date()): string {
return berlinDayOf(now);
}
/** A stored day (YYYY-MM-DD) as the DateTime used for dates: midnight UTC. */
export function dayToDate(day: string): Date {
if (!DAY_PATTERN.test(day)) {
throw new Error(`Invalid day: ${day}`);
}
const date = new Date(`${day}T00:00:00.000Z`);
// 2026-02-30 parses to March 2nd: only a day that round-trips exists.
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== day) {
throw new Error(`Invalid day: ${day}`);
}
return date;
}
function dayNumber(day: string): number {
return Math.round(dayToDate(day).getTime() / MS_PER_DAY);
}
/** Calendar arithmetic on YYYY-MM-DD strings; no time zone involved. */
export function addDays(day: string, days: number): string {
const result = new Date(dayToDate(day).getTime() + days * MS_PER_DAY);
return result.toISOString().slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
export function daysBetween(from: string, to: string): number {
return dayNumber(to) - dayNumber(from);
}
/** Offset of Europe/Berlin to UTC at an instant, in milliseconds. */
function berlinOffsetMs(instant: Date): number {
const parts: Record<string, number> = {};
for (const part of berlinPartsFormat.formatToParts(instant)) {
if (part.type !== 'literal') {
parts[part.type] = Number(part.value);
}
}
const asUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
// The instant without its milliseconds: the formatter works in seconds.
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* The instant at which the Berlin wall clock shows `day` `hhmm`. The offset
* is taken at the target day itself (applied twice, so the day a clock change
* happens comes out right): 06:30 is 04:30Z in summer and 05:30Z in winter.
*/
export function berlinDateTime(day: string, hhmm: string): Date {
const time = TIME_PATTERN.exec(hhmm);
if (!time) {
throw new Error(`Invalid time: ${hhmm}`);
}
const wallClockAsUtc =
dayToDate(day).getTime() +
(Number(time[1]) * 60 + Number(time[2])) * 60_000;
let instant = new Date(
wallClockAsUtc - berlinOffsetMs(new Date(wallClockAsUtc)),
);
instant = new Date(wallClockAsUtc - berlinOffsetMs(instant));
return instant;
}
@@ -0,0 +1,56 @@
import {
FEED_CONDITION_LABELS_DE,
appendLegacyFeedNote,
feedConditionFromInput,
feedConditionFromLegacy,
} from './feed-vocabulary';
describe('feed vocabulary', () => {
it.each([
[' Ausreichend ', 'GOOD', null],
['niedrig', 'WEAK', null],
['KRITISCH', 'CRITICAL', null],
['', null, null],
[' ', null, null],
[null, null, null],
[undefined, null, null],
['halb voll', null, 'Futter (alt): halb voll'],
])('maps %p', (text, feedStatus, notesSuffix) => {
expect(feedConditionFromLegacy(text)).toEqual({ feedStatus, notesSuffix });
});
it('labels every condition in German', () => {
expect(FEED_CONDITION_LABELS_DE).toEqual({
EXCELLENT: 'Reichlich',
GOOD: 'Ausreichend',
AVERAGE: 'Mittel',
WEAK: 'Niedrig',
CRITICAL: 'Kritisch',
});
});
it.each([
['critical', 'CRITICAL', null],
[' EXCELLENT ', 'EXCELLENT', null],
['niedrig', 'WEAK', null],
['constructor', null, 'Futter (alt): constructor'],
['halb voll', null, 'Futter (alt): halb voll'],
])(
'accepts condition names or legacy text: %p',
(text, feedStatus, notesSuffix) => {
expect(feedConditionFromInput(text)).toEqual({ feedStatus, notesSuffix });
},
);
describe('appendLegacyFeedNote', () => {
it.each([
[null, null, null],
['x', null, 'x'],
[null, 'Futter (alt): a', 'Futter (alt): a'],
['', 'Futter (alt): a', 'Futter (alt): a'],
['x', 'Futter (alt): a', 'x\nFutter (alt): a'],
])('joins %p and %p', (notes, suffix, expected) => {
expect(appendLegacyFeedNote(notes, suffix)).toBe(expected);
});
});
});
@@ -0,0 +1,67 @@
import { ColonyCondition } from '../../generated/prisma';
/** German display labels for the one feed vocabulary (colony card and inspection). */
export const FEED_CONDITION_LABELS_DE: Record<ColonyCondition, string> = {
EXCELLENT: 'Reichlich',
GOOD: 'Ausreichend',
AVERAGE: 'Mittel',
WEAK: 'Niedrig',
CRITICAL: 'Kritisch',
};
export interface LegacyFeedMapping {
feedStatus: ColonyCondition | null;
/** 'Futter (alt): <Text>' for text that maps to no condition, else null. */
notesSuffix: string | null;
}
const LEGACY_WORDS: ReadonlyMap<string, ColonyCondition> = new Map([
['ausreichend', 'GOOD'],
['niedrig', 'WEAK'],
['kritisch', 'CRITICAL'],
]);
const LEGACY_NOTE_PREFIX = 'Futter (alt): ';
/**
* Maps the old free-text feed status (trimmed, case-insensitive) onto the
* enum. Empty text maps to nothing; unknown text is kept for the notes.
*/
export function feedConditionFromLegacy(
text: string | null | undefined,
): LegacyFeedMapping {
const trimmed = (text ?? '').trim();
if (trimmed === '') {
return { feedStatus: null, notesSuffix: null };
}
const mapped = LEGACY_WORDS.get(trimmed.toLowerCase());
if (mapped) {
return { feedStatus: mapped, notesSuffix: null };
}
return { feedStatus: null, notesSuffix: `${LEGACY_NOTE_PREFIX}${trimmed}` };
}
/**
* Like feedConditionFromLegacy, but a value that already is a condition name
* (any case) is taken as it is. For import paths that see both old and new data.
*/
export function feedConditionFromInput(
text: string | null | undefined,
): LegacyFeedMapping {
const upper = (text ?? '').trim().toUpperCase();
if (Object.hasOwn(FEED_CONDITION_LABELS_DE, upper)) {
return { feedStatus: upper as ColonyCondition, notesSuffix: null };
}
return feedConditionFromLegacy(text);
}
/** Appends the legacy suffix to the notes, separated by a newline. */
export function appendLegacyFeedNote(
notes: string | null,
suffix: string | null,
): string | null {
if (!suffix) {
return notes;
}
return notes ? `${notes}\n${suffix}` : suffix;
}
@@ -1,4 +1,5 @@
import { DashboardService } from './dashboard.service';
import { berlinToday } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
describe('DashboardService', () => {
@@ -11,6 +12,7 @@ describe('DashboardService', () => {
honeyBatch: { count: jest.Mock };
stockItem: { findMany: jest.Mock };
swarmControlCheck: { findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
};
beforeEach(() => {
@@ -28,6 +30,8 @@ describe('DashboardService', () => {
honeyBatch: { count: jest.fn().mockResolvedValue(1) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) },
// No stored SEASON_AUTOPILOT rule: the built-in setting applies.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
};
service = new DashboardService(prisma as unknown as PrismaService);
});
@@ -137,33 +141,44 @@ describe('DashboardService', () => {
});
describe('OLD_QUEEN', () => {
it('flags a colony whose queen is at least 3 years old', async () => {
const currentYear = Number(berlinToday().slice(0, 4));
const queenedColony = (hatchYear: number) => [
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear } },
];
it('OLD_QUEEN starts at queenMaxAgeYears (2)', async () => {
prisma.colony.findMany
.mockResolvedValueOnce([]) // attentionColonies
.mockResolvedValueOnce([
{ id: 'colony-1', code: 'V-001', currentQueen: { hatchYear: 2020 } },
]);
.mockResolvedValueOnce(queenedColony(currentYear - 2));
const alerts = await service.alerts();
expect(alerts).toHaveLength(1);
expect(alerts[0].type).toBe('OLD_QUEEN');
expect(alerts[0].relatedId).toBe('colony-1');
expect(alerts[0].message).toContain('2 Jahre');
});
it('does not flag a queen younger than the threshold', async () => {
const currentYear = new Date().getFullYear();
prisma.colony.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: 'colony-1',
code: 'V-001',
currentQueen: { hatchYear: currentYear },
},
]);
prisma.colony.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(queenedColony(currentYear - 1));
const alerts = await service.alerts();
expect(await service.alerts()).toHaveLength(0);
});
expect(alerts).toHaveLength(0);
it('follows queenMaxAgeYears of the SEASON_AUTOPILOT rule', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'rule-1',
active: true,
actionTemplate: null,
config: { queenMaxAgeYears: 4 },
});
prisma.colony.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(queenedColony(currentYear - 3));
expect(await service.alerts()).toHaveLength(0);
});
});
});
@@ -1,6 +1,9 @@
import { Injectable } from '@nestjs/common';
import { readRule } from '../automation/rule-config';
import { berlinToday } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { isQueenAged } from '../season/queen-age';
export interface DashboardSummary {
apiariesActive: number;
@@ -24,10 +27,6 @@ export interface DashboardAlert {
}
const ATTENTION_COLONY_STATUSES = ['WEAK', 'QUEENLESS', 'CONSPICUOUS'] as const;
// Queens rarely stay reliably productive past their third season - laying
// rate and swarming risk both worsen well before then, so this flags a
// colony for evaluation rather than demanding immediate requeening.
const OLD_QUEEN_AGE_YEARS = 3;
@Injectable()
export class DashboardService {
@@ -158,15 +157,23 @@ export class DashboardService {
});
}
}
const currentYear = now.getFullYear();
// The age comes from the rule, like QueenAged and the autopilot: a flag
// for evaluation rather than a demand for immediate requeening.
const { config: autopilot } = await readRule(
this.prisma,
'SEASON_AUTOPILOT',
);
const today = berlinToday(now);
const currentYear = Number(today.slice(0, 4));
for (const colony of queenedColonies) {
// The where clause above already requires currentQueen and its
// hatchYear to exist, but the relation is still nullable in the type.
if (!colony.currentQueen?.hatchYear) {
continue;
}
const ageYears = currentYear - colony.currentQueen.hatchYear;
if (ageYears >= OLD_QUEEN_AGE_YEARS) {
const { hatchYear } = colony.currentQueen;
if (isQueenAged(hatchYear, today, autopilot.queenMaxAgeYears)) {
const ageYears = currentYear - hatchYear;
alerts.push({
type: 'OLD_QUEEN',
message: `Königin in Volk ${colony.code} ist ${ageYears} Jahre alt - Nachzucht prüfen`,
@@ -56,8 +56,18 @@ export interface DomainEventPayloads {
WithdrawalEnded: { colonyId: string; endedOn: string };
TaskDue: { dueOn: string };
QueenAged: { hatchYear: number; colonyId: string | null };
VarroaMeasurementDue: { dueOn: string; lastMeasuredOn: string | null };
InspectionOverdue: { dueOn: string; lastInspectedOn: string | null };
// kind/anchor/firstOfYear came with T4a: absent on older events.
VarroaMeasurementDue: {
dueOn: string;
lastMeasuredOn: string | null;
kind?: 'INTERVAL' | 'WINTER_WINDOW';
anchor?: string;
};
InspectionOverdue: {
dueOn: string;
lastInspectedOn: string | null;
firstOfYear?: boolean;
};
}
export type DomainEventType = keyof DomainEventPayloads;
@@ -1,6 +1,8 @@
import { NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import { PrismaService } from '../prisma/prisma.service';
import { ColonyCondition } from '../../generated/prisma';
import { FEED_CONDITION_LABELS_DE } from '../common/feed-vocabulary';
import {
PDF_MARGIN,
drawSection,
@@ -83,7 +85,7 @@ export interface ColonyRecordCard {
queenCellsPresent: boolean | null;
gentleness: number | null;
swarmMood: number | null;
feedStatus: string | null;
feedStatus: ColonyCondition | null;
measures: string | null;
notes: string | null;
}[];
@@ -348,7 +350,7 @@ export function renderColonyRecordCardPdf(
yesNo(i.queenCellsPresent),
orDash(i.gentleness),
orDash(i.swarmMood),
orDash(i.feedStatus),
orDash(i.feedStatus ? FEED_CONDITION_LABELS_DE[i.feedStatus] : null),
orDash(i.measures),
orDash(i.notes),
]),
@@ -133,6 +133,42 @@ describe('ImportService', () => {
expect(createArg.data.code).toBe('V-100');
});
it('maps CSV feed text "niedrig" to WEAK and keeps unknown text in the notes', 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,feedStatus,notes',
'V-1,Teststand,PRODUCTION_COLONY,niedrig,',
'V-2,Teststand,PRODUCTION_COLONY,halb voll,Hinweis',
'V-3,Teststand,PRODUCTION_COLONY,CRITICAL,',
].join('\n'),
),
'user-1',
);
expect(report.created).toBe(3);
const data = (
prisma.colony.create.mock.calls as [
[{ data: { feedStatus?: string; notes?: string } }],
]
).map(([call]) => call.data);
expect(data[0]).toMatchObject({ feedStatus: 'WEAK' });
expect(data[0].notes).toBeUndefined();
expect(data[1].feedStatus).toBeUndefined();
expect(data[1].notes).toBe('Hinweis\nFutter (alt): halb voll');
expect(data[2]).toMatchObject({ feedStatus: 'CRITICAL' });
expect(report.rows.map((row) => row.message)).toEqual([
undefined,
'Futterangabe „halb voll“ nicht erkannt, in die Notizen übernommen.',
undefined,
]);
});
it('reports a validation error for an invalid colonyType', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
+22 -3
View File
@@ -6,6 +6,10 @@ import { AuditLogService } from '../audit/audit-log.service';
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { parseCsv } from './csv';
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
@@ -166,6 +170,8 @@ export class ImportService {
};
}
// Old exports carry free text here; known words map to the enum.
const feed = feedConditionFromInput(record.feedStatus);
const plain = {
code: emptyToUndefined(record.code),
apiaryId: apiary.id,
@@ -174,9 +180,13 @@ export class ImportService {
originText: emptyToUndefined(record.originText),
strength: parseNumberField(record.strength),
broodStatus: emptyToUndefined(record.broodStatus),
feedStatus: emptyToUndefined(record.feedStatus),
feedStatus: feed.feedStatus ?? undefined,
healthStatus: emptyToUndefined(record.healthStatus),
notes: emptyToUndefined(record.notes),
notes:
appendLegacyFeedNote(
emptyToUndefined(record.notes) ?? null,
feed.notesSuffix,
) ?? undefined,
};
const dto = plainToInstance(CreateColonyDto, plain);
const errors = await validate(dto);
@@ -199,7 +209,16 @@ export class ImportService {
};
}
const colony = await this.prisma.colony.create({ data: { ...dto } });
return { row: rowNumber, status: 'created', id: colony.id };
return {
row: rowNumber,
status: 'created',
id: colony.id,
...(feed.notesSuffix
? {
message: `Futterangabe „${record.feedStatus}“ nicht erkannt, in die Notizen übernommen.`,
}
: {}),
};
}
private async importQueenRow(
@@ -0,0 +1,39 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreateInspectionDto } from './create-inspection.dto';
import { UpdateInspectionDto } from './update-inspection.dto';
async function check(
dtoClass: typeof CreateInspectionDto | typeof UpdateInspectionDto,
feedStatus: unknown,
) {
const dto = plainToInstance(dtoClass, {
colonyId: 'c',
date: '2026-05-01',
feedStatus,
});
return { dto, errors: await validate(dto) };
}
describe('Inspection DTOs feedStatus', () => {
it.each([
['niedrig', 'WEAK'],
[' Kritisch ', 'CRITICAL'],
['AUSREICHEND', 'GOOD'],
['EXCELLENT', 'EXCELLENT'],
['average', 'AVERAGE'],
['', null],
[' ', null],
])('accepts %p as %p', async (input, expected) => {
for (const dtoClass of [CreateInspectionDto, UpdateInspectionDto]) {
const { dto, errors } = await check(dtoClass, input);
expect(errors).toEqual([]);
expect(dto.feedStatus).toBe(expected);
}
});
it.each(['halb voll', 'constructor', 5, {}])('rejects %p', async (input) => {
const { errors } = await check(CreateInspectionDto, input);
expect(errors.map((e) => e.property)).toContain('feedStatus');
});
});
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
@@ -9,6 +10,9 @@ import {
Max,
Min,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { ColonyCondition } from '../../../generated/prisma';
import { feedConditionFromInput } from '../../common/feed-vocabulary';
export class CreateInspectionDto {
@ApiProperty()
@@ -99,10 +103,24 @@ export class CreateInspectionDto {
@Max(5)
combBehavior?: number;
@ApiPropertyOptional()
@ApiPropertyOptional({
enum: ColonyCondition,
nullable: true,
description:
'Feed condition. Also accepts the legacy German words ausreichend (GOOD), niedrig (WEAK) and kritisch (CRITICAL), ignoring case and surrounding whitespace; a blank string counts as null. Other text is rejected.',
})
@IsOptional()
@IsString()
feedStatus?: string;
@Transform(({ value }: { value: unknown }) => {
if (typeof value !== 'string') {
return value;
}
if (value.trim() === '') {
return null;
}
return feedConditionFromInput(value).feedStatus ?? value;
})
@IsEnum(ColonyCondition)
feedStatus?: ColonyCondition;
@ApiPropertyOptional()
@IsOptional()
@@ -0,0 +1,93 @@
import { Logger } from '@nestjs/common';
import { JobPartialFailure } from '../system/job-run.service';
import {
DAILY_EVALUATION_STEP_ORDER,
DailyEvaluationStepName,
runDailyEvaluationSteps,
} from './daily-evaluation-steps';
import type { JobResult } from './job';
function step(
name: DailyEvaluationStepName,
run: (today: string) => Promise<JobResult>,
): { name: DailyEvaluationStepName; run: jest.Mock } {
return { name, run: jest.fn(run) };
}
describe('runDailyEvaluationSteps', () => {
const logger = new Logger('test');
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
it('has the steps of the plan in this order', () =>
expect(DAILY_EVALUATION_STEP_ORDER).toEqual([
'phenology',
'time-events',
'autopilot',
'suggestions-expiry',
'digest',
]));
it('runs the steps in the fixed order with the day and merges their counters', async () => {
const order: string[] = [];
const digest = step('digest', () => {
order.push('digest');
return Promise.resolve({ digests: 3 });
});
const timeEvents = step('time-events', () => {
order.push('time-events');
return Promise.resolve({ TaskDue: 1 });
});
const { result, failed } = await runDailyEvaluationSteps(
[digest, timeEvents],
'2026-06-10',
logger,
);
expect(order).toEqual(['time-events', 'digest']);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
expect(result).toEqual({ TaskDue: 1, digests: 3 });
expect(failed).toEqual([]);
});
it('a failing step leaves the others running and the run FAILED with all counters', async () => {
const phenology = step('phenology', () =>
Promise.resolve({ phenologyApiaries: 2 }),
);
const timeEvents = step('time-events', () =>
Promise.reject(
new JobPartialFailure({ WithdrawalEnded: 1, TaskDue: 0 }, ['TaskDue']),
),
);
const autopilot = step('autopilot', () =>
Promise.reject(new Error('connection reset')),
);
const digest = step('digest', () => Promise.resolve({ digests: 4 }));
const { result, failed, failures } = await runDailyEvaluationSteps(
[phenology, timeEvents, autopilot, digest],
'2026-06-10',
logger,
);
expect(digest.run).toHaveBeenCalled();
expect(failed).toEqual(['time-events', 'autopilot']);
expect(failures).toEqual(['time-events: TaskDue', 'autopilot']);
expect(result).toEqual({
phenologyApiaries: 2,
WithdrawalEnded: 1,
TaskDue: 0,
digests: 4,
});
expect(errorSpy).toHaveBeenCalledWith(
'Step "autopilot" failed: connection reset',
expect.any(String),
);
});
});
@@ -0,0 +1,69 @@
import type { Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { JobPartialFailure } from '../system/job-run.service';
import type { JobResult } from './job';
/** The steps of the daily evaluation, in the order they run. */
export const DAILY_EVALUATION_STEP_ORDER = [
'phenology',
'time-events',
'autopilot',
'suggestions-expiry',
'digest',
] as const;
export type DailyEvaluationStepName =
(typeof DAILY_EVALUATION_STEP_ORDER)[number];
/**
* One step of the daily evaluation. `today` is the Berlin day of the run
* (YYYY-MM-DD). A step commits in transactions of its own; it reports its
* counters, and fails by throwing - a JobPartialFailure if it still has
* counters to report.
*/
export interface DailyEvaluationStep {
readonly name: DailyEvaluationStepName;
run(today: string): Promise<JobResult>;
}
/**
* Runs the steps in DAILY_EVALUATION_STEP_ORDER, whatever order they come
* in. A failing step is logged and does not stop the others; the counters of
* all steps - also the partial ones of a failed step - are merged into
* `result`, `failed` names the steps that failed and `failures` says why.
*/
export async function runDailyEvaluationSteps(
steps: readonly DailyEvaluationStep[],
today: string,
logger: Logger,
): Promise<{
result: JobResult;
failed: DailyEvaluationStepName[];
/** One entry per failed step, with the failed parts: "time-events: QueenAged". */
failures: string[];
}> {
const ordered = [...steps].sort(
(a, b) =>
DAILY_EVALUATION_STEP_ORDER.indexOf(a.name) -
DAILY_EVALUATION_STEP_ORDER.indexOf(b.name),
);
let result: JobResult = {};
const failed: DailyEvaluationStepName[] = [];
const failures: string[] = [];
for (const step of ordered) {
try {
result = { ...result, ...(await step.run(today)) };
} catch (error: unknown) {
const { message, stack } = loggableError(error);
logger.error(`Step "${step.name}" failed: ${message}`, stack);
failed.push(step.name);
if (error instanceof JobPartialFailure) {
result = { ...result, ...error.result };
failures.push(`${step.name}: ${error.failedSteps.join(', ')}`);
} else {
failures.push(step.name);
}
}
}
return { result, failed, failures };
}
@@ -1,146 +1,64 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { JobPartialFailure } from '../system/job-run.service';
import { DailyEvaluationJob } from './daily-evaluation.job';
type RawCall = [readonly string[], ...unknown[]];
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
import type { TimeEventsStep } from './steps/time-events.step';
describe('DailyEvaluationJob', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
/** The database clock: a day the JS clock of the test never shows. */
const dbNow = new Date('2026-06-10T02:30:00.000Z');
let queryRaw: jest.Mock<Promise<unknown[]>, RawCall>;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
let job: DailyEvaluationJob;
beforeEach(() => {
queryRaw = jest.fn<Promise<unknown[]>, RawCall>(([sql]) =>
Promise.resolve(
sql.includes('now()')
? [{ now: dbNow }]
: [
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
],
),
);
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: queryRaw,
$transaction: transaction,
task: { findMany: taskFindMany },
queen: { findMany: jest.fn().mockResolvedValue([]) },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { groupBy: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
} as unknown as PrismaService;
job = new DailyEvaluationJob(prisma, {
recordAll,
} as unknown as DomainEventsService);
});
afterEach(() => jest.restoreAllMocks());
it('is the daily-evaluation job', () => {
expect(job.name).toBe('daily-evaluation');
});
it('evaluates the day of the database clock and reports new events per type', async () => {
const result = await job.run();
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10 - not in the one of the test's own clock.
expect(result).toEqual({
const COUNTS = {
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()');
};
let timeEvents: { name: 'time-events'; 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);
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('is the daily-evaluation job', () => {
expect(job.name).toBe('daily-evaluation');
});
it('runs the steps for the Berlin day of the run and reports their counters', async () => {
expect(await job.run()).toEqual(COUNTS);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
});
it('takes the Berlin day, not the UTC day, around midnight', async () => {
jest.setSystemTime(new Date('2026-06-09T22:30:00.000Z'));
await job.run();
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
expect(timeEvents.run).toHaveBeenCalledWith('2026-06-10');
});
it('a second run on the same day records nothing new', async () => {
await job.run();
expect(await job.run()).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it('a failing rule does not stop the others, but fails the run', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
await expect(job.evaluate(dbNow)).rejects.toThrow(
'Daily evaluation failed for: TaskDue',
it('a failing step fails the run with the counters of all steps', async () => {
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
timeEvents.run.mockRejectedValue(
new JobPartialFailure({ WithdrawalEnded: 1 }, ['TaskDue']),
);
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
const failure = await job.run().then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
failedSteps: ['time-events'],
result: { WithdrawalEnded: 1 },
message: 'Daily evaluation failed for: time-events: TaskDue',
});
});
});
@@ -1,72 +1,39 @@
import { Injectable, Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { berlinToday } from '../common/dates';
import { JobPartialFailure } from '../system/job-run.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
} from './daily-evaluation.rules';
DailyEvaluationStep,
runDailyEvaluationSteps,
} from './daily-evaluation-steps';
import { Job, JobResult } from './job';
import { JobName } from './job-names';
import { TimeEventsStep } from './steps/time-events.step';
/**
* Records the time-based domain events (CronJob 04:30): one rule per type,
* see DAILY_EVALUATION_RULES. An occurrence is recorded on every run within
* CATCH_UP_DAYS after its day under the same daily dedupe key, so a missed
* run is caught up and a repeated one adds nothing. The result counts the
* events that were new, per type.
* The daily evaluation (CronJob 04:30 Europe/Berlin): its steps run in the
* fixed order of DAILY_EVALUATION_STEP_ORDER, each in transactions of its
* own, and a failing step does not stop the others. The result holds the
* counters of all steps; if a step failed, the run fails with that result
* (JobPartialFailure), so it shows on the System page.
*/
@Injectable()
export class DailyEvaluationJob implements Job {
readonly name: JobName = 'daily-evaluation';
private readonly logger = new Logger(DailyEvaluationJob.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
) {}
constructor(private readonly timeEvents: TimeEventsStep) {}
/** Evaluates the current day of the database clock. */
/** Evaluates the current Berlin day. */
async run(): Promise<JobResult> {
const [{ now }] = await this.prisma.$queryRaw<
{ now: Date }[]
>`SELECT now() AS "now"`;
return this.evaluate(now);
}
/**
* Runs every rule for `today`. A failing rule is logged and does not stop
* the others - their events are committed - but fails the run, so it
* shows on the System page; the next run catches up.
*/
async evaluate(today: Date): Promise<JobResult> {
const counts: [string, number][] = [];
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts.push([rule.type, await this.evaluateRule(rule, today)]);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new Error(`Daily evaluation failed for: ${failed.join(', ')}`);
}
return Object.fromEntries(counts);
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(rule: DailyRule, today: Date): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today),
today,
const steps: DailyEvaluationStep[] = [this.timeEvents];
const { result, failed, failures } = await runDailyEvaluationSteps(
steps,
berlinToday(),
this.logger,
);
if (events.length === 0) {
return 0;
if (failed.length > 0) {
throw new JobPartialFailure(result, failed, failures);
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
return result;
}
}
@@ -1,15 +1,11 @@
import { PrismaService } from '../prisma/prisma.service';
import { SeasonService } from '../season/season.service';
import {
ACTIVE_SEASON_MONTHS,
CATCH_UP_DAYS,
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyEvent,
inCatchUpWindow,
INSPECTION_INTERVAL_DAYS,
latestCycleOccurrence,
QUEEN_AGED_MIN_YEARS,
VARROA_MEASUREMENT_INTERVAL_DAYS,
withdrawalEndDay,
} from './daily-evaluation.rules';
@@ -17,8 +13,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
/** Midnight UTC of a 'YYYY-MM-DD' day. */
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
/** Every day from `from` to `to`, both included. */
function daysBetween(from: string, to: string): string[] {
@@ -38,23 +32,25 @@ interface FakeRows {
withdrawalPeriodDays: number | null;
}[];
tasks?: { id: string; dueDate: Date | null }[];
queens?: {
id: string;
hatchYear: number | null;
colony: { id: string } | null;
}[];
/** Living colonies, all in one apiary. */
colonies?: { id: string; createdAt: Date }[];
/** Living colonies with their current queen (rows the queen query returns). */
queenedColonies?: {
id: string;
currentQueen: { id: string; hatchYear: number | null } | null;
}[];
/** The latest varroa measurement per colony. */
varroa?: { colonyId: string; date: Date }[];
varroa?: { id: string; colonyId: string; date: Date }[];
/** The latest inspection per colony. */
inspections?: { colonyId: string; date: Date }[];
/** Stored rule configs by trigger type; others read as the built-in setting. */
rules?: Record<string, object>;
}
interface FakeDb {
db: PrismaService;
queryRaw: jest.Mock;
taskFindMany: jest.Mock;
queenFindMany: jest.Mock;
colonyFindMany: jest.Mock;
}
@@ -63,32 +59,60 @@ interface FakeDb {
* day themselves, the database filters only narrow the candidates.
*/
function fakeDb(rows: FakeRows): FakeDb {
const latest = (records: { colonyId: string; date: Date }[] = []) =>
records.map(({ colonyId, date }) => ({ colonyId, _max: { date } }));
const queryRaw = jest.fn().mockResolvedValue(rows.treatments ?? []);
const taskFindMany = jest.fn().mockResolvedValue(rows.tasks ?? []);
const queenFindMany = jest.fn().mockResolvedValue(rows.queens ?? []);
const colonyFindMany = jest.fn().mockResolvedValue(rows.colonies ?? []);
const colonyFindMany = jest.fn(
({ select }: { select: Record<string, unknown> }) =>
Promise.resolve(
'currentQueen' in select
? (rows.queenedColonies ?? [])
: (rows.colonies ?? []).map((colony) => ({
...colony,
apiaryId: 'apiary-1',
})),
),
);
const db = {
$queryRaw: queryRaw,
task: { findMany: taskFindMany },
queen: { findMany: queenFindMany },
colony: { findMany: colonyFindMany },
varroaMeasurement: {
groupBy: jest.fn().mockResolvedValue(latest(rows.varroa)),
findMany: jest.fn().mockResolvedValue(rows.varroa ?? []),
},
inspection: {
groupBy: jest.fn().mockResolvedValue(latest(rows.inspections)),
groupBy: jest.fn().mockResolvedValue(
(rows.inspections ?? []).map(({ colonyId, date }) => ({
colonyId,
_max: { date },
})),
),
},
automationRule: {
findUnique: jest.fn(({ where }: { where: { triggerType: string } }) => {
const config = rows.rules?.[where.triggerType];
return Promise.resolve(
config === undefined
? null
: { id: 'rule-1', active: true, actionTemplate: null, config },
);
}),
},
} as unknown as PrismaService;
return { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany };
return { db, queryRaw, taskFindMany, colonyFindMany };
}
const seasons = new SeasonService();
/** One job run on `day`: every rule's events in the catch-up window. */
async function eventsOn(db: PrismaService, day: string): Promise<DailyEvent[]> {
const events: DailyEvent[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
events.push(...catchUpEvents(await rule.occurrences(db, at(day)), at(day)));
events.push(
...catchUpEvents(
await rule.occurrences(db, d(day), { seasons, day }),
d(day),
),
);
}
return events;
}
@@ -113,13 +137,12 @@ async function runDaily(
return store;
}
const keysOf = (store: Map<string, DailyEvent>, type: string): string[] =>
[...store.keys()].filter((key) => key.startsWith(`${type}:`));
describe('daily evaluation rules', () => {
it('keeps the values of the backbone plan', () => {
it('keeps the catch-up window of the backbone plan', () => {
expect(CATCH_UP_DAYS).toBe(7);
expect(QUEEN_AGED_MIN_YEARS).toBe(2);
expect(VARROA_MEASUREMENT_INTERVAL_DAYS).toBe(28);
expect(INSPECTION_INTERVAL_DAYS).toBe(21);
expect(ACTIVE_SEASON_MONTHS).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
});
it('runs one rule per time-based event type', () => {
@@ -170,50 +193,9 @@ describe('daily evaluation rules', () => {
).toBeNull());
});
describe('latestCycleOccurrence', () => {
it('is the latest base + k × interval up to today, with k ≥ 1', () => {
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-06-15'))).toBe(
'2026-06-12',
);
expect(latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-22'))).toBe(
'2026-05-22',
);
});
it('is null before the first full interval and for a base in the future', () => {
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-21')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-05-01'), 21, at('2026-05-01')),
).toBeNull();
expect(
latestCycleOccurrence(d('2026-07-01'), 21, at('2026-05-01')),
).toBeNull();
});
it('counts whole UTC days, not hours', () =>
expect(
latestCycleOccurrence(
new Date('2026-05-01T20:00:00.000Z'),
21,
at('2026-05-22'),
),
).toBe('2026-05-22'));
it('refuses an interval that is not a positive whole number of days', () => {
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 0, at('2026-06-01')),
).toThrow(RangeError);
expect(() =>
latestCycleOccurrence(d('2026-05-01'), 1.5, at('2026-06-01')),
).toThrow(RangeError);
});
});
describe('inCatchUpWindow', () => {
it('spans today and the 7 days before it', () => {
const today = at('2026-06-10');
const today = d('2026-06-10');
expect(inCatchUpWindow('2026-06-03', today)).toBe(true);
expect(inCatchUpWindow('2026-06-07', today)).toBe(true);
expect(inCatchUpWindow('2026-06-10', today)).toBe(true);
@@ -222,8 +204,8 @@ describe('daily evaluation rules', () => {
});
it('works across a year boundary', () => {
expect(inCatchUpWindow('2026-12-29', at('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', at('2027-01-04'))).toBe(false);
expect(inCatchUpWindow('2026-12-29', d('2027-01-04'))).toBe(true);
expect(inCatchUpWindow('2026-12-27', d('2027-01-04'))).toBe(false);
});
});
@@ -245,10 +227,11 @@ describe('daily evaluation rules', () => {
// Never measured or inspected: they count from its creation.
{ id: 'colony-2', createdAt: d('2026-04-12') },
],
varroa: [{ colonyId: 'colony-1', date: d('2026-05-01') }],
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-05-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-05-20') }],
});
// 2026-05-29 is SCHWARMZEIT (inspection every 7 days).
expect(await eventsOn(db, '2026-05-29')).toEqual([
{
type: 'WithdrawalEnded',
@@ -269,16 +252,26 @@ describe('daily evaluation rules', () => {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: { dueOn: '2026-05-29', lastMeasuredOn: '2026-05-01' },
payload: {
dueOn: '2026-05-29',
lastMeasuredOn: '2026-05-01',
kind: 'INTERVAL',
anchor: 'm-1',
},
dedupeKey: 'VarroaMeasurementDue:colony-1:2026-05-29',
},
// colony-1: 2026-05-20 + 21 = 2026-06-10, not yet due.
// colony-1: 2026-05-20 + 7 = 2026-05-27 is due, overdue the day after.
// colony-2: 2026-04-12 + 7, overdue on 2026-04-20, outside the window.
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-2',
payload: { dueOn: '2026-05-24', lastInspectedOn: null },
dedupeKey: 'InspectionOverdue:colony-2:2026-05-24',
aggregateId: 'colony-1',
payload: {
dueOn: '2026-05-27',
lastInspectedOn: '2026-05-20',
firstOfYear: false,
},
dedupeKey: 'InspectionOverdue:colony-1:2026-05-28',
},
]);
});
@@ -334,82 +327,300 @@ describe('daily evaluation rules', () => {
expect(await eventsOn(db, '2026-06-22')).toEqual([]);
});
it('emits no inspection or varroa event outside March–October', async () => {
// Never measured or inspected: due every 28 resp. 21 days from 2026-01-10.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-01-10') }],
describe('QueenAged', () => {
const queen = (hatchYear: number | null) => ({
id: 'queen-1',
hatchYear,
});
const store = await runDaily(db, daysBetween('2026-01-01', '2026-12-31'));
const dueDays = (type: string) =>
[...store.values()]
.filter((event) => event.type === type)
.map((event) => event.dedupeKey.split(':')[2]);
// Not 2026-02-07, 2026-11-14 or 2026-12-12.
expect(dueDays('VarroaMeasurementDue')).toEqual([
'2026-03-07',
'2026-04-04',
'2026-05-02',
'2026-05-30',
'2026-06-27',
'2026-07-25',
'2026-08-22',
'2026-09-19',
'2026-10-17',
]);
// Not 2026-01-31, 2026-02-21, 2026-11-21 or 2026-12-12.
expect(dueDays('InspectionOverdue')).toEqual([
'2026-03-14',
'2026-04-04',
'2026-04-25',
'2026-05-16',
'2026-06-06',
'2026-06-27',
'2026-07-18',
'2026-08-08',
'2026-08-29',
'2026-09-19',
'2026-10-10',
'2026-10-31',
]);
});
it('reports a queen once a year on January 1 from her second year after hatching', async () => {
it('fires on the job day, once per queen and year under the January 1 key', async () => {
const { db } = fakeDb({
queens: [
{ id: 'queen-2026', hatchYear: 2026, colony: { id: 'colony-1' } },
{ id: 'queen-2027', hatchYear: 2027, colony: null },
{ id: 'queen-unknown', hatchYear: null, colony: null },
queenedColonies: [
{ id: 'colony-1', currentQueen: queen(2024) },
{ id: 'colony-2', currentQueen: { id: 'queen-2', hatchYear: 2025 } },
{ id: 'colony-3', currentQueen: { id: 'queen-3', hatchYear: null } },
],
});
const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20'));
expect([...store.values()]).toEqual([
expect(await eventsOn(db, '2026-09-09')).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2026',
payload: { hatchYear: 2026, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-2026:2028-01-01',
},
expect.objectContaining({
dedupeKey: 'QueenAged:queen-2026:2029-01-01',
}) as DailyEvent,
{
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: 'queen-2027',
payload: { hatchYear: 2027, colonyId: null },
dedupeKey: 'QueenAged:queen-2027:2029-01-01',
aggregateId: 'queen-1',
payload: { hatchYear: 2024, colonyId: 'colony-1' },
dedupeKey: 'QueenAged:queen-1:2026-01-01',
},
]);
});
it('QueenAged is not limited to January 1–8', async () => {
const { db } = fakeDb({
queenedColonies: [{ id: 'colony-1', currentQueen: queen(2024) }],
});
for (const day of ['2026-01-01', '2026-03-15', '2026-11-30']) {
expect((await eventsOn(db, day)).map((e) => e.dedupeKey)).toEqual([
'QueenAged:queen-1:2026-01-01',
]);
}
});
it('QueenAged uses queenMaxAgeYears', async () => {
const queened = [{ id: 'colony-1', currentQueen: queen(2025) }];
const { db: byDefault } = fakeDb({ queenedColonies: queened });
const { db: stricter, colonyFindMany } = fakeDb({
queenedColonies: queened,
rules: { SEASON_AUTOPILOT: { queenMaxAgeYears: 1 } },
});
expect(await eventsOn(byDefault, '2026-06-10')).toEqual([]);
expect(await eventsOn(stricter, '2026-06-10')).toHaveLength(1);
expect(colonyFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
currentQueen: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}) as object,
}),
);
});
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', async () => {
const { db, colonyFindMany } = fakeDb({});
// The queen (hatched 2024) is assigned to the colony on 2026-06-15.
let assigned = false;
colonyFindMany.mockImplementation(
({ select }: { select: Record<string, unknown> }) =>
Promise.resolve(
'currentQueen' in select && assigned
? [{ id: 'colony-1', currentQueen: queen(2024) }]
: [],
),
);
const store = await runDaily(db, daysBetween('2026-06-01', '2026-06-14'));
expect([...store.keys()]).toEqual([]);
assigned = true;
expect(
(await eventsOn(db, '2026-06-15')).map((event) => event.dedupeKey),
).toEqual(['QueenAged:queen-1:2026-01-01']);
await runDaily(db, daysBetween('2026-06-15', '2026-12-31'), store);
expect([...store.keys()]).toEqual(['QueenAged:queen-1:2026-01-01']);
// The next year is a new anchor.
await runDaily(db, ['2027-01-02'], store);
expect([...store.keys()]).toEqual([
'QueenAged:queen-1:2026-01-01',
'QueenAged:queen-1:2027-01-01',
]);
});
});
describe('InspectionOverdue', () => {
it('emits InspectionOverdue on the day after inspectionDueOn by the segment interval', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
// SCHWARMZEIT since 2026-04-15: 2026-04-28 + 7 = 2026-05-05.
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
expect(
(await eventsOn(db, '2026-05-05')).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([]);
expect(
(await eventsOn(db, '2026-05-06')).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: 'colony-1',
payload: {
dueOn: '2026-05-05',
lastInspectedOn: '2026-04-28',
firstOfYear: false,
},
dedupeKey: 'InspectionOverdue:colony-1:2026-05-06',
},
]);
});
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', async () => {
// Last inspected in autumn: the next one is due in the winter.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2025-10-20') }],
});
const winter = await runDaily(
db,
daysBetween('2026-01-01', '2026-03-19'),
);
expect([...winter.keys()]).toEqual([]);
// FRUEHJAHR begins 2026-03-20: due that day, overdue the day after.
const spring = await runDaily(
db,
daysBetween('2026-03-20', '2026-04-10'),
);
expect(keysOf(spring, 'InspectionOverdue')).toEqual([
'InspectionOverdue:colony-1:2026-03-21',
]);
expect(spring.get('InspectionOverdue:colony-1:2026-03-21')).toMatchObject(
{
payload: {
dueOn: '2026-03-20',
lastInspectedOn: '2025-10-20',
firstOfYear: true,
},
},
);
});
it('emits nothing for a due date more than a week back (one event per due date)', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
const inspections = async (day: string) =>
(await eventsOn(db, day)).filter((e) => e.type === 'InspectionOverdue');
// Due 2026-05-05, overdue 2026-05-06, last caught up on 2026-05-13.
expect(await inspections('2026-05-13')).toHaveLength(1);
// Due + 9 days.
expect(await inspections('2026-05-14')).toEqual([]);
});
it('keeps the same dedupe key on consecutive days while nothing changes', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-04-28') }],
});
const keys = async (day: string) =>
(await eventsOn(db, day)).map((event) => event.dedupeKey);
expect(await keys('2026-05-08')).toEqual(
expect.arrayContaining(['InspectionOverdue:colony-1:2026-05-06']),
);
expect(await keys('2026-05-09')).toEqual(await keys('2026-05-08'));
});
it('a segment interval change gives a second event with another key', async () => {
// Inspected 2026-06-05: SCHWARMZEIT (7 days) is overdue on 06-13; from
// SOMMER on 2026-06-20 (14 days) the due date is 06-19, overdue 06-20.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-06-05') }],
});
const store = await runDaily(db, daysBetween('2026-06-06', '2026-06-25'));
expect(keysOf(store, 'InspectionOverdue')).toEqual([
'InspectionOverdue:colony-1:2026-06-13',
'InspectionOverdue:colony-1:2026-06-20',
]);
});
it('a HERBST due date inside the winter gives no event on WINTER and a first-of-year one in spring', async () => {
// HERBST: 21 days. 2026-10-24 + 21 = 2026-11-14, overdue on 11-15 -
// but 11-15 is WINTERRUHE, so nothing.
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-10-24') }],
});
expect(
keysOf(
await runDaily(db, daysBetween('2026-11-10', '2027-03-19')),
'InspectionOverdue',
),
).toEqual([]);
const spring = await runDaily(
db,
daysBetween('2027-03-20', '2027-03-25'),
);
expect(spring.get('InspectionOverdue:colony-1:2027-03-21')).toMatchObject(
{ payload: { dueOn: '2027-03-20', firstOfYear: true } },
);
});
it('emits no inspection event inside WINTERRUHE although the due date passes', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
inspections: [{ colonyId: 'colony-1', date: d('2026-11-01') }],
});
const winter = await runDaily(
db,
daysBetween('2026-11-15', '2027-03-19'),
);
expect(keysOf(winter, 'InspectionOverdue')).toEqual([]);
});
});
describe('VarroaMeasurementDue', () => {
it('is due on the winter window start without a measurement since 11-01 and not in the rest of WINTERRUHE', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
varroa: [{ id: 'm-1', colonyId: 'colony-1', date: d('2026-09-20') }],
});
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2026-11-15',
]);
});
it('is not due in the winter window after a measurement since 11-01', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
varroa: [{ id: 'm-2', colonyId: 'colony-1', date: d('2026-11-03') }],
});
const store = await runDaily(db, daysBetween('2026-11-10', '2027-02-28'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([]);
});
it('takes up a due date inside the winter at the start of FRUEHJAHR', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2025-06-01') }],
// Measured 2026-11-20 (inside the winter window of the year): the
// next interval day, 12-18, lies in WINTERRUHE.
varroa: [{ id: 'm-9', colonyId: 'colony-1', date: d('2026-11-20') }],
});
const store = await runDaily(db, daysBetween('2026-11-21', '2027-03-25'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2027-03-20',
]);
expect(
store.get('VarroaMeasurementDue:colony-1:2027-03-20'),
).toMatchObject({ payload: { kind: 'INTERVAL', anchor: 'm-9' } });
});
it('follows varroaMeasureIntervalDays outside WINTERRUHE', async () => {
const { db } = fakeDb({
colonies: [{ id: 'colony-1', createdAt: d('2026-04-20') }],
rules: { SEASON_AUTOPILOT: { varroaMeasureIntervalDays: 14 } },
});
const store = await runDaily(db, daysBetween('2026-05-01', '2026-05-10'));
expect(keysOf(store, 'VarroaMeasurementDue')).toEqual([
'VarroaMeasurementDue:colony-1:2026-05-04',
]);
});
});
it('asks the database only for the candidates of the catch-up window', async () => {
const { db, queryRaw, taskFindMany, queenFindMany, colonyFindMany } =
fakeDb({});
const { db, queryRaw, taskFindMany, colonyFindMany } = fakeDb({});
await eventsOn(db, '2026-06-10');
@@ -434,14 +645,5 @@ describe('daily evaluation rules', () => {
];
expect(strings.join('?')).toContain('"withdrawalPeriodDays" IS NOT NULL');
expect(values).toEqual(['2026-06-03', '2026-06-10']);
// QueenAged only looks on January 1-8.
expect(queenFindMany).not.toHaveBeenCalled();
await eventsOn(db, '2027-01-08');
expect(queenFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', hatchYear: { lte: 2025 } },
}),
);
});
});
+173 -121
View File
@@ -1,22 +1,20 @@
import { TaskStatus, Treatment } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { addDays, berlinDayOf } from '../common/dates';
import {
AnyNewDomainEvent,
dailyDedupeKey,
utcDay,
} from '../domain-events/domain-event-types';
import { PrismaService } from '../prisma/prisma.service';
import { inspectionDueOn } from '../season/inspection-due';
import { isQueenAged } from '../season/queen-age';
import type { SeasonService } from '../season/season.service';
import { varroaMeasurementDue } from '../season/varroa-due';
/** How many days back a run still records an occurrence it missed. */
export const CATCH_UP_DAYS = 7;
/** A queen counts as aged from this many years after her hatch year. */
export const QUEEN_AGED_MIN_YEARS = 2;
export const VARROA_MEASUREMENT_INTERVAL_DAYS = 28;
export const INSPECTION_INTERVAL_DAYS = 21;
/** March to October: varroa and inspection reminders only in these months. */
export const ACTIVE_SEASON_MONTHS: readonly number[] = [
3, 4, 5, 6, 7, 8, 9, 10,
];
const DAY_MS = 24 * 60 * 60 * 1000;
const OPEN_TASK_STATUSES: TaskStatus[] = ['OPEN', 'IN_PROGRESS'];
@@ -37,18 +35,35 @@ export type DailyEvent<T extends DailyEventType = DailyEventType> =
export interface DailyOccurrence<T extends DailyEventType = DailyEventType> {
day: string;
event: AnyNewDomainEvent<T>;
/**
* The day of the dedupe key when it is not `day`: an occurrence that
* repeats on every job day but must be recorded once (QueenAged: the
* January 1 of the year).
*/
anchorDay?: string;
}
/** What a rule needs besides the database: the seasons and the run's day. */
export interface DailyRuleContext {
seasons: SeasonService;
/** The Berlin day of the run, YYYY-MM-DD. */
day: string;
}
/**
* One row of the daily evaluation. `occurrences` reads the candidates and
* names the day of each; the job keeps those in the catch-up window
* names the day of each; the step keeps those in the catch-up window
* (catchUpEvents) and records them under their daily dedupe key. A rule
* may narrow its query to the window, but must not rely on that: the day
* it reports decides.
* it reports decides. `today` is dayToDate(ctx.day).
*/
export interface DailyRule<T extends DailyEventType = DailyEventType> {
readonly type: T;
occurrences(db: PrismaService, today: Date): Promise<DailyOccurrence<T>[]>;
occurrences(
db: PrismaService,
today: Date,
ctx: DailyRuleContext,
): Promise<DailyOccurrence<T>[]>;
}
/** Midnight UTC of the UTC day of `date`. */
@@ -73,39 +88,11 @@ export function withdrawalEndDay(
return utcDay(dayAfter(t.endDate ?? t.date, t.withdrawalPeriodDays));
}
/**
* The latest day base + k × intervalDays (k ≥ 1) that is not after today,
* in whole UTC days; null if not even the first interval has passed.
*/
export function latestCycleOccurrence(
base: Date,
intervalDays: number,
today: Date,
): string | null {
if (!Number.isInteger(intervalDays) || intervalDays < 1) {
throw new RangeError(
`Interval must be a positive whole number of days, got ${intervalDays}`,
);
}
const elapsedDays = Math.round(
(dayStart(today).getTime() - dayStart(base).getTime()) / DAY_MS,
);
const cycles = Math.floor(elapsedDays / intervalDays);
if (cycles < 1) {
return null;
}
return utcDay(dayAfter(base, cycles * intervalDays));
}
/** True for a 'YYYY-MM-DD' day from today − CATCH_UP_DAYS up to today. */
export function inCatchUpWindow(day: string, today: Date): boolean {
return day >= utcDay(dayAfter(today, -CATCH_UP_DAYS)) && day <= utcDay(today);
}
function inActiveSeason(day: string): boolean {
return ACTIVE_SEASON_MONTHS.includes(Number(day.slice(5, 7)));
}
/**
* The occurrences in the catch-up window, as events with their daily dedupe
* key: every run within the window repeats the same key, so the unique
@@ -117,9 +104,13 @@ export function catchUpEvents(
): DailyEvent[] {
return occurrences
.filter(({ day }) => inCatchUpWindow(day, today))
.map(({ day, event }) => ({
.map(({ day, anchorDay, event }) => ({
...event,
dedupeKey: dailyDedupeKey(event.type, event.aggregateId, day),
dedupeKey: dailyDedupeKey(
event.type,
event.aggregateId,
anchorDay ?? day,
),
}));
}
@@ -197,34 +188,47 @@ const taskDue: DailyRule<'TaskDue'> = {
},
};
/** Active queen from her second year after hatching: once a year, on January 1. */
/**
* The current queen of a living colony once she is aged
* (SEASON_AUTOPILOT.queenMaxAgeYears): on the first job day she is, once per
* queen and year (the key is anchored on January 1), so a queen assigned
* later in the year, or a changed hatch year, is caught too.
*/
const queenAged: DailyRule<'QueenAged'> = {
type: 'QueenAged',
async occurrences(db, today) {
const year = today.getUTCFullYear();
const day = `${year}-01-01`;
if (!inCatchUpWindow(day, today)) {
return [];
}
const queens = await db.queen.findMany({
async occurrences(db, _today, { day }) {
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
const year = Number(day.slice(0, 4));
const colonies = await db.colony.findMany({
where: {
...LIVING_COLONY_WHERE,
currentQueen: {
status: 'ACTIVE',
hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS },
hatchYear: { lte: year - config.queenMaxAgeYears },
},
},
select: {
id: true,
currentQueen: { select: { id: true, hatchYear: true } },
},
select: { id: true, hatchYear: true, colony: { select: { id: true } } },
});
return queens.flatMap(({ id, hatchYear, colony }) => {
if (hatchYear === null || year - hatchYear < QUEEN_AGED_MIN_YEARS) {
return colonies.flatMap(({ id, currentQueen }) => {
if (
currentQueen === null ||
currentQueen.hatchYear === null ||
!isQueenAged(currentQueen.hatchYear, day, config.queenMaxAgeYears)
) {
return [];
}
return [
{
day,
anchorDay: `${year}-01-01`,
event: {
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: id,
payload: { hatchYear, colonyId: colony?.id ?? null },
aggregateId: currentQueen.id,
payload: { hatchYear: currentQueen.hatchYear, colonyId: id },
},
},
];
@@ -232,98 +236,146 @@ const queenAged: DailyRule<'QueenAged'> = {
},
};
interface CycleDue {
colonyId: string;
day: string;
/** The UTC day of the last record, null if the colony has none. */
lastOn: string | null;
interface SeasonalColony {
id: string;
apiaryId: string;
/** The Berlin day the colony was created on. */
createdOn: string;
}
async function livingColonies(db: PrismaService): Promise<SeasonalColony[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, apiaryId: true, createdAt: true },
});
return colonies.map(({ id, apiaryId, createdAt }) => ({
id,
apiaryId,
createdOn: berlinDayOf(createdAt),
}));
}
/**
* Living colonies whose cycle comes due in the active season: counted from
* the last record, or from the colony's creation if it has none.
* Living colony: varroa measurement due, by the season of its apiary
* (varroaMeasurementDue): the interval after the last measurement, or the
* winter window. One event per due date, on the due day; the 7-day catch-up
* only covers job outages (the A2 suggestion carries the reminder after).
*/
async function colonyCyclesDue(
db: PrismaService,
lastRecordOn: ReadonlyMap<string, Date>,
intervalDays: number,
today: Date,
): Promise<CycleDue[]> {
const colonies = await db.colony.findMany({
where: LIVING_COLONY_WHERE,
select: { id: true, createdAt: true },
const varroaDue: DailyRule<'VarroaMeasurementDue'> = {
type: 'VarroaMeasurementDue',
async occurrences(db, _today, { seasons, day }) {
const colonies = await livingColonies(db);
const latest = await db.varroaMeasurement.findMany({
where: { colony: LIVING_COLONY_WHERE },
distinct: ['colonyId'],
orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }],
select: { id: true, colonyId: true, date: true },
});
return colonies.flatMap(({ id, createdAt }) => {
const last = lastRecordOn.get(id) ?? null;
const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today);
if (day === null || !inActiveSeason(day)) {
const { config } = await readRule(db, 'SEASON_AUTOPILOT');
const states = await seasons.statesFor(
db,
colonies.map((colony) => colony.apiaryId),
day,
);
const lastByColony = new Map(latest.map((row) => [row.colonyId, row]));
return colonies.flatMap((colony) => {
const season = states.get(colony.apiaryId);
const last = lastByColony.get(colony.id);
const lastMeasuredOn = last === undefined ? null : utcDay(last.date);
const due =
season === undefined
? null
: varroaMeasurementDue(
{
createdOn: colony.createdOn,
lastMeasuredOn,
lastMeasurementId: last?.id ?? null,
},
season,
config,
day,
);
if (due === null) {
return [];
}
return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }];
});
}
function latestByColony(
rows: { colonyId: string; _max: { date: Date | null } }[],
): ReadonlyMap<string, Date> {
return new Map(
rows.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, _max.date] as const],
),
);
}
/** Living colony: varroa measurement due again in the active season. */
const varroaMeasurementDue: DailyRule<'VarroaMeasurementDue'> = {
type: 'VarroaMeasurementDue',
async occurrences(db, today) {
const latest = await db.varroaMeasurement.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
VARROA_MEASUREMENT_INTERVAL_DAYS,
today,
);
return due.map(({ colonyId, day, lastOn }) => ({
day,
return [
{
day: due.dueOn,
event: {
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastMeasuredOn: lastOn },
aggregateId: colony.id,
payload: {
dueOn: due.dueOn,
lastMeasuredOn,
kind: due.kind,
anchor: due.anchor,
},
}));
},
},
];
});
},
};
/** Living colony: inspection overdue in the active season. */
/**
* Living colony: inspection overdue the day after inspectionDueOn - by the
* interval of the season segment of its apiary, never in WINTERRUHE; after
* the winter on the day after FRUEHJAHR begins ("first of the year"). One
* event per due date; the 7-day catch-up only covers job outages (A1 carries
* the reminder after). A changed interval (new segment) yields a new due
* date and so a second event with another key.
*/
const inspectionOverdue: DailyRule<'InspectionOverdue'> = {
type: 'InspectionOverdue',
async occurrences(db, today) {
async occurrences(db, _today, { seasons, day }) {
const colonies = await livingColonies(db);
const latest = await db.inspection.groupBy({
by: ['colonyId'],
where: { colony: LIVING_COLONY_WHERE },
_max: { date: true },
});
const due = await colonyCyclesDue(
db,
latestByColony(latest),
INSPECTION_INTERVAL_DAYS,
today,
const lastInspected = new Map(
latest.flatMap(({ colonyId, _max }) =>
_max.date === null ? [] : [[colonyId, utcDay(_max.date)] as const],
),
);
return due.map(({ colonyId, day, lastOn }) => ({
const { config } = await readRule(db, 'INSPECTION_INTERVAL');
const states = await seasons.statesFor(
db,
colonies.map((colony) => colony.apiaryId),
day,
);
return colonies.flatMap((colony) => {
const season = states.get(colony.apiaryId);
const lastInspectedOn = lastInspected.get(colony.id) ?? null;
const due =
season === undefined
? null
: inspectionDueOn(
{ createdOn: colony.createdOn, lastInspectedOn },
season,
config,
);
if (due === null) {
return [];
}
return [
{
day: addDays(due.dueOn, 1),
event: {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: colonyId,
payload: { dueOn: day, lastInspectedOn: lastOn },
aggregateId: colony.id,
payload: {
dueOn: due.dueOn,
lastInspectedOn,
firstOfYear: due.firstOfYear,
},
}));
},
},
];
});
},
};
@@ -332,6 +384,6 @@ export const DAILY_EVALUATION_RULES: readonly DailyRule[] = [
withdrawalEnded,
taskDue,
queenAged,
varroaMeasurementDue,
varroaDue,
inspectionOverdue,
];
+6 -16
View File
@@ -1,12 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from '../audit/audit.module';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { JobPermissionsModule } from '../auth/permissions/permission-resolver.module';
import { BackupModule } from '../backup/backup.module';
import { IdempotencyCleanupService } from '../common/idempotency/idempotency-cleanup.service';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SeasonModule } from '../season/season.module';
import { SystemModule } from '../system/system.module';
import { TasksModule } from '../tasks/tasks.module';
import { AppBackupJob } from './app-backup.job';
@@ -15,22 +16,9 @@ import { DailyEvaluationJob } from './daily-evaluation.job';
import { Job } from './job';
import { JobRegistry, JOBS } from './job-registry';
import { RekeyJob } from './rekey.job';
import { TimeEventsStep } from './steps/time-events.step';
import { TaskTemplatesJob } from './task-templates.job';
/**
* PermissionResolverService for the job context (BackupService clears its
* cache after a restore, which no job does). Not PermissionsModule: its
* PermissionCatalogSyncService would rewrite the permission catalog on
* every job start. Global like PermissionsModule, so modules that inject
* the resolver load here unchanged.
*/
@Global()
@Module({
providers: [PermissionResolverService],
exports: [PermissionResolverService],
})
class JobPermissionsModule {}
const JOB_CLASSES = [
DailyEvaluationJob,
TaskTemplatesJob,
@@ -58,6 +46,7 @@ const JOB_CLASSES = [
JobPermissionsModule,
BackupModule,
SystemModule,
SeasonModule,
],
providers: [
...JOB_CLASSES,
@@ -66,6 +55,7 @@ const JOB_CLASSES = [
useFactory: (...jobs: Job[]): Job[] => jobs,
inject: JOB_CLASSES,
},
TimeEventsStep,
JobRegistry,
IdempotencyCleanupService,
],
@@ -0,0 +1,168 @@
import { Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { berlinToday } from '../../common/dates';
import { AnyNewDomainEvent } from '../../domain-events/domain-event-types';
import { DomainEventsService } from '../../domain-events/domain-events.service';
import { PrismaService } from '../../prisma/prisma.service';
import { SeasonService } from '../../season/season.service';
import { JobPartialFailure } from '../../system/job-run.service';
import { TimeEventsStep } from './time-events.step';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('TimeEventsStep', () => {
const tx = { tx: true } as unknown as Prisma.TransactionClient;
let taskFindMany: jest.Mock;
let transaction: jest.Mock;
let recordAll: jest.Mock<
Promise<number>,
[Prisma.TransactionClient, readonly AnyNewDomainEvent[]]
>;
let step: TimeEventsStep;
beforeEach(() => {
taskFindMany = jest
.fn()
.mockResolvedValue([{ id: 'task-1', dueDate: d('2026-06-09') }]);
transaction = jest.fn(
(work: (client: Prisma.TransactionClient) => unknown) => work(tx),
);
// Like createMany(skipDuplicates) on the unique dedupeKey.
const stored = new Set<string>();
recordAll = jest.fn((_client, events: readonly AnyNewDomainEvent[]) => {
const fresh = events.filter((event) => !stored.has(event.dedupeKey!));
fresh.forEach((event) => stored.add(event.dedupeKey!));
return Promise.resolve(fresh.length);
});
const prisma = {
$queryRaw: jest.fn().mockResolvedValue([
{
id: 'treatment-1',
colonyId: 'colony-1',
date: d('2026-05-25'),
endDate: null,
withdrawalPeriodDays: 14,
},
]),
$transaction: transaction,
task: { findMany: taskFindMany },
colony: { findMany: jest.fn().mockResolvedValue([]) },
varroaMeasurement: { findMany: jest.fn().mockResolvedValue([]) },
inspection: { groupBy: jest.fn().mockResolvedValue([]) },
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
} as unknown as PrismaService;
step = new TimeEventsStep(
prisma,
{ recordAll } as unknown as DomainEventsService,
new SeasonService(),
);
});
afterEach(() => jest.restoreAllMocks());
it('is the time-events step', () => {
expect(step.name).toBe('time-events');
});
it('reports the new events per type', async () => {
// 2026-05-25 + 14 = 2026-06-08 and the task due 2026-06-09 lie in the
// window of 2026-06-10.
expect(await step.run('2026-06-10')).toEqual({
WithdrawalEnded: 1,
TaskDue: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it('writes each type in its own transaction, with its daily dedupe keys', async () => {
await step.run('2026-06-10');
// Types without an event open no transaction.
expect(transaction).toHaveBeenCalledTimes(2);
expect(recordAll.mock.calls).toEqual([
[
tx,
[
expect.objectContaining({
type: 'WithdrawalEnded',
dedupeKey: 'WithdrawalEnded:treatment-1:2026-06-08',
}),
],
],
[
tx,
[
expect.objectContaining({
type: 'TaskDue',
dedupeKey: 'TaskDue:task-1:2026-06-09',
}),
],
],
]);
});
it('a second run on the same day records nothing new', async () => {
await step.run('2026-06-10');
expect(await step.run('2026-06-10')).toEqual({
WithdrawalEnded: 0,
TaskDue: 0,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
});
});
it.each([
['summer', '2026-06-10T02:30:00.000Z', '2026-06-10'],
['winter', '2026-12-05T03:30:00.000Z', '2026-12-05'],
])(
'the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey (%s)',
async (_season, instant, day) => {
taskFindMany.mockResolvedValue([{ id: 'task-1', dueDate: d(day) }]);
expect(berlinToday(new Date(instant))).toBe(day);
await step.run(berlinToday(new Date(instant)));
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ dedupeKey: `TaskDue:task-1:${day}` }),
]);
},
);
it('a failing rule does not stop the others, but fails the step with their counters', async () => {
const logged = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
taskFindMany.mockRejectedValue(new Error('connection reset'));
const failure = await step.run('2026-06-10').then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(JobPartialFailure);
expect(failure).toMatchObject({
failedSteps: ['TaskDue'],
result: {
WithdrawalEnded: 1,
QueenAged: 0,
VarroaMeasurementDue: 0,
InspectionOverdue: 0,
},
});
expect(recordAll).toHaveBeenCalledWith(tx, [
expect.objectContaining({ type: 'WithdrawalEnded' }),
]);
expect(logged).toHaveBeenCalledWith(
'Rule "TaskDue" failed: connection reset',
expect.any(String),
);
});
it('refuses a malformed day', async () => {
await expect(step.run('2026-02-30')).rejects.toThrow('Invalid day');
});
});
@@ -0,0 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { dayToDate } from '../../common/dates';
import { loggableError } from '../../common/loggable-error';
import { DomainEventsService } from '../../domain-events/domain-events.service';
import { PrismaService } from '../../prisma/prisma.service';
import { SeasonService } from '../../season/season.service';
import { JobPartialFailure } from '../../system/job-run.service';
import {
catchUpEvents,
DAILY_EVALUATION_RULES,
DailyRule,
DailyRuleContext,
} from '../daily-evaluation.rules';
import type { DailyEvaluationStep } from '../daily-evaluation-steps';
import type { JobResult } from '../job';
/**
* Step "time-events": records the time-based domain events, one rule per
* type, see DAILY_EVALUATION_RULES. An occurrence is recorded on every run
* within CATCH_UP_DAYS after its day under the same daily dedupe key, so a
* missed run is caught up and a repeated one adds nothing. The result counts
* the events that were new, per type.
*/
@Injectable()
export class TimeEventsStep implements DailyEvaluationStep {
readonly name = 'time-events';
private readonly logger = new Logger(TimeEventsStep.name);
constructor(
private readonly prisma: PrismaService,
private readonly events: DomainEventsService,
private readonly seasons: SeasonService,
) {}
/**
* Runs every rule for `day` (the Berlin day of the run). A failing rule is
* logged and does not stop the others - their events are committed - but
* fails the step with the counters of the rules that ran; the next run
* catches up.
*/
async run(day: string): Promise<JobResult> {
const today = dayToDate(day);
const context: DailyRuleContext = { seasons: this.seasons, day };
const counts: JobResult = {};
const failed: string[] = [];
for (const rule of DAILY_EVALUATION_RULES) {
try {
counts[rule.type] = await this.evaluateRule(rule, today, context);
} catch (error: unknown) {
const { message, stack } = loggableError(error);
this.logger.error(`Rule "${rule.type}" failed: ${message}`, stack);
failed.push(rule.type);
}
}
if (failed.length > 0) {
throw new JobPartialFailure(counts, failed);
}
return counts;
}
/** One transaction per type; returns how many events were new. */
private async evaluateRule(
rule: DailyRule,
today: Date,
context: DailyRuleContext,
): Promise<number> {
const events = catchUpEvents(
await rule.occurrences(this.prisma, today, context),
today,
);
if (events.length === 0) {
return 0;
}
return this.prisma.$transaction((tx) => this.events.recordAll(tx, events));
}
}
@@ -1,5 +1,6 @@
import { Prisma } from '../../generated/prisma';
import {
LOCK_ORDER,
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
@@ -143,3 +144,98 @@ describe('lockRowsForUpdate', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lock keys per table', () => {
const lockers = [
[
'lockRowForUpdate',
(tx: unknown) =>
lockRowForUpdate(tx as never, 'NotificationPreference', 'u1'),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ? FOR UPDATE',
['u1'],
],
[
'lockRowsForUpdate',
(tx: unknown) =>
lockRowsForUpdate(tx as never, 'NotificationPreference', ['u2', 'u1']),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR UPDATE',
[['u1', 'u2']],
],
[
'lockRowsForShare',
(tx: unknown) =>
lockRowsForShare(tx as never, 'NotificationPreference', ['u2', 'u1']),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR SHARE',
[['u1', 'u2']],
],
] as const;
it.each(lockers)(
'%s locks NotificationPreference by "userId" with the id as parameter',
async (_name, lock, sql, values) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lock(tx);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(sql);
expect(query.values).toEqual(values);
},
);
it.each([
'Colony',
'Suggestion',
'Task',
'Queen',
'AutomationRule',
'BottlingBatch',
'Feeding',
'Harvest',
'Inspection',
'Product',
'ProductionBatch',
'RawMaterialLot',
'StockItem',
'Treatment',
'VarroaMeasurement',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, table, 'x1');
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(`SELECT id FROM "${table}" WHERE id = ? FOR UPDATE`);
expect(query.values).toEqual(['x1']);
});
it('rejects a table outside the map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForShare(tx as never, 'Notification' as never, ['n1']),
).rejects.toThrow('lockRowsForShare: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
expect(LOCK_ORDER).toEqual([
'Colony',
'Suggestion',
'Task',
'Queen',
'AutomationRule',
'BottlingBatch',
'Feeding',
'Harvest',
'Inspection',
'Product',
'ProductionBatch',
'RawMaterialLot',
'StockItem',
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
]);
});
});
+54 -32
View File
@@ -1,29 +1,57 @@
import { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from './db-client';
// Fixed identifier map: a table name is never taken from input.
// `key` is the SQL identifier of the key column: plain id, quoted for a
// camelCase name.
function lockable(table: string, key = 'id') {
return {
table: Prisma.raw(`"${table}"`),
key: Prisma.raw(key === 'id' ? 'id' : `"${key}"`),
};
}
// Fixed identifier map: a table or key column name is never taken from input.
// The order is the lock order: a transaction that locks rows of several
// tables takes them in this order (and ascending key within a table), so two
// of them cannot wait for each other.
const LOCKABLE_TABLES = {
Colony: Prisma.raw('"Colony"'),
Task: Prisma.raw('"Task"'),
BottlingBatch: Prisma.raw('"BottlingBatch"'),
Feeding: Prisma.raw('"Feeding"'),
Harvest: Prisma.raw('"Harvest"'),
Inspection: Prisma.raw('"Inspection"'),
Product: Prisma.raw('"Product"'),
ProductionBatch: Prisma.raw('"ProductionBatch"'),
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
StockItem: Prisma.raw('"StockItem"'),
Treatment: Prisma.raw('"Treatment"'),
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
Colony: lockable('Colony'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
Queen: lockable('Queen'),
AutomationRule: lockable('AutomationRule'),
BottlingBatch: lockable('BottlingBatch'),
Feeding: lockable('Feeding'),
Harvest: lockable('Harvest'),
Inspection: lockable('Inspection'),
Product: lockable('Product'),
ProductionBatch: lockable('ProductionBatch'),
RawMaterialLot: lockable('RawMaterialLot'),
StockItem: lockable('StockItem'),
Treatment: lockable('Treatment'),
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
/** The tables in the order a transaction must lock them. */
export const LOCK_ORDER = Object.keys(LOCKABLE_TABLES) as LockableTable[];
function entryOf(table: LockableTable, caller: string) {
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error(`${caller}: table not lockable`);
}
return LOCKABLE_TABLES[table];
}
/**
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
* read-modify-write on it is serialised with concurrent writers. Re-read the
* row after locking and use that copy as "before". Resolves to whether the
* row exists.
* row exists. `id` is the value of the table's key column ("id", for
* NotificationPreference "userId").
*/
export async function lockRowForUpdate(
tx: Prisma.TransactionClient,
@@ -31,20 +59,18 @@ export async function lockRowForUpdate(
id: string,
): Promise<boolean> {
assertTransactionClient(tx, 'lockRowForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowForUpdate: table not lockable');
}
const locked = await tx.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
const entry = entryOf(table, 'lockRowForUpdate');
const locked = await tx.$queryRaw<unknown[]>(
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ${id} FOR UPDATE`,
);
return locked.length > 0;
}
/**
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
* ONE statement in ascending id order: it meets lockRowsForShare on the same
* ONE statement in ascending key order: it meets lockRowsForShare on the same
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
* ids lock nothing; re-read the rows after locking.
* keys lock nothing; re-read the rows after locking.
*/
export async function lockRowsForUpdate(
tx: Prisma.TransactionClient,
@@ -52,24 +78,22 @@ export async function lockRowsForUpdate(
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForUpdate: table not lockable');
}
const entry = entryOf(table, 'lockRowsForUpdate');
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`,
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR UPDATE`,
);
}
/**
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
* cannot be changed or deleted meanwhile, other readers are not blocked. The
* rows are locked in ascending id order, so two transactions locking
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
* after locking.
* rows are locked in ascending key order, so two transactions locking
* overlapping sets cannot deadlock. Unknown keys lock nothing; re-read the
* rows after locking.
*/
export async function lockRowsForShare(
tx: Prisma.TransactionClient,
@@ -77,14 +101,12 @@ export async function lockRowsForShare(
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForShare');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForShare: table not lockable');
}
const entry = entryOf(table, 'lockRowsForShare');
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`,
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR SHARE`,
);
}
@@ -0,0 +1,73 @@
import { DEFAULT_INSPECTION_INTERVAL_CONFIG } from '../automation/rule-config/inspection-interval.config';
import { inspectionDueOn } from './inspection-due';
const CONFIG = DEFAULT_INSPECTION_INTERVAL_CONFIG;
const SCHWARMZEIT = {
segment: 'SCHWARMZEIT',
springSince: '2026-03-20',
} as const;
describe('inspectionDueOn', () => {
it('adds the interval of the current segment to the last inspection', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2026-05-05' },
SCHWARMZEIT,
CONFIG,
),
).toEqual({ dueOn: '2026-05-12', firstOfYear: false, intervalDays: 7 }));
it('uses the interval of SOMMER in SOMMER', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2026-06-25' },
{ segment: 'SOMMER', springSince: '2026-03-20' },
CONFIG,
),
).toEqual({ dueOn: '2026-07-09', firstOfYear: false, intervalDays: 14 }));
it('returns null in WINTERRUHE', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-01' },
{ segment: 'WINTERRUHE', springSince: null },
CONFIG,
),
).toBeNull());
it('counts from createdAt without any inspection', () =>
expect(
inspectionDueOn(
{ createdOn: '2026-04-20', lastInspectedOn: null },
SCHWARMZEIT,
CONFIG,
),
).toEqual({ dueOn: '2026-04-27', firstOfYear: false, intervalDays: 7 }));
it('is due from the start of FRUEHJAHR when the last inspection was before it (first of the year)', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2025-10-30' },
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
CONFIG,
),
).toEqual({ dueOn: '2026-03-20', firstOfYear: true, intervalDays: 14 }));
it('a colony created before the spring without an inspection is first-of-year due too', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-09-01', lastInspectedOn: null },
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
CONFIG,
),
).toMatchObject({ dueOn: '2026-03-20', firstOfYear: true }));
it('an inspection on the first day of FRUEHJAHR counts as done', () =>
expect(
inspectionDueOn(
{ createdOn: '2025-06-01', lastInspectedOn: '2026-03-20' },
{ segment: 'FRUEHJAHR', springSince: '2026-03-20' },
CONFIG,
),
).toEqual({ dueOn: '2026-04-03', firstOfYear: false, intervalDays: 14 }));
});
@@ -0,0 +1,35 @@
import { addDays } from '../common/dates';
import type { InspectionIntervalConfig } from '../automation/rule-config/rule-config.types';
import type { SeasonState } from './season-types';
export interface InspectionDue {
dueOn: string;
/** Due because the last inspection lies before this spring ("first of the year"). */
firstOfYear: boolean;
intervalDays: number;
}
/**
* The day an inspection is due: the last inspection (else the creation of
* the colony) plus the interval of today's segment. A colony not inspected
* since before FRUEHJAHR began is due from that day on. Never in WINTERRUHE.
*/
export function inspectionDueOn(
input: { createdOn: string; lastInspectedOn: string | null },
season: Pick<SeasonState, 'segment' | 'springSince'>,
config: InspectionIntervalConfig,
): InspectionDue | null {
if (season.segment === 'WINTERRUHE') {
return null;
}
const intervalDays = config.intervalDays[season.segment];
const base = input.lastInspectedOn ?? input.createdOn;
if (season.springSince !== null && base < season.springSince) {
return { dueOn: season.springSince, firstOfYear: true, intervalDays };
}
return {
dueOn: addDays(base, intervalDays),
firstOfYear: false,
intervalDays,
};
}
@@ -0,0 +1,30 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
// The age at which a queen counts as aged is SEASON_AUTOPILOT.queenMaxAgeYears,
// judged by isQueenAged. No second constant may appear in src.
const SRC = join(__dirname, '..');
const THIS_FILE = 'season/queen-age-threshold.spec.ts';
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return sources(path);
}
return entry.name.endsWith('.ts') ? [path] : [];
});
}
function offenders(pattern: RegExp): string[] {
return sources(SRC)
.filter((file) => pattern.test(readFileSync(file, 'utf8')))
.map((file) => relative(SRC, file).split(sep).join('/'))
.filter((file) => file !== THIS_FILE)
.sort();
}
describe('queen age threshold', () => {
it('there is no second queen age threshold in src', () =>
expect(offenders(/OLD_QUEEN_AGE_YEARS|QUEEN_AGED_MIN_YEARS/)).toEqual([]));
});
@@ -0,0 +1,17 @@
import { isQueenAged } from './queen-age';
describe('isQueenAged', () => {
it('is aged from queenMaxAgeYears after the hatch year', () => {
expect(isQueenAged(2024, '2026-06-10', 2)).toBe(true);
expect(isQueenAged(2025, '2026-06-10', 2)).toBe(false);
expect(isQueenAged(2023, '2026-06-10', 3)).toBe(true);
});
it('changes with the year, not within it', () => {
expect(isQueenAged(2025, '2026-12-31', 2)).toBe(false);
expect(isQueenAged(2025, '2027-01-01', 2)).toBe(true);
});
it('is never aged without a hatch year', () =>
expect(isQueenAged(null, '2026-06-10', 1)).toBe(false));
});
+15
View File
@@ -0,0 +1,15 @@
/**
* An aged queen: the current year minus her hatch year reaches
* `queenMaxAgeYears`. The one definition for QueenAged, the dashboard's
* OLD_QUEEN and autopilot A8. `today` is a YYYY-MM-DD day.
*/
export function isQueenAged(
hatchYear: number | null,
today: string,
queenMaxAgeYears: number,
): boolean {
if (hatchYear === null) {
return false;
}
return Number(today.slice(0, 4)) - hatchYear >= queenMaxAgeYears;
}
@@ -0,0 +1,92 @@
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
import { calendarPhase, calendarSeasonState } from './season-calendar';
const FALLBACK = DEFAULT_PHENOLOGY_CONFIG.calendarFallback;
describe('calendarPhase', () => {
it('is the WINTER phase of the previous year before SALWEIDE', () =>
expect(calendarPhase('2026-03-10', FALLBACK)).toEqual({
phase: 'WINTER',
since: '2025-11-15',
}));
it('is the latest phase whose calendar day has been reached', () => {
expect(calendarPhase('2026-05-12', FALLBACK)).toEqual({
phase: 'APFEL',
since: '2026-04-28',
});
expect(calendarPhase('2026-04-28', FALLBACK)).toEqual({
phase: 'APFEL',
since: '2026-04-28',
});
expect(calendarPhase('2026-11-15', FALLBACK)).toEqual({
phase: 'WINTER',
since: '2026-11-15',
});
});
});
describe('calendarSeasonState', () => {
const state = (today: string) =>
calendarSeasonState('apiary-1', today, DEFAULT_PHENOLOGY_CONFIG);
it('2026-03-10 is WINTERRUHE since the WINTER date of 2025', () =>
expect(state('2026-03-10')).toEqual({
apiaryId: 'apiary-1',
year: 2026,
phase: 'WINTER',
phaseSource: 'CALENDAR',
reportedOn: null,
segment: 'WINTERRUHE',
segmentSince: '2025-11-15',
springSince: null,
gts: null,
gtsThroughDay: null,
note: null,
}));
it('2026-05-12 is SCHWARMZEIT since 2026-04-15 by calendar', () =>
expect(state('2026-05-12')).toMatchObject({
phase: 'APFEL',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-15',
springSince: '2026-03-20',
}));
it('FRUEHJAHR starts with SALWEIDE and sets springSince', () =>
expect(state('2026-04-01')).toMatchObject({
segment: 'FRUEHJAHR',
segmentSince: '2026-03-20',
springSince: '2026-03-20',
}));
it('WINTERRUHE after WINTER keeps springSince of the year', () =>
expect(state('2026-12-01')).toMatchObject({
segment: 'WINTERRUHE',
segmentSince: '2026-11-15',
springSince: '2026-03-20',
}));
it('a segment spanning several phases begins at its first phase', () =>
expect(state('2026-06-25')).toMatchObject({
segment: 'SOMMER',
segmentSince: '2026-06-20',
}));
it('stays WINTERRUHE across the year change, springSince only after SALWEIDE', () => {
expect(state('2026-12-31')).toMatchObject({
year: 2026,
segment: 'WINTERRUHE',
segmentSince: '2026-11-15',
springSince: '2026-03-20',
});
expect(state('2027-01-01')).toMatchObject({
year: 2027,
phase: 'WINTER',
segment: 'WINTERRUHE',
segmentSince: '2026-11-15',
springSince: null,
});
});
});
@@ -0,0 +1,92 @@
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import { segmentOf } from './season-segment';
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
type SeasonState,
} from './season-types';
type CalendarFallback = Record<PhenologyPhaseKey, string>;
interface PhaseOnDay {
phase: PhenologyPhaseKey;
day: string;
}
/** The phases of `year` whose calendar day has been reached, in phase order. */
function reachedPhases(
year: number,
today: string,
fallback: CalendarFallback,
): PhaseOnDay[] {
return PHENOLOGY_PHASES.map((phase) => ({
phase,
day: `${year}-${fallback[phase]}`,
})).filter(({ day }) => day <= today);
}
/**
* The phase the calendar puts `today` in: the latest phase of the year whose
* day has been reached; before SALWEIDE still the WINTER of the previous year.
*/
export function calendarPhase(
today: string,
fallback: CalendarFallback,
): { phase: PhenologyPhaseKey | null; since: string | null } {
const year = Number(today.slice(0, 4));
const reached = reachedPhases(year, today, fallback);
const latest = reached[reached.length - 1];
if (latest === undefined) {
return { phase: 'WINTER', since: `${year - 1}-${fallback.WINTER}` };
}
return { phase: latest.phase, since: latest.day };
}
/** The day the segment of the latest reached phase began. */
function segmentStart(
reached: readonly PhaseOnDay[],
swarmSeason: PhenologyConfig['swarmSeason'],
fallbackStart: string,
): string {
const latest = reached[reached.length - 1];
if (latest === undefined) {
return fallbackStart;
}
const segment = segmentOf(latest.phase, swarmSeason);
let since = latest.day;
for (let i = reached.length - 2; i >= 0; i -= 1) {
if (segmentOf(reached[i].phase, swarmSeason) !== segment) {
break;
}
since = reached[i].day;
}
return since;
}
/**
* The season of an apiary from the calendar alone (no weather, no report):
* what every apiary gets until the phenology data of Task 25 is there.
*/
export function calendarSeasonState(
apiaryId: string,
today: string,
config: PhenologyConfig,
): SeasonState {
const year = Number(today.slice(0, 4));
const { phase, since } = calendarPhase(today, config.calendarFallback);
const reached = reachedPhases(year, today, config.calendarFallback);
const springDay = `${year}-${config.calendarFallback.SALWEIDE}`;
return {
apiaryId,
year,
phase,
phaseSource: 'CALENDAR',
reportedOn: null,
segment: segmentOf(phase, config.swarmSeason),
segmentSince: segmentStart(reached, config.swarmSeason, since ?? today),
springSince: springDay <= today ? springDay : null,
gts: null,
gtsThroughDay: null,
note: null,
};
}
@@ -0,0 +1,34 @@
import { segmentOf } from './season-segment';
import type { PhenologyPhaseKey } from './season-types';
const DEFAULT_SWARM_SEASON = { from: 'KIRSCHE', until: 'LINDE' } as const;
describe('segmentOf', () => {
it.each<[PhenologyPhaseKey | null, string]>([
[null, 'WINTERRUHE'],
['SALWEIDE', 'FRUEHJAHR'],
['KIRSCHE', 'SCHWARMZEIT'],
['APFEL', 'SCHWARMZEIT'],
['HOLUNDER', 'SCHWARMZEIT'],
['LINDE', 'SOMMER'],
['TRACHTENDE', 'SPAETSOMMER'],
['HERBST', 'HERBST'],
['WINTER', 'WINTERRUHE'],
])('%p -> %p', (phase, segment) => {
expect(segmentOf(phase, DEFAULT_SWARM_SEASON)).toBe(segment);
});
it('honours a configured swarm season start at APFEL', () =>
expect(segmentOf('KIRSCHE', { from: 'APFEL', until: 'LINDE' })).toBe(
'FRUEHJAHR',
));
it('honours a configured swarm season end at HOLUNDER', () => {
expect(segmentOf('APFEL', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
'SCHWARMZEIT',
);
expect(segmentOf('HOLUNDER', { from: 'KIRSCHE', until: 'HOLUNDER' })).toBe(
'SOMMER',
);
});
});
@@ -0,0 +1,38 @@
import type { PhenologyConfig } from '../automation/rule-config/rule-config.types';
import {
PHENOLOGY_PHASES,
type PhenologyPhaseKey,
type SeasonSegmentKey,
} from './season-types';
const phaseIndex = (phase: PhenologyPhaseKey): number =>
PHENOLOGY_PHASES.indexOf(phase);
/**
* The season segment a phase belongs to: WINTERRUHE from WINTER (and before
* the first phase), FRUEHJAHR from SALWEIDE, SCHWARMZEIT from the configured
* start, SOMMER from its end, SPAETSOMMER from TRACHTENDE, HERBST from HERBST.
*/
export function segmentOf(
phase: PhenologyPhaseKey | null,
swarmSeason: PhenologyConfig['swarmSeason'],
): SeasonSegmentKey {
switch (phase) {
case null:
case 'WINTER':
return 'WINTERRUHE';
case 'HERBST':
return 'HERBST';
case 'TRACHTENDE':
return 'SPAETSOMMER';
default: {
const index = phaseIndex(phase);
if (index >= phaseIndex(swarmSeason.until)) {
return 'SOMMER';
}
return index >= phaseIndex(swarmSeason.from)
? 'SCHWARMZEIT'
: 'FRUEHJAHR';
}
}
}
@@ -0,0 +1,51 @@
// Season vocabulary shared by rule settings, the weather sources and the
// season state. The Prisma enums follow in Task 23 with the same values.
export const PHENOLOGY_PHASES = [
'SALWEIDE',
'KIRSCHE',
'APFEL',
'HOLUNDER',
'LINDE',
'TRACHTENDE',
'HERBST',
'WINTER',
] as const;
export const SEASON_SEGMENTS = [
'WINTERRUHE',
'FRUEHJAHR',
'SCHWARMZEIT',
'SOMMER',
'SPAETSOMMER',
'HERBST',
] as const;
export const PHASE_SOURCES = ['REPORT', 'GTS', 'CALENDAR'] as const;
export type PhenologyPhaseKey = (typeof PHENOLOGY_PHASES)[number];
export type SeasonSegmentKey = (typeof SEASON_SEGMENTS)[number];
export type PhaseSourceKey = (typeof PHASE_SOURCES)[number];
export type SeasonNote =
'NO_COORDINATES' | 'WEATHER_GAPS' | 'WEATHER_STALE' | null;
/** Daily mean temperature of one day (YYYY-MM-DD); null when unknown. */
export interface DailyMean {
day: string;
meanTempC: number | null;
}
export interface SeasonState {
apiaryId: string;
year: number;
phase: PhenologyPhaseKey | null;
phaseSource: PhaseSourceKey | null;
reportedOn: string | null;
segment: SeasonSegmentKey;
segmentSince: string;
springSince: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SeasonService } from './season.service';
@Module({
providers: [SeasonService],
exports: [SeasonService],
})
export class SeasonModule {}
@@ -0,0 +1,58 @@
import { DEFAULT_PHENOLOGY_CONFIG } from '../automation/rule-config/phenology.config';
import type { DbClient } from '../prisma/db-client';
import { SeasonService } from './season.service';
describe('SeasonService', () => {
const findUnique = jest.fn();
const db = { automationRule: { findUnique } } as unknown as DbClient;
const service = new SeasonService();
beforeEach(() => {
findUnique.mockReset().mockResolvedValue(null);
});
it('gives every apiary the calendar state, reading PHENOLOGY once', async () => {
const states = await service.statesFor(
db,
['apiary-1', 'apiary-2', 'apiary-1'],
'2026-05-12',
);
expect([...states.keys()]).toEqual(['apiary-1', 'apiary-2']);
expect(states.get('apiary-2')).toMatchObject({
apiaryId: 'apiary-2',
phaseSource: 'CALENDAR',
segment: 'SCHWARMZEIT',
});
expect(findUnique).toHaveBeenCalledTimes(1);
});
it('reads no rule without apiaries', async () => {
expect((await service.statesFor(db, [], '2026-05-12')).size).toBe(0);
expect(findUnique).not.toHaveBeenCalled();
});
it('follows the stored calendar of the PHENOLOGY rule', async () => {
findUnique.mockResolvedValue({
id: 'rule-1',
active: true,
actionTemplate: null,
config: {
...DEFAULT_PHENOLOGY_CONFIG,
calendarFallback: {
...DEFAULT_PHENOLOGY_CONFIG.calendarFallback,
SALWEIDE: '03-01',
KIRSCHE: '04-01',
},
},
});
const states = await service.statesFor(db, ['apiary-1'], '2026-04-05');
expect(states.get('apiary-1')).toMatchObject({
segment: 'SCHWARMZEIT',
segmentSince: '2026-04-01',
springSince: '2026-03-01',
});
});
});
@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { readRule } from '../automation/rule-config';
import type { DbClient } from '../prisma/db-client';
import { calendarSeasonState } from './season-calendar';
import type { SeasonState } from './season-types';
/**
* The season of apiaries. Until the phenology data exists (Task 25) every
* apiary gets the calendar state; then ApiaryPhenology takes over here, and
* the callers stay as they are.
*/
@Injectable()
export class SeasonService {
/** The season state of each given apiary on `today` (YYYY-MM-DD, Berlin). */
async statesFor(
db: DbClient,
apiaryIds: readonly string[],
today: string,
): Promise<Map<string, SeasonState>> {
const states = new Map<string, SeasonState>();
if (apiaryIds.length === 0) {
return states;
}
const { config } = await readRule(db, 'PHENOLOGY');
for (const apiaryId of new Set(apiaryIds)) {
states.set(apiaryId, calendarSeasonState(apiaryId, today, config));
}
return states;
}
}
@@ -0,0 +1,128 @@
import { DEFAULT_SEASON_AUTOPILOT_CONFIG } from '../automation/rule-config/season-autopilot.config';
import { varroaMeasurementDue } from './varroa-due';
const CONFIG = DEFAULT_SEASON_AUTOPILOT_CONFIG;
const SOMMER = { segment: 'SOMMER', springSince: '2026-03-20' } as const;
const WINTERRUHE = {
segment: 'WINTERRUHE',
springSince: '2026-03-20',
} as const;
const NEVER_MEASURED = {
createdOn: '2025-01-01',
lastMeasuredOn: null,
lastMeasurementId: null,
};
describe('varroaMeasurementDue', () => {
it('is due 28 days after the last measurement outside WINTERRUHE', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-06-01',
lastMeasurementId: 'm-1',
},
SOMMER,
CONFIG,
'2026-06-30',
),
).toEqual({ dueOn: '2026-06-29', kind: 'INTERVAL', anchor: 'm-1' }));
it('counts from createdOn without a measurement and anchors on none', () =>
expect(
varroaMeasurementDue(
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
SOMMER,
CONFIG,
'2026-06-30',
),
).toEqual({ dueOn: '2026-05-18', kind: 'INTERVAL', anchor: 'none' }));
it('uses the configured interval', () =>
expect(
varroaMeasurementDue(
{ ...NEVER_MEASURED, createdOn: '2026-04-20' },
SOMMER,
{ ...CONFIG, varroaMeasureIntervalDays: 14 },
'2026-06-30',
),
).toMatchObject({ dueOn: '2026-05-04' }));
it('is due on 11-15 in the winter window without a measurement since 11-01', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-09-20',
lastMeasurementId: 'm-1',
},
WINTERRUHE,
CONFIG,
'2026-11-20',
),
).toEqual({
dueOn: '2026-11-15',
kind: 'WINTER_WINDOW',
anchor: 'winter-2026',
}));
it('is not due in the winter window after a measurement since 11-01', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-11-03',
lastMeasurementId: 'm-2',
},
WINTERRUHE,
CONFIG,
'2026-11-20',
),
).toBeNull());
it('is not due in WINTERRUHE outside the winter window', () => {
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-21'),
).toBeNull();
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-02-10'),
).toBeNull();
});
it('opens the winter window on its first and last day', () => {
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-11-15'),
).toMatchObject({ kind: 'WINTER_WINDOW' });
expect(
varroaMeasurementDue(NEVER_MEASURED, WINTERRUHE, CONFIG, '2026-12-20'),
).toMatchObject({ kind: 'WINTER_WINDOW' });
});
it('moves an interval due day inside the winter to the start of FRUEHJAHR', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2026-11-20',
lastMeasurementId: 'm-3',
},
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
CONFIG,
'2027-03-25',
),
).toEqual({ dueOn: '2027-03-20', kind: 'INTERVAL', anchor: 'm-3' }));
it('keeps a due day after the start of FRUEHJAHR', () =>
expect(
varroaMeasurementDue(
{
createdOn: '2025-01-01',
lastMeasuredOn: '2027-03-10',
lastMeasurementId: 'm-4',
},
{ segment: 'FRUEHJAHR', springSince: '2027-03-20' },
CONFIG,
'2027-04-01',
),
).toMatchObject({ dueOn: '2027-04-07' }));
});
+67
View File
@@ -0,0 +1,67 @@
import { addDays } from '../common/dates';
import type { SeasonAutopilotConfig } from '../automation/rule-config/rule-config.types';
import type { SeasonState } from './season-types';
export interface VarroaDue {
dueOn: string;
kind: 'INTERVAL' | 'WINTER_WINDOW';
/** Changes with every new measurement (or winter), so one due date stays one reminder. */
anchor: string;
}
/*
* Invariant: the winter window (SEASON_AUTOPILOT.winterVarroaWindow, default
* 11-15) lies inside WINTERRUHE, i.e. it does not begin before the WINTER
* date of PHENOLOGY.calendarFallback (default 11-15). Before WINTER the
* interval rule applies instead.
*/
/** A measurement from this day on counts for the winter window of the year. */
const WINTER_MEASUREMENT_FROM = '11-01';
/**
* The day a varroa measurement is due. Outside WINTERRUHE: the last
* measurement (else the creation of the colony) plus the interval. In
* WINTERRUHE only inside the winter window, once, if there was no
* measurement since 11-01.
*/
export function varroaMeasurementDue(
input: {
createdOn: string;
lastMeasuredOn: string | null;
lastMeasurementId: string | null;
},
season: Pick<SeasonState, 'segment' | 'springSince'>,
config: SeasonAutopilotConfig,
today: string,
): VarroaDue | null {
if (season.segment !== 'WINTERRUHE') {
const base = input.lastMeasuredOn ?? input.createdOn;
const intervalDue = addDays(base, config.varroaMeasureIntervalDays);
// A due day that fell into the winter is taken up at the start of
// FRUEHJAHR, like the first inspection of the year.
const dueOn =
season.springSince !== null && intervalDue < season.springSince
? season.springSince
: intervalDue;
return {
dueOn,
kind: 'INTERVAL',
anchor: input.lastMeasurementId ?? 'none',
};
}
const year = today.slice(0, 4);
const { from, to } = config.winterVarroaWindow;
const inWindow = today >= `${year}-${from}` && today <= `${year}-${to}`;
const measuredThisWinter =
input.lastMeasuredOn !== null &&
input.lastMeasuredOn >= `${year}-${WINTER_MEASUREMENT_FROM}`;
if (!inWindow || measuredThisWinter) {
return null;
}
return {
dueOn: `${year}-${from}`,
kind: 'WINTER_WINDOW',
anchor: `winter-${year}`,
};
}
@@ -40,6 +40,21 @@ describe('summarizeStock', () => {
});
});
it('leaves a BOTTLING movement out: the honey batch ledger is not product stock', () => {
const summary = summarizeStock(10, [
{ type: 'BOTTLING', quantity: -500 },
{ type: 'SHOP_SALE', quantity: -2 },
]);
expect(summary).toEqual({
initial: 10,
soldInShop: 2,
withdrawn: 0,
corrected: 0,
consumed: 0,
remaining: 8,
});
});
it('counts raw material used in production, net of give-backs', () => {
const summary = summarizeStock(4200, [
{ type: 'CONSUMPTION', quantity: -800 },
+7 -2
View File
@@ -7,8 +7,9 @@
* always derived, never stored, so it cannot drift from the ledger.
*/
export type MovementType =
'SHOP_SALE' | 'SHOP_RETURN' | 'WITHDRAWAL' | 'CORRECTION' | 'CONSUMPTION';
import type { BatchMovementType } from '../../generated/prisma';
export type MovementType = BatchMovementType;
export interface LedgerEntry {
type: MovementType;
@@ -45,6 +46,10 @@ export function summarizeStock(
withdrawn -= entry.quantity;
} else if (entry.type === 'CONSUMPTION') {
consumed -= entry.quantity;
} else if (entry.type === 'BOTTLING') {
// Takes honey out of a honey batch (the grams ledger, Task 23); it is
// never booked on a product batch, so it does not count here.
continue;
} else {
corrected += entry.quantity;
}
@@ -0,0 +1,71 @@
import { buildEvidenceLookup } from './evidence-lookup';
function db() {
return {
colony: { findMany: jest.fn().mockResolvedValue([]) },
queen: { findMany: jest.fn().mockResolvedValue([]) },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
apiary: { findMany: jest.fn().mockResolvedValue([]) },
stockItem: { findMany: jest.fn().mockResolvedValue([]) },
};
}
describe('buildEvidenceLookup', () => {
it('reads each model once for all evidences', async () => {
const client = db();
client.colony.findMany.mockResolvedValue([
{ id: 'c1', code: 'V-1' },
{ id: 'c2', code: 'V-2' },
]);
client.queen.findMany.mockResolvedValue([{ id: 'q1', code: 'K-9' }]);
client.treatment.findMany.mockResolvedValue([
{ id: 't1', product: 'Oxalsäure' },
]);
client.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hausstand' }]);
client.stockItem.findMany.mockResolvedValue([{ id: 's1', name: 'Gläser' }]);
const lookup = await buildEvidenceLookup(client as never, [
{ colonyId: 'c1', parentColonyId: 'c2', queenId: 'q1' },
{ colonyId: 'c1', treatmentId: 't1', apiaryId: 'a1', stockItemId: 's1' },
]);
for (const model of Object.values(client)) {
expect(model.findMany).toHaveBeenCalledTimes(1);
}
expect(
(client.colony.findMany.mock.calls as { where: unknown }[][])[0][0].where,
).toEqual({
id: { in: ['c1', 'c2'] },
});
expect(lookup.colonyCode('c1')).toBe('V-1');
expect(lookup.colonyCode('c2')).toBe('V-2');
expect(lookup.queenCode('q1')).toBe('K-9');
expect(lookup.treatmentProduct('t1')).toBe('Oxalsäure');
expect(lookup.apiaryName('a1')).toBe('Hausstand');
expect(lookup.stockItemName('s1')).toBe('Gläser');
});
it('asks for no model the evidence does not mention', async () => {
const client = db();
const lookup = await buildEvidenceLookup(client as never, [
{ baseDate: '2026-05-01', count: 3, flag: true, none: null },
{ colonyId: 42 },
]);
for (const model of Object.values(client)) {
expect(model.findMany).not.toHaveBeenCalled();
}
expect(lookup.colonyCode('c1')).toBeNull();
});
it('answers null for an id that no longer exists', async () => {
const client = db();
const lookup = await buildEvidenceLookup(client as never, [
{ queenId: 'gone' },
]);
expect(lookup.queenCode('gone')).toBeNull();
});
});
@@ -0,0 +1,109 @@
import type { DbClient } from '../prisma/db-client';
import type { SuggestionEvidence } from './suggestion.types';
/** Evidence keys whose value is an ID the texts show as a name. */
export const LOOKUP_EVIDENCE_KEYS = [
'colonyId',
'parentColonyId',
'queenId',
'treatmentId',
'apiaryId',
'stockItemId',
] as const;
type LookupKey = (typeof LOOKUP_EVIDENCE_KEYS)[number];
/** Names for the IDs in evidence; null for an ID that no longer exists. */
export interface EvidenceLookup {
colonyCode(id: string): string | null;
queenCode(id: string): string | null;
treatmentProduct(id: string): string | null;
apiaryName(id: string): string | null;
stockItemName(id: string): string | null;
}
type NameMap = ReadonlyMap<string, string>;
function idsOf(
evidences: readonly SuggestionEvidence[],
keys: readonly LookupKey[],
): string[] {
const ids = new Set<string>();
for (const evidence of evidences) {
for (const key of keys) {
const value = evidence[key];
if (typeof value === 'string') {
ids.add(value);
}
}
}
return [...ids];
}
/** No query at all for a model no evidence refers to. */
async function namesOf(
ids: readonly string[],
read: (ids: string[]) => Promise<{ id: string; name: string }[]>,
): Promise<NameMap> {
if (ids.length === 0) {
return new Map();
}
const rows = await read([...ids]);
return new Map(rows.map((row) => [row.id, row.name]));
}
/**
* Reads the names the texts of these suggestions mention: one query per
* model.
*/
export async function buildEvidenceLookup(
db: DbClient,
evidences: readonly SuggestionEvidence[],
): Promise<EvidenceLookup> {
const [colonies, queens, treatments, apiaries, stockItems] =
await Promise.all([
namesOf(idsOf(evidences, ['colonyId', 'parentColonyId']), async (ids) =>
(
await db.colony.findMany({
where: { id: { in: ids } },
select: { id: true, code: true },
})
).map(({ id, code }) => ({ id, name: code })),
),
namesOf(idsOf(evidences, ['queenId']), async (ids) =>
(
await db.queen.findMany({
where: { id: { in: ids } },
select: { id: true, code: true },
})
).map(({ id, code }) => ({ id, name: code })),
),
namesOf(idsOf(evidences, ['treatmentId']), async (ids) =>
(
await db.treatment.findMany({
where: { id: { in: ids } },
select: { id: true, product: true },
})
).map(({ id, product }) => ({ id, name: product })),
),
namesOf(idsOf(evidences, ['apiaryId']), (ids) =>
db.apiary.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
}),
),
namesOf(idsOf(evidences, ['stockItemId']), (ids) =>
db.stockItem.findMany({
where: { id: { in: ids } },
select: { id: true, name: true },
}),
),
]);
return {
colonyCode: (id) => colonies.get(id) ?? null,
queenCode: (id) => queens.get(id) ?? null,
treatmentProduct: (id) => treatments.get(id) ?? null,
apiaryName: (id) => apiaries.get(id) ?? null,
stockItemName: (id) => stockItems.get(id) ?? null,
};
}
@@ -0,0 +1,42 @@
import { planBaseDate, planOf, planSteps } from './suggestion-plan';
const PLAN = [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
{ step: 'EGGS_CHECK', offsetDays: 28 },
];
describe('suggestion plan', () => {
it('places every step at base day + offset, with its window end', () => {
expect(planSteps(PLAN, '2026-05-01')).toEqual([
{ step: 'RAISE_CELLS_CHECK', day: '2026-05-10', windowUntil: null },
{ step: 'MATING_CHECK', day: '2026-05-22', windowUntil: '2026-05-25' },
{ step: 'EGGS_CHECK', day: '2026-05-29', windowUntil: null },
]);
});
it('moves the base so the first step lands on the chosen day', () => {
expect(
planSteps(PLAN, '2026-05-01', '2026-05-12').map((s) => s.day),
).toEqual(['2026-05-12', '2026-05-24', '2026-05-31']);
expect(
planSteps(PLAN, '2026-05-01', '2026-05-08').map((s) => s.day),
).toEqual(['2026-05-08', '2026-05-20', '2026-05-27']);
});
it('reads a stored plan and rejects an unusable one', () => {
expect(planOf(PLAN)).toEqual(PLAN);
expect(planOf(null)).toBeNull();
expect(planOf([])).toBeNull();
expect(planOf([{ step: 'X' }])).toBeNull();
expect(planOf('nope')).toBeNull();
});
it('takes the base day from the evidence, else the fallback', () => {
expect(planBaseDate({ baseDate: '2026-05-01' }, '2026-06-01')).toBe(
'2026-05-01',
);
expect(planBaseDate({ baseDate: 'soon' }, '2026-06-01')).toBe('2026-06-01');
expect(planBaseDate({}, '2026-06-01')).toBe('2026-06-01');
});
});
@@ -0,0 +1,71 @@
import { addDays, daysBetween } from '../common/dates';
import type { PlanStepConfig } from '../automation/rule-config';
/** One task a plan suggestion turns into. */
export interface PlannedStep {
step: string;
/** YYYY-MM-DD */
day: string;
/** YYYY-MM-DD, the end of the step's window. */
windowUntil: string | null;
}
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function isPlanStep(value: unknown): value is PlanStepConfig {
if (typeof value !== 'object' || value === null) {
return false;
}
const { step, offsetDays, windowDays } = value as Record<string, unknown>;
return (
typeof step === 'string' &&
Number.isInteger(offsetDays) &&
(windowDays === undefined || Number.isInteger(windowDays))
);
}
/** The stored plan; null when there is none or it is unusable. */
export function planOf(json: unknown): PlanStepConfig[] | null {
if (!Array.isArray(json) || json.length === 0 || !json.every(isPlanStep)) {
return null;
}
return json;
}
/** The base day of a plan: `evidence.baseDate`, else the fallback. */
export function planBaseDate(
evidence: Record<string, unknown>,
fallbackDay: string,
): string {
const { baseDate } = evidence;
return typeof baseDate === 'string' && DAY_PATTERN.test(baseDate)
? baseDate
: fallbackDay;
}
/**
* The day of every step: base day + offset. With `firstStepDay` the base is
* moved so that the first step lands on that day and every other step keeps
* its distance to it.
*/
export function planSteps(
plan: readonly PlanStepConfig[],
baseDate: string,
firstStepDay?: string | null,
): PlannedStep[] {
const shift =
firstStepDay === null || firstStepDay === undefined
? 0
: daysBetween(addDays(baseDate, plan[0].offsetDays), firstStepDay);
return plan.map(({ step, offsetDays, windowDays }) => {
const day = addDays(baseDate, offsetDays + shift);
return {
step,
day,
windowUntil:
windowDays !== undefined && windowDays > 0
? addDays(day, windowDays)
: null,
};
});
}
@@ -0,0 +1,96 @@
import type { EvidenceLookup } from './evidence-lookup';
import {
formatDayDe,
formatNumberDe,
INTENT_TITLES,
PLAN_STEP_TITLES,
renderSuggestion,
SUGGESTION_TEXT_REGISTERS,
SUGGESTION_TEXTS,
} from './suggestion-texts';
const MAX_REASONS = 2;
const MAX_REASON_LENGTH = 90;
const lookup: EvidenceLookup = {
colonyCode: (id) => `V-${id}`,
queenCode: (id) => `Q-${id}`,
treatmentProduct: () => 'Oxalsäure',
apiaryName: () => 'Hausstand',
stockItemName: () => 'Gläser',
};
describe('suggestion texts', () => {
it('every registered template renders its example in at most 2 reasons of at most 90 characters', () => {
for (const [reasonCode, template] of Object.entries(SUGGESTION_TEXTS)) {
const { title, reasons } = template.render(
template.example,
lookup,
'2026-05-05',
);
expect({ reasonCode, empty: title.trim() === '' }).toEqual({
reasonCode,
empty: false,
});
expect(reasons.length).toBeLessThanOrEqual(MAX_REASONS);
for (const reason of reasons) {
expect(reason.length).toBeLessThanOrEqual(MAX_REASON_LENGTH);
}
}
});
it('no reason code is registered twice', () => {
const codes = SUGGESTION_TEXT_REGISTERS.flatMap((register) =>
Object.keys(register),
);
expect(new Set(codes).size).toBe(codes.length);
expect(Object.keys(SUGGESTION_TEXTS)).toHaveLength(codes.length);
});
it('formats numbers and days in German', () => {
expect(formatNumberDe(12.4)).toBe('12,4');
expect(formatNumberDe(12)).toBe('12');
expect(formatNumberDe(0.456, 2)).toBe('0,46');
expect(formatDayDe('2026-05-05')).toBe('05.05.');
expect(formatDayDe('2026-05-05', true)).toBe('05.05.2026');
});
it('falls back to INTENT_TITLES for an unknown reason code', () => {
expect(
renderSuggestion(
{ reasonCode: 'NO_SUCH_CODE', evidence: {}, intent: 'FEED' },
lookup,
'2026-05-05',
),
).toEqual({ title: INTENT_TITLES.FEED, reasons: [] });
});
it('replaces the title with a non-empty override only', () => {
const suggestion = {
reasonCode: 'NO_SUCH_CODE',
evidence: {},
intent: 'INSPECT' as const,
};
expect(
renderSuggestion(suggestion, lookup, '2026-05-05', 'Eigener Titel').title,
).toBe('Eigener Titel');
expect(renderSuggestion(suggestion, lookup, '2026-05-05', '').title).toBe(
INTENT_TITLES.INSPECT,
);
expect(renderSuggestion(suggestion, lookup, '2026-05-05', null).title).toBe(
INTENT_TITLES.INSPECT,
);
});
it('has a title for every intent and every split step', () => {
expect(Object.keys(INTENT_TITLES)).toHaveLength(17);
for (const step of [
'RAISE_CELLS_CHECK',
'MATING_CHECK',
'EGGS_CHECK',
'ACCEPTANCE_CHECK',
]) {
expect(PLAN_STEP_TITLES[step]).toBeTruthy();
}
});
});
@@ -0,0 +1,127 @@
import type {
AutomationTriggerType,
Suggestion,
SuggestionIntent,
} from '../../generated/prisma';
import type { EvidenceLookup } from './evidence-lookup';
import type { SuggestionEvidence } from './suggestion.types';
import { AUTOPILOT_TEXTS } from './texts/autopilot-texts';
import { CHAIN_PLAN_STEP_TITLES, CHAIN_TEXTS } from './texts/chain-texts';
import { CLEANUP_TEXTS } from './texts/cleanup-texts';
import { RULE_TEXTS } from './texts/rule-texts';
export type { EvidenceLookup };
export interface RenderedSuggestionText {
title: string;
/** At most 2, each at most 90 characters. */
reasons: string[];
}
export interface SuggestionTextTemplate {
ruleKey: AutomationTriggerType;
/** Rendered by the text spec: the limits hold for it. */
example: SuggestionEvidence;
render(
evidence: SuggestionEvidence,
lookup: EvidenceLookup,
today: string,
): RenderedSuggestionText;
}
/** Templates by reason code. */
export type SuggestionTextRegister = Readonly<
Record<string, SuggestionTextTemplate>
>;
export const SUGGESTION_TEXT_REGISTERS: readonly SuggestionTextRegister[] = [
RULE_TEXTS,
CHAIN_TEXTS,
AUTOPILOT_TEXTS,
CLEANUP_TEXTS,
];
/** All templates; a reason code in two registers is a programming error. */
function mergeRegisters(
registers: readonly SuggestionTextRegister[],
): SuggestionTextRegister {
const merged: Record<string, SuggestionTextTemplate> = {};
for (const register of registers) {
for (const [reasonCode, template] of Object.entries(register)) {
if (Object.hasOwn(merged, reasonCode)) {
throw new Error(`Reason code registered twice: ${reasonCode}`);
}
merged[reasonCode] = template;
}
}
return merged;
}
export const SUGGESTION_TEXTS: SuggestionTextRegister = mergeRegisters(
SUGGESTION_TEXT_REGISTERS,
);
export const PLAN_STEP_TITLES: Readonly<Record<string, string>> =
CHAIN_PLAN_STEP_TITLES;
/** Title of a suggestion whose reason code has no template. */
export const INTENT_TITLES: Readonly<Record<SuggestionIntent, string>> = {
INSPECT: 'Durchsicht fällig',
SWARM_CHECK: 'Schwarmkontrolle',
QUEEN_CHECK: 'Königin prüfen',
QUEEN_ACCEPTANCE: 'Annahme der Königin prüfen',
QUEEN_REPLACE: 'Königin ersetzen',
SPLIT_PLAN: 'Ableger kontrollieren',
VARROA_MEASURE: 'Varroa messen',
VARROA_RECHECK: 'Varroa nachkontrollieren',
VARROA_TREAT: 'Varroa behandeln',
SUMMER_TREATMENT: 'Sommerbehandlung planen',
FEED: 'Füttern',
HONEY_SUPER: 'Honigraum aufsetzen',
WINTER_CHECK: 'Winterkontrolle',
HEALTH_CHECK: 'Gesundheit prüfen',
FOLLOW_UP: 'Nachkontrolle',
STOCK_REORDER: 'Nachbestellen',
CLEANUP: 'Aufräumen',
};
/**
* Title and reasons of a suggestion, rendered when it is read from its
* reason code and evidence. `titleOverride` (the rule's actionTemplate)
* replaces the title when it is not empty.
*/
export function renderSuggestion(
s: Pick<Suggestion, 'reasonCode' | 'evidence' | 'intent'>,
lookup: EvidenceLookup,
today: string,
titleOverride?: string | null,
): RenderedSuggestionText {
const template = Object.hasOwn(SUGGESTION_TEXTS, s.reasonCode)
? SUGGESTION_TEXTS[s.reasonCode]
: undefined;
const rendered: RenderedSuggestionText = template
? template.render(s.evidence as SuggestionEvidence, lookup, today)
: { title: INTENT_TITLES[s.intent], reasons: [] };
const override = titleOverride?.trim();
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);
}
@@ -0,0 +1,86 @@
import type {
AutomationTriggerType,
Suggestion,
SuggestionIntent,
SuggestionMode,
SuggestionSource,
Task,
TaskPriority,
TaskRelatedType,
} from '../../generated/prisma';
import type { PlanStepConfig } from '../automation/rule-config';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
export type EvidenceValue =
string | number | boolean | null | readonly string[];
/** Only IDs, numbers and YYYY-MM-DD; a plan's base day is `baseDate`. */
export type SuggestionEvidence = Readonly<Record<string, EvidenceValue>>;
export interface ProposeInput {
ruleKey: AutomationTriggerType;
source: SuggestionSource;
mode: SuggestionMode;
intent: SuggestionIntent;
priority?: TaskPriority;
relatedType: TaskRelatedType;
relatedId: string | null;
colonyId: string | null;
reasonCode: string;
evidence: SuggestionEvidence;
requiredPermission: PermissionKey | null;
dueDate?: string | null;
expiresAt?: string | null;
plan?: readonly PlanStepConfig[] | null;
anchor: string;
sourceEventId?: string | null;
/**
* Default false. Rule and chain suggestions pass `config.notify`, the
* autopilot never; the notification itself is wired by Task 7.
*/
notify?: boolean;
}
/** The intent a task created by a legacy rule stands for. */
export const INTENT_OF_LEGACY_TRIGGER: Readonly<
Partial<Record<AutomationTriggerType, SuggestionIntent>>
> = {
QUEENLESS_NO_EGGS: 'QUEEN_CHECK',
VARROA_THRESHOLD: 'VARROA_TREAT',
FEED_STATUS_CRITICAL: 'FEED',
SWARM_MOOD_HIGH: 'SWARM_CHECK',
COLONY_HEALTH_CRITICAL: 'HEALTH_CHECK',
STOCK_ITEM_LOW: 'STOCK_REORDER',
RAW_MATERIAL_LOW: 'STOCK_REORDER',
TREATMENT_FOLLOW_UP: 'FOLLOW_UP',
FEEDING_FOLLOW_UP: 'FOLLOW_UP',
};
/** `<ruleKey>:<relatedId|none>:<anchor>`, e.g. `INSPECTION_INTERVAL:col_1:insp_9`. */
export function suggestionDedupeKey(
ruleKey: AutomationTriggerType,
relatedId: string | null,
anchor: string,
): string {
return `${ruleKey}:${relatedId ?? 'none'}:${anchor}`;
}
export interface AcceptSuggestionInput {
/** YYYY-MM-DD; for a plan the day of its first step. */
dueDate?: string | null;
assignedUserId?: string | null;
}
export interface AcceptSuggestionResult {
suggestionId: string;
status: 'ACCEPTED';
tasks: Task[];
}
export type SuggestionResolveNote =
| 'Bedingung entfällt'
| 'Autopilot aus'
| 'Aufgeräumt'
| 'Mit Durchsicht erledigt';
export type { Suggestion };
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { TasksModule } from '../tasks/tasks.module';
import { SuggestionsService } from './suggestions.service';
/**
* Suggestions of the rules, chains and the season autopilot. The permission resolver
* the views need comes from the global PermissionsModule (API) or JobPermissionsModule (worker and jobs).
*/
@Module({
imports: [TasksModule, AuditModule],
providers: [SuggestionsService],
exports: [SuggestionsService],
})
export class SuggestionsModule {}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,700 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import {
Prisma,
type AutomationTriggerType,
type Suggestion,
type SuggestionIntent,
type SuggestionStatus,
type Task,
type TaskPriority,
type TaskRelatedType,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRuleIn, readRule } from '../automation/rule-config';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
import { isLivingColonyStatus } from '../colonies/colony-status';
import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient, type DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import {
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
} from '../prisma/row-lock';
import type { NewTaskInput } from '../tasks/tasks.service';
import { TasksService } from '../tasks/tasks.service';
import { buildEvidenceLookup } from './evidence-lookup';
import { planBaseDate, planOf, planSteps } from './suggestion-plan';
import {
formatDayDe,
INTENT_TITLES,
PLAN_STEP_TITLES,
renderSuggestion,
} from './suggestion-texts';
import {
INTENT_OF_LEGACY_TRIGGER,
suggestionDedupeKey,
type AcceptSuggestionInput,
type AcceptSuggestionResult,
type ProposeInput,
type SuggestionEvidence,
type SuggestionResolveNote,
} from './suggestion.types';
const OPEN_TASK_STATUSES = ['OPEN', 'IN_PROGRESS'] as const;
const BULK_MIN = 1;
const BULK_MAX = 50;
const MAX_NOTE_LENGTH = 500;
const NOT_OPEN_MESSAGE = 'Der Vorschlag ist nicht mehr offen.';
const COLONY_ENDED_MESSAGE = 'Das Volk ist nicht mehr aktiv.';
interface Target {
/** Scopes the check of an object-less target to one rule. */
ruleKey?: AutomationTriggerType;
colonyId: string | null;
relatedType: TaskRelatedType;
relatedId: string | null;
}
/** The task fields every task of one accepted suggestion shares. */
type SharedTaskFields = Omit<NewTaskInput, 'title' | 'description' | 'dueDate'>;
function notOpen(status: SuggestionStatus): ConflictException {
return new ConflictException({ message: NOT_OPEN_MESSAGE, status });
}
function parseDay(day: string | null | undefined): string | null {
if (!day) {
return null;
}
try {
dayToDate(day);
} catch {
throw new BadRequestException('Bitte ein gültiges Datum wählen.');
}
return day;
}
function assertBulkIds(ids: readonly string[]): void {
if (
ids.length < BULK_MIN ||
ids.length > BULK_MAX ||
new Set(ids).size !== ids.length
) {
throw new BadRequestException(
`Bitte ${BULK_MIN} bis ${BULK_MAX} verschiedene Vorschläge wählen.`,
);
}
}
function taskIntent(task: {
sourceSuggestion: { intent: SuggestionIntent } | null;
sourceAutomationTrigger: keyof typeof INTENT_OF_LEGACY_TRIGGER | null;
}): SuggestionIntent {
return (
task.sourceSuggestion?.intent ??
(task.sourceAutomationTrigger === null
? undefined
: INTENT_OF_LEGACY_TRIGGER[task.sourceAutomationTrigger]) ??
'FOLLOW_UP'
);
}
/**
* The one place suggestions are proposed, decided and cleaned up: rule,
* chain and autopilot suggestions share one model and one set of rules (at
* most one open thing per intent and object, an anchor is proposed once).
* Every decision is a compare-and-set under the suggestion's row lock, with
* its audit entry in the same transaction.
*/
@Injectable()
export class SuggestionsService {
private readonly logger = new Logger(SuggestionsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly tasks: TasksService,
) {}
/**
* Proposes a suggestion unless its anchor was proposed before (the dedupe
* key) or something of the same intent is already open for the object.
* Locks the colony first, then the rule, so rule, chain and autopilot
* proposals for one colony are serialised and the intent check holds
* across rules. Resolves to the new suggestion, or null.
*/
async proposeIn(
tx: Prisma.TransactionClient,
input: ProposeInput,
): Promise<Suggestion | null> {
assertTransactionClient(tx, 'SuggestionsService.proposeIn');
if (
input.colonyId !== null &&
!(await lockRowForUpdate(tx, 'Colony', input.colonyId))
) {
// The colony is gone: nothing to propose (and no FK error).
return null;
}
await lockRuleIn(tx, input.ruleKey);
const open = await this.openIntentsIn(tx, input);
if (open.has(input.intent)) {
return null;
}
const created = await tx.suggestion.createManyAndReturn({
data: [this.rowOf(input)],
skipDuplicates: true,
});
if (created.length === 0) {
return null;
}
const [suggestion] = created;
await this.auditLog.record(
{
entityType: 'Suggestion',
entityId: suggestion.id,
action: 'CREATE',
afterData: suggestion,
},
tx,
);
return suggestion;
}
/** The intents with an open suggestion or an open task for the object. */
async openIntentsIn(
db: DbClient,
target: Target,
): Promise<Set<SuggestionIntent>> {
const suggestionWhere: Prisma.SuggestionWhereInput =
target.colonyId !== null
? { status: 'OPEN', colonyId: target.colonyId }
: {
status: 'OPEN',
relatedType: target.relatedType,
relatedId: target.relatedId,
...(this.isObjectLess(target) ? { ruleKey: target.ruleKey } : {}),
};
// One after the other, not Promise.all: on a transaction client both
// queries share one connection and are queued anyway, and the order
// stays the same in every transaction.
const suggestions = await db.suggestion.findMany({
where: suggestionWhere,
select: { intent: true },
});
const tasks = await db.task.findMany({
where: {
status: { in: [...OPEN_TASK_STATUSES] },
OR: this.taskObjectsOf(target),
},
select: {
sourceAutomationTrigger: true,
sourceSuggestion: { select: { intent: true } },
},
});
return new Set([
...suggestions.map((suggestion) => suggestion.intent),
...tasks.map(taskIntent),
]);
}
/** Brings an open suggestion up to date with what the rule sees now. */
async refreshIn(
tx: Prisma.TransactionClient,
id: string,
patch: {
evidence: SuggestionEvidence;
dueDate: string | null;
priority: TaskPriority;
expiresAt: string | null;
},
): Promise<void> {
assertTransactionClient(tx, 'SuggestionsService.refreshIn');
await tx.suggestion.updateMany({
where: { id, status: 'OPEN' },
data: {
evidence: patch.evidence,
dueDate: patch.dueDate === null ? null : dayToDate(patch.dueDate),
priority: patch.priority,
expiresAt: patch.expiresAt === null ? null : dayToDate(patch.expiresAt),
},
});
}
/**
* OPEN to RESOLVED because the condition is gone; frees the dedupe key in
* the same statement, so the same anchor may be proposed again.
*/
async resolveIn(
tx: Prisma.TransactionClient,
ids: readonly string[],
note: SuggestionResolveNote,
): Promise<number> {
assertTransactionClient(tx, 'SuggestionsService.resolveIn');
if (ids.length === 0) {
return 0;
}
return tx.$executeRaw`UPDATE "Suggestion"
SET status = 'RESOLVED', "dedupeKey" = NULL, "decidedAt" = now(),
"decisionNote" = ${note}, "updatedAt" = now()
WHERE id = ANY(${[...ids]}) AND status = 'OPEN'`;
}
/** OPEN to OBSOLETE for a colony that ended; the dedupe key stays. */
async obsoleteForColonyIn(
tx: Prisma.TransactionClient,
colonyId: string,
): Promise<number> {
assertTransactionClient(tx, 'SuggestionsService.obsoleteForColonyIn');
return tx.$executeRaw`UPDATE "Suggestion"
SET status = 'OBSOLETE', "decidedAt" = now(), "updatedAt" = now()
WHERE "colonyId" = ${colonyId} AND status = 'OPEN'`;
}
/**
* OPEN past its expiry day to EXPIRED, in one statement that also frees
* the dedupe key.
*/
async expireDue(today: string): Promise<number> {
return this.prisma.$executeRaw`UPDATE "Suggestion"
SET status = 'EXPIRED', "dedupeKey" = NULL, "decidedAt" = now(),
"updatedAt" = now()
WHERE status = 'OPEN' AND "expiresAt" < ${dayToDate(today)}`;
}
async accept(
id: string,
input: AcceptSuggestionInput,
actingUserId: string,
): Promise<AcceptSuggestionResult> {
return this.prisma.$transaction((tx) =>
this.acceptIn(tx, id, input, actingUserId),
);
}
/** All or nothing: one failing suggestion rolls back every task. */
async bulkAccept(
ids: readonly string[],
actingUserId: string,
): Promise<AcceptSuggestionResult[]> {
assertBulkIds(ids);
return this.prisma.$transaction(
async (tx) => {
await lockRowsForUpdate(tx, 'Suggestion', ids);
await this.assertDatedIn(tx, ids);
const results: AcceptSuggestionResult[] = [];
for (const id of ids) {
results.push(await this.acceptIn(tx, id, {}, actingUserId));
}
return results;
},
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
);
}
async dismiss(
id: string,
note: string | null,
actingUserId: string,
): Promise<Suggestion> {
this.assertNote(note);
return this.prisma.$transaction((tx) =>
this.dismissIn(tx, id, note, actingUserId),
);
}
/** All or nothing; resolves to how many suggestions were dismissed. */
async bulkDismiss(
ids: readonly string[],
actingUserId: string,
): Promise<number> {
assertBulkIds(ids);
return this.prisma.$transaction(
async (tx) => {
await lockRowsForUpdate(tx, 'Suggestion', ids);
for (const id of ids) {
await this.dismissIn(tx, id, null, actingUserId);
}
return ids.length;
},
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
);
}
/** DISMISSED back to OPEN, up to 30 days after the decision. */
async reopen(id: string, actingUserId: string): Promise<Suggestion> {
return this.prisma.$transaction(async (tx) => {
const before = await this.readLockedIn(tx, id);
if (before.status !== 'DISMISSED') {
throw notOpen(before.status);
}
await this.assertColonyAliveIn(tx, before);
const open = await this.openIntentsIn(tx, before);
if (open.has(before.intent)) {
const subject =
before.relatedType === 'COLONY' ? 'dieses Volk' : 'diesen Eintrag';
throw new ConflictException({
message: `Für ${subject} ist schon ein Vorschlag „${INTENT_TITLES[before.intent]}“ offen.`,
status: before.status,
});
}
const moved = await tx.$executeRaw`UPDATE "Suggestion"
SET status = 'OPEN', "decidedAt" = NULL, "decidedByUserId" = NULL,
"decisionNote" = NULL, "updatedAt" = now()
WHERE id = ${id} AND status = 'DISMISSED'
AND "decidedAt" > now() - interval '30 days'`;
if (moved === 0) {
throw new ConflictException({
message:
'Der Vorschlag kann nach 30 Tagen nicht mehr geöffnet werden.',
status: before.status,
});
}
return this.auditedUpdateIn(tx, before, actingUserId);
});
}
private async acceptIn(
tx: Prisma.TransactionClient,
id: string,
input: AcceptSuggestionInput,
actingUserId: string,
): Promise<AcceptSuggestionResult> {
const before = await this.readLockedIn(tx, id);
if (before.status !== 'OPEN') {
throw notOpen(before.status);
}
await this.assertColonyAliveIn(tx, before);
// Everything that can answer 400 comes before the first write.
await this.assertAssigneeIn(tx, input.assignedUserId);
const newTasks = await this.newTasksOf(tx, before, input);
const moved = await tx.$executeRaw`UPDATE "Suggestion"
SET status = 'ACCEPTED', "decidedAt" = now(),
"decidedByUserId" = ${actingUserId}, "updatedAt" = now()
WHERE id = ${id} AND status = 'OPEN'`;
if (moved === 0) {
throw notOpen(await this.currentStatusIn(tx, id));
}
const tasks: Task[] = [];
for (const newTask of newTasks) {
tasks.push(await this.tasks.createInTx(tx, newTask, actingUserId));
}
await this.auditedUpdateIn(tx, before, actingUserId);
return { suggestionId: id, status: 'ACCEPTED', tasks };
}
private async dismissIn(
tx: Prisma.TransactionClient,
id: string,
note: string | null,
actingUserId: string,
): Promise<Suggestion> {
const before = await this.readLockedIn(tx, id);
if (before.status !== 'OPEN') {
throw notOpen(before.status);
}
const moved = await tx.$executeRaw`UPDATE "Suggestion"
SET status = 'DISMISSED', "decidedAt" = now(),
"decidedByUserId" = ${actingUserId}, "decisionNote" = ${note},
"updatedAt" = now()
WHERE id = ${id} AND status = 'OPEN'`;
if (moved === 0) {
throw notOpen(await this.currentStatusIn(tx, id));
}
return this.auditedUpdateIn(tx, before, actingUserId);
}
/** Locks the suggestion and reads it afterwards: the copy to decide on. */
private async readLockedIn(
tx: Prisma.TransactionClient,
id: string,
): Promise<Suggestion> {
assertTransactionClient(tx, 'SuggestionsService.readLockedIn');
await lockRowForUpdate(tx, 'Suggestion', id);
const suggestion = await tx.suggestion.findUnique({ where: { id } });
if (!suggestion) {
throw new NotFoundException('Der Vorschlag wurde nicht gefunden.');
}
return suggestion;
}
private async currentStatusIn(
tx: Prisma.TransactionClient,
id: string,
): Promise<SuggestionStatus> {
const current = await tx.suggestion.findUnique({
where: { id },
select: { status: true },
});
if (!current) {
throw new NotFoundException('Der Vorschlag wurde nicht gefunden.');
}
return current.status;
}
/** Reads the decided suggestion and writes the audit entry for the change. */
private async auditedUpdateIn(
tx: Prisma.TransactionClient,
before: Suggestion,
actingUserId: string,
): Promise<Suggestion> {
const after = await tx.suggestion.findUniqueOrThrow({
where: { id: before.id },
});
await this.auditLog.record(
{
entityType: 'Suggestion',
entityId: before.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
},
tx,
);
return after;
}
/**
* The tasks accepting `suggestion` creates: one per plan step, else one.
* A plan step is due base day + offset (moved as a whole by a chosen
* date); a DO suggestion needs a day, a PLAN suggestion without one is
* due today.
*/
private async newTasksOf(
tx: Prisma.TransactionClient,
suggestion: Suggestion,
input: AcceptSuggestionInput,
): Promise<NewTaskInput[]> {
const today = berlinToday();
const evidence = suggestion.evidence as SuggestionEvidence;
const chosenDay = parseDay(input.dueDate);
const [rule, lookup, sourceInspectionId] = await Promise.all([
readRule(tx, suggestion.ruleKey),
buildEvidenceLookup(tx, [evidence]),
this.existingInspectionIdIn(tx, evidence),
]);
const text = renderSuggestion(
suggestion,
lookup,
today,
rule.actionTemplate,
);
const shared: SharedTaskFields = {
priority: suggestion.priority,
assignedUserId: input.assignedUserId ?? null,
relatedType: suggestion.relatedType,
relatedId: suggestion.relatedId,
sourceInspectionId,
sourceAutomationTrigger: suggestion.ruleKey,
sourceSuggestionId: suggestion.id,
};
const plan = planOf(suggestion.plan);
if (plan === null && suggestion.plan !== null) {
this.logger.warn(
`Suggestion ${suggestion.id} has a stored plan that cannot be used; creating one task`,
);
}
if (plan) {
const baseDate = planBaseDate(evidence, today);
return planSteps(plan, baseDate, chosenDay).map((step) => ({
...shared,
title: Object.hasOwn(PLAN_STEP_TITLES, step.step)
? PLAN_STEP_TITLES[step.step]
: text.title,
description:
[
...text.reasons,
...(step.windowUntil === null
? []
: [`Zeitfenster bis ${formatDayDe(step.windowUntil)}`]),
].join('\n') || null,
dueDate: dayToDate(step.day),
}));
}
const ownDay =
suggestion.dueDate === null ? null : utcDay(suggestion.dueDate);
const day =
chosenDay ?? ownDay ?? (suggestion.mode === 'PLAN' ? today : null);
if (day === null) {
throw new BadRequestException('Bitte ein Datum wählen.');
}
return [
{
...shared,
title: text.title,
description: text.reasons.join('\n') || null,
dueDate: dayToDate(day),
},
];
}
/** A colony that ended takes no new work: its suggestions are dead. */
private async assertColonyAliveIn(
tx: Prisma.TransactionClient,
suggestion: Suggestion,
): Promise<void> {
if (suggestion.colonyId === null) {
return;
}
const colony = await tx.colony.findUnique({
where: { id: suggestion.colonyId },
select: { status: true },
});
if (!colony || !isLivingColonyStatus(colony.status)) {
throw new ConflictException({
message: COLONY_ENDED_MESSAGE,
status: suggestion.status,
});
}
}
/**
* Bulk accept has no date to give: a DO suggestion without its own date
* would fail one by one, so all of them are named at once.
*/
private async assertDatedIn(
tx: Prisma.TransactionClient,
ids: readonly string[],
): Promise<void> {
const rows = await tx.suggestion.findMany({
where: { id: { in: [...ids] } },
select: { id: true, mode: true, dueDate: true, plan: true },
});
const undated = rows
.filter(
(row) =>
row.mode === 'DO' &&
row.dueDate === null &&
planOf(row.plan) === null,
)
.map((row) => row.id);
if (undated.length > 0) {
throw new BadRequestException({
message: 'Einige Vorschläge brauchen ein Datum.',
issues: [
{
field: 'ids',
message: 'Bitte diese Vorschläge einzeln mit Datum übernehmen.',
ids: undated,
},
],
});
}
}
/** The assignee must be an existing, active user (a task links it by FK). */
private async assertAssigneeIn(
tx: Prisma.TransactionClient,
assignedUserId: string | null | undefined,
): Promise<void> {
if (!assignedUserId) {
return;
}
const user = await tx.user.findUnique({
where: { id: assignedUserId },
select: { isActive: true },
});
if (!user?.isActive) {
throw new BadRequestException({
message: 'Die Einstellung ist ungültig.',
issues: [
{
field: 'assignedUserId',
message: 'Bitte einen aktiven Benutzer wählen.',
},
],
});
}
}
/** The evidence's inspection, if it still exists (a task links it by FK). */
private async existingInspectionIdIn(
tx: Prisma.TransactionClient,
evidence: SuggestionEvidence,
): Promise<string | null> {
const { inspectionId } = evidence;
if (typeof inspectionId !== 'string') {
return null;
}
// Shared lock: the inspection cannot be deleted before the task links it.
await lockRowsForShare(tx, 'Inspection', [inspectionId]);
const found = await tx.inspection.count({ where: { id: inspectionId } });
return found > 0 ? inspectionId : null;
}
/** No colony and no object id: only the rule tells proposals apart. */
private isObjectLess(
target: Target,
): target is Target & { ruleKey: AutomationTriggerType } {
return (
target.colonyId === null &&
target.relatedId === null &&
target.ruleKey !== undefined
);
}
private taskObjectsOf(target: Target): Prisma.TaskWhereInput[] {
const objects: Prisma.TaskWhereInput[] = [];
if (target.colonyId !== null) {
objects.push({ relatedType: 'COLONY', relatedId: target.colonyId });
}
const ownIsColony =
target.relatedType === 'COLONY' && target.relatedId === target.colonyId;
if (
(target.relatedId !== null || target.colonyId === null) &&
!ownIsColony
) {
objects.push({
relatedType: target.relatedType,
relatedId: target.relatedId,
...(this.isObjectLess(target)
? { sourceAutomationTrigger: target.ruleKey }
: {}),
});
}
return objects;
}
private rowOf(input: ProposeInput): Prisma.SuggestionCreateManyInput {
return {
ruleKey: input.ruleKey,
source: input.source,
mode: input.mode,
intent: input.intent,
priority: input.priority ?? 'NORMAL',
relatedType: input.relatedType,
relatedId: input.relatedId,
colonyId: input.colonyId,
reasonCode: input.reasonCode,
evidence: input.evidence,
requiredPermission: input.requiredPermission,
dueDate: input.dueDate ? dayToDate(input.dueDate) : null,
expiresAt: input.expiresAt ? dayToDate(input.expiresAt) : null,
plan: input.plan
? (input.plan as unknown as Prisma.InputJsonArray)
: undefined,
dedupeKey: suggestionDedupeKey(
input.ruleKey,
input.relatedId,
input.anchor,
),
sourceEventId: input.sourceEventId ?? null,
};
}
private assertNote(note: string | null): void {
if (note !== null && note.length > MAX_NOTE_LENGTH) {
throw new BadRequestException(
`Die Notiz darf höchstens ${MAX_NOTE_LENGTH} Zeichen lang sein.`,
);
}
}
}
@@ -0,0 +1,4 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the season autopilot suggestions, by reason code. */
export const AUTOPILOT_TEXTS: SuggestionTextRegister = {};
@@ -0,0 +1,12 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the chain suggestions, by reason code. */
export const CHAIN_TEXTS: SuggestionTextRegister = {};
/** 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',
};
@@ -0,0 +1,4 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the clean-up suggestions, by reason code. */
export const CLEANUP_TEXTS: SuggestionTextRegister = {};
@@ -0,0 +1,4 @@
import type { SuggestionTextRegister } from '../suggestion-texts';
/** Texts of the rule suggestions, by reason code (filled by the rule tasks). */
export const RULE_TEXTS: SuggestionTextRegister = {};
@@ -3,7 +3,11 @@ import { JobRun, Prisma } from '../../generated/prisma';
import { JOB_NAMES } from '../jobs/job-names';
import { NotificationsService } from '../notifications/notifications.service';
import { PrismaService } from '../prisma/prisma.service';
import { JOB_ABORTED_ERROR, JobRunService } from './job-run.service';
import {
JOB_ABORTED_ERROR,
JobPartialFailure,
JobRunService,
} from './job-run.service';
type RawCall = [readonly string[], ...unknown[]];
@@ -125,6 +129,26 @@ describe('JobRunService', () => {
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('Mustermann');
});
it('stores the counters of a partial failure with the FAILED run', async () => {
const run = await service.run('daily-evaluation', () =>
Promise.reject(
new JobPartialFailure({ WithdrawalEnded: 2, TaskDue: 1 }, [
'time-events',
]),
),
);
expect(run.status).toBe('FAILED');
const [finish] = prisma.$queryRaw.mock.calls;
expect(finish.slice(1)).toEqual([
'FAILED',
'{"WithdrawalEnded":2,"TaskDue":1}',
'Daily evaluation failed for: time-events',
'run-1',
]);
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
});
// finishedAt of every run - finished, failed or aborted - comes from
// the database clock, like the comparisons of cleanup.
it.each([
+20 -2
View File
@@ -20,9 +20,26 @@ const JOB_RUN_RELATED_TYPE = 'JobRun';
const NEWEST_FIRST = [{ startedAt: 'desc' as const }, { id: 'desc' as const }];
/**
* A run of several steps in which some failed: `result` holds the counters
* of all steps, the finished ones included. JobRunService stores it with the
* FAILED run, so the System page shows what was done.
*/
export class JobPartialFailure extends Error {
constructor(
readonly result: JobResult,
readonly failedSteps: readonly string[],
/** What the stored error names, e.g. "time-events: QueenAged". */
details: readonly string[] = failedSteps,
) {
super(`Daily evaluation failed for: ${details.join(', ')}`);
this.name = 'JobPartialFailure';
}
}
type RunOutcome =
| { status: 'SUCCEEDED'; result: JobResult }
| { status: 'FAILED'; error: string };
| { status: 'FAILED'; error: string; result?: JobResult };
/**
* Bookkeeping of the CronJob runs: every run is one JobRun row (RUNNING,
@@ -116,6 +133,7 @@ export class JobRunService {
const failed = await this.finish(runId, {
status: 'FAILED',
error: message,
...(error instanceof JobPartialFailure ? { result: error.result } : {}),
});
await this.notifyFailure(job, runId);
return failed;
@@ -127,7 +145,7 @@ export class JobRunService {
*/
private async finish(runId: string, outcome: RunOutcome): Promise<JobRun> {
const result =
outcome.status === 'SUCCEEDED' ? JSON.stringify(outcome.result) : null;
outcome.result === undefined ? null : JSON.stringify(outcome.result);
const error = outcome.status === 'FAILED' ? outcome.error : null;
const [finished] = await this.prisma.$queryRaw<JobRun[]>`
UPDATE "JobRun"
@@ -34,6 +34,7 @@ export interface NewTaskInput {
sourceInspectionId?: string | null;
sourceTemplateId?: string | null;
sourceAutomationTrigger?: AutomationTriggerType | null;
sourceSuggestionId?: string | null;
}
export interface TaskFilter {
@@ -1,11 +1,13 @@
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 { AutomationModule } from '../automation/automation.module';
import { CalendarSyncModule } from '../calendar-sync/calendar-sync.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PrismaModule } from '../prisma/prisma.module';
import { SuggestionsModule } from '../suggestions/suggestions.module';
import { TasksModule } from '../tasks/tasks.module';
import { WebhooksModule } from '../webhooks/webhooks.module';
import { WorkerHealthServer } from './worker-health-server.service';
@@ -23,12 +25,14 @@ import { WorkerRuntimeModule } from './worker-runtime.module';
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuditModule,
JobPermissionsModule,
NotificationsModule,
DomainEventsModule,
WebhooksModule,
CalendarSyncModule,
TasksModule,
AutomationModule,
SuggestionsModule,
WorkerRuntimeModule,
],
providers: [WorkerHealthServer],
+91 -2
View File
@@ -7,6 +7,7 @@ import { AutomationTriggerType, Prisma } from '../generated/prisma';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
import { drainDomainEvents } from './utils/domain-events';
import { DEFAULT_RULES } from '../src/automation/rule-config';
interface AutomationRuleBody {
id: string;
@@ -165,6 +166,94 @@ describe('Automation rules (e2e)', () => {
)!.id;
});
const NEW_TRIGGERS = [
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
] as const;
it('lists the nine new rules with their defaults', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/automation-rules')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const rules = response.body as AutomationRuleBody[];
for (const trigger of NEW_TRIGGERS) {
const rule = rules.find((entry) => entry.triggerType === trigger);
expect(rule).toBeDefined();
expect(rule!.active).toBe(DEFAULT_RULES[trigger].active ?? true);
expect(rule!.config).toEqual(DEFAULT_RULES[trigger].config);
expect(rule!.actionTemplate).toBe('');
}
// Existing rules keep their stored config; a fresh database has the new defaults.
const varroa = rules.find(
(rule) => rule.triggerType === 'VARROA_THRESHOLD',
);
expect(varroa?.config).toBeTruthy();
});
it('PATCH a rule with an invalid known value answers 400 with the field', async () => {
const rules = (
await request(app.getHttpServer())
.get('/api/v1/automation-rules')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200)
).body as AutomationRuleBody[];
const autopilot = rules.find(
(rule) => rule.triggerType === 'SEASON_AUTOPILOT',
)!;
const res = await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${autopilot.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ config: { queenMaxAgeYears: 0 } })
.expect(400);
const body = res.body as {
message: string;
issues: { field: string; message: string }[];
};
expect(body.message).toBe('Die Einstellung ist ungültig.');
expect(body.issues).toEqual([
expect.objectContaining({ field: 'queenMaxAgeYears' }),
]);
// Nothing was written.
const stored = await prisma.automationRule.findUniqueOrThrow({
where: { id: autopilot.id },
});
expect(stored.config).toEqual(autopilot.config);
});
it('PATCH keeps unknown keys and saves a valid config', async () => {
const rule = await prisma.automationRule.findUniqueOrThrow({
where: { triggerType: 'SEASON_AUTOPILOT' },
});
try {
await request(app.getHttpServer())
.patch(`/api/v1/automation-rules/${rule.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ config: { queenMaxAgeYears: 3, futureKey: 'x' } })
.expect(200);
const stored = await prisma.automationRule.findUniqueOrThrow({
where: { id: rule.id },
});
expect(stored.config).toEqual({ queenMaxAgeYears: 3, futureKey: 'x' });
} finally {
await prisma.automationRule.update({
where: { id: rule.id },
data: { config: rule.config as Prisma.InputJsonValue },
});
}
});
it('creates a task and notification when queen not seen and no eggs present', async () => {
await record('inspections', { queenSeen: false, eggsPresent: false });
@@ -190,7 +279,7 @@ describe('Automation rules (e2e)', () => {
});
it('triggers a feeding task when feed status is kritisch', async () => {
await record('inspections', { feedStatus: 'kritisch' });
await record('inspections', { feedStatus: 'CRITICAL' });
const tasks = await prisma.task.findMany({
where: { relatedId: colonyId },
@@ -212,7 +301,7 @@ describe('Automation rules (e2e)', () => {
await request(app.getHttpServer())
.patch(`/api/v1/inspections/${id}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'kritisch' })
.send({ queenSeen: false, eggsPresent: false, feedStatus: 'CRITICAL' })
.expect(200);
await drainDomainEvents(app, [id]);
@@ -1,13 +1,11 @@
import { INestApplicationContext } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import { DailyEvaluationJob } from '../src/jobs/daily-evaluation.job';
import { JobsModule } from '../src/jobs/jobs.module';
import { TimeEventsStep } from '../src/jobs/steps/time-events.step';
import { PrismaService } from '../src/prisma/prisma.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
/** When the CronJob runs on `day` (04:30 Europe/Berlin). */
const at = (day: string): Date => new Date(`${day}T02:30:00.000Z`);
const NOTHING_NEW = {
WithdrawalEnded: 0,
@@ -21,14 +19,14 @@ const NOTHING_NEW = {
* The job evaluates the whole database. The e2e files share it and run in
* parallel, so the evaluation runs on days in 2001: no other file has
* records there, and the events of this file stay its own. Called through
* evaluate(day) instead of runCli, which would also write JobRun rows that
* test/jobs.e2e-spec.ts counts.
* TimeEventsStep.run(day) instead of runCli, which would also write JobRun
* rows that test/jobs.e2e-spec.ts counts.
*/
describe('daily-evaluation (e2e)', () => {
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let app: INestApplicationContext;
let prisma: PrismaService;
let job: DailyEvaluationJob;
let step: TimeEventsStep;
const ids: Record<string, string> = {};
const eventsOf = (aggregateId: string) =>
@@ -50,7 +48,7 @@ describe('daily-evaluation (e2e)', () => {
}).compile();
app = await moduleRef.init();
prisma = app.get(PrismaService);
job = app.get(DailyEvaluationJob);
step = app.get(TimeEventsStep);
const user = await prisma.user.create({
data: {
@@ -99,7 +97,8 @@ describe('daily-evaluation (e2e)', () => {
},
});
ids.dead = dead.id;
// Inspected 2001-04-28: the next inspection is overdue on 2001-05-19.
// Inspected 2001-04-28, in the swarm season (every 7 days): the next
// inspection is due 2001-05-05 and overdue on 2001-05-06.
for (const colonyId of [living.id, dead.id]) {
await prisma.inspection.create({
data: { colonyId, userId: user.id, date: d('2001-04-28') },
@@ -180,10 +179,10 @@ describe('daily-evaluation (e2e)', () => {
});
it('running the job twice yields exactly one WithdrawalEnded per treatment and day', async () => {
const first = await job.evaluate(at('2001-05-20'));
const second = await job.evaluate(at('2001-05-20'));
const first = await step.run('2001-05-20');
const second = await step.run('2001-05-20');
// The next day the occurrence is still in the window - and still known.
const nextDay = await job.evaluate(at('2001-05-21'));
const nextDay = await step.run('2001-05-21');
expect(first.WithdrawalEnded).toBeGreaterThanOrEqual(1);
expect(second).toEqual(NOTHING_NEW);
@@ -201,8 +200,8 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids['treatment:no-withdrawal'])).toEqual([]);
});
it('records due tasks and overdue checks only for open tasks and living colonies', async () => {
await job.evaluate(at('2001-05-20'));
it('records due tasks and varroa checks only for open tasks and living colonies', async () => {
await step.run('2001-05-20');
expect(await eventsOf(ids['task:open'])).toEqual([
{
@@ -215,17 +214,15 @@ describe('daily-evaluation (e2e)', () => {
]);
expect(await eventsOf(ids['task:done'])).toEqual([]);
expect(await eventsOf(ids.living)).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: { dueOn: '2001-05-19', lastInspectedOn: '2001-04-28' },
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-19`,
actorUserId: null,
},
{
type: 'VarroaMeasurementDue',
aggregateType: 'Colony',
payload: { dueOn: '2001-05-18', lastMeasuredOn: null },
payload: {
dueOn: '2001-05-18',
lastMeasuredOn: null,
kind: 'INTERVAL',
anchor: 'none',
},
dedupeKey: `VarroaMeasurementDue:${ids.living}:2001-05-18`,
actorUserId: null,
},
@@ -233,11 +230,70 @@ describe('daily-evaluation (e2e)', () => {
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('reports active queens from their second year in the first days of January', async () => {
await job.evaluate(at('2001-01-05'));
expect(await job.evaluate(at('2001-01-08'))).toEqual(NOTHING_NEW);
it('InspectionOverdue follows the segment interval', async () => {
await step.run('2001-05-12');
expect(await eventsOf(ids['queen:aged'])).toEqual([
// SCHWARMZEIT since 2001-04-15: 2001-04-28 + 7 = 2001-05-05, not + 21.
expect(
(await eventsOf(ids.living)).filter(
(event) => event.type === 'InspectionOverdue',
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: {
dueOn: '2001-05-05',
lastInspectedOn: '2001-04-28',
firstOfYear: false,
},
dedupeKey: `InspectionOverdue:${ids.living}:2001-05-06`,
actorUserId: null,
},
]);
expect(await eventsOf(ids.dead)).toEqual([]);
});
it('records no inspection in WINTERRUHE and the first one the day after FRUEHJAHR begins', async () => {
await step.run('2002-03-10');
expect(
(await eventsOf(ids.living)).filter((event) =>
event.dedupeKey?.endsWith(':2002-03-21'),
),
).toEqual([]);
// FRUEHJAHR begins 2002-03-20; the colony was last inspected in 2001.
await step.run('2002-03-21');
expect(
(await eventsOf(ids.living)).filter((event) =>
event.dedupeKey?.endsWith(':2002-03-21'),
),
).toEqual([
{
type: 'InspectionOverdue',
aggregateType: 'Colony',
payload: {
dueOn: '2002-03-20',
lastInspectedOn: '2001-04-28',
firstOfYear: true,
},
dedupeKey: `InspectionOverdue:${ids.living}:2002-03-21`,
actorUserId: null,
},
]);
});
it('reports the current queen of a living colony once she is aged, on any job day of the year', async () => {
await step.run('2001-09-09');
await step.run('2001-09-10');
// One per queen and year: the 2002 run of the test above added its own.
const events = await eventsOf(ids['queen:aged']);
expect(events.map((event) => event.dedupeKey)).toEqual([
`QueenAged:${ids['queen:aged']}:2001-01-01`,
`QueenAged:${ids['queen:aged']}:2002-01-01`,
]);
expect(events.slice(0, 1)).toEqual([
{
type: 'QueenAged',
aggregateType: 'Queen',
@@ -11,6 +11,7 @@ interface InspectionBody {
id: string;
colonyId: string;
broodFrames?: number | null;
feedStatus?: string | null;
}
interface TaskBody {
@@ -268,4 +269,37 @@ describe('Inspections (e2e)', () => {
.send({ broodFrames: 'x' })
.expect(400);
});
it('maps legacy feed words to the enum, accepts enum names and rejects other text', async () => {
const post = (feedStatus: unknown) =>
request(app.getHttpServer())
.post('/api/v1/inspections')
.set('Authorization', `Bearer ${staffToken}`)
.send({ colonyId, date: new Date().toISOString(), feedStatus });
await post('halb voll').expect(400);
await post(5).expect(400);
const blank = await post(' ').expect(201);
expect((blank.body as InspectionBody).feedStatus).toBeNull();
const legacy = await post(' Niedrig ').expect(201);
expect((legacy.body as InspectionBody).feedStatus).toBe('WEAK');
const created = await post('WEAK').expect(201);
const body = created.body as InspectionBody;
expect(body.feedStatus).toBe('WEAK');
const patch = (feedStatus: unknown) =>
request(app.getHttpServer())
.patch(`/api/v1/inspections/${body.id}`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ feedStatus });
await patch('irgendwas').expect(400);
const mapped = await patch('Kritisch').expect(200);
expect((mapped.body as InspectionBody).feedStatus).toBe('CRITICAL');
const blankPatch = await patch(' ').expect(200);
expect((blankPatch.body as InspectionBody).feedStatus).toBeNull();
await patch('WEAK').expect(200);
const cleared = await patch(null).expect(200);
expect((cleared.body as InspectionBody).feedStatus).toBeNull();
});
});
@@ -0,0 +1,415 @@
import { INestApplication } from '@nestjs/common';
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 { createTestApp } from './utils/create-test-app';
/**
* The suggestion core against a real PostgreSQL: the dedupe key, the SQL
* status changes that free it, the plan tasks and the 30-day reopen window.
*/
describe('Suggestions (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
let service: SuggestionsService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let apiaryId: string;
let colonyId: string;
let userId: string;
const proposal = (anchor: string): ProposeInput => ({
ruleKey: 'SPLIT_SCHEDULE',
source: 'CHAIN',
mode: 'PLAN',
intent: 'SPLIT_PLAN',
relatedType: 'COLONY',
relatedId: colonyId,
colonyId,
reasonCode: `SUGGESTIONS_E2E_${suffix}`,
evidence: { colonyId, baseDate: '2026-05-01' },
requiredPermission: 'colonies:read',
plan: [
{ step: 'RAISE_CELLS_CHECK', offsetDays: 9 },
{ step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 },
],
anchor,
});
const propose = (input: ProposeInput) =>
prisma.$transaction((tx) => service.proposeIn(tx, input));
const statusOf = async (id: string) =>
(await prisma.suggestion.findUniqueOrThrow({ where: { id } })).status;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
service = app.get(SuggestionsService);
const apiary = await prisma.apiary.create({
data: { name: `Suggestions e2e ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `SUG-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
const user = await prisma.user.create({
data: {
email: `suggestions-e2e-${suffix}@example.com`,
passwordHash: 'x',
name: 'Suggestions e2e',
role: { connect: { key: 'STAFF' } },
},
});
userId = user.id;
});
afterAll(async () => {
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.suggestion.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 app.close();
});
afterEach(async () => {
await prisma.task.deleteMany({
where: { sourceSuggestion: { colonyId } },
});
await prisma.suggestion.deleteMany({ where: { colonyId } });
});
it('proposes once per anchor and writes an audit entry', async () => {
const created = await propose(proposal('a1'));
expect(created).not.toBeNull();
expect(created?.dedupeKey).toBe(`SPLIT_SCHEDULE:${colonyId}:a1`);
expect(
await prisma.auditLog.count({
where: { entityType: 'Suggestion', entityId: created!.id },
}),
).toBe(1);
// Same anchor: the key exists. A second intent check would also stop it.
expect(await propose(proposal('a1'))).toBeNull();
});
it('allows one open suggestion per intent and colony', async () => {
expect(await propose(proposal('a1'))).not.toBeNull();
expect(await propose(proposal('a2'))).toBeNull();
expect(
await propose({
...proposal('a3'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
plan: null,
}),
).not.toBeNull();
});
it('is stopped by an open task of the same intent', async () => {
const first = (await propose(proposal('a1')))!;
await service.accept(first.id, { dueDate: '2026-05-12' }, userId);
// The accepted plan left open tasks of this intent.
expect(await propose(proposal('a2'))).toBeNull();
});
it('expireDue frees the key, dismiss keeps it', async () => {
const dismissed = (await propose(proposal('d1')))!;
await service.dismiss(dismissed.id, 'nein', userId);
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
expect(await propose(proposal('d1'))).toBeNull();
const expiring = (await propose({
...proposal('e1'),
expiresAt: '2026-05-01',
}))!;
expect(await service.expireDue('2026-05-02')).toBeGreaterThanOrEqual(1);
const expired = await prisma.suggestion.findUniqueOrThrow({
where: { id: expiring.id },
});
expect(expired.status).toBe('EXPIRED');
expect(expired.dedupeKey).toBeNull();
expect(await propose(proposal('e1'))).not.toBeNull();
});
it('resolveIn frees the key in the same statement', async () => {
const created = (await propose(proposal('r1')))!;
expect(
await prisma.$transaction((tx) =>
service.resolveIn(tx, [created.id], 'Bedingung entfällt'),
),
).toBe(1);
const resolved = await prisma.suggestion.findUniqueOrThrow({
where: { id: created.id },
});
expect(resolved).toMatchObject({
status: 'RESOLVED',
dedupeKey: null,
decisionNote: 'Bedingung entfällt',
});
expect(await propose(proposal('r1'))).not.toBeNull();
});
it('accept creates the plan tasks shifted to the chosen day', async () => {
const created = (await propose(proposal('p1')))!;
const result = await service.accept(
created.id,
{ dueDate: '2026-05-12' },
userId,
);
expect(
result.tasks.map((t) => t.dueDate?.toISOString().slice(0, 10)),
).toEqual(['2026-05-12', '2026-05-24']);
expect(result.tasks[1].description).toContain('bis 27.05.');
expect(result.tasks.every((t) => t.sourceSuggestionId === created.id)).toBe(
true,
);
expect(await statusOf(created.id)).toBe('ACCEPTED');
await expect(service.accept(created.id, {}, userId)).rejects.toMatchObject({
response: { status: 'ACCEPTED' },
});
});
it('bulkAccept rolls everything back when one suggestion is not open', async () => {
const open = (await propose(proposal('b1')))!;
const other = (await propose({
...proposal('b2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
plan: null,
dueDate: '2026-05-20',
}))!;
await service.dismiss(other.id, null, userId);
await expect(
service.bulkAccept([open.id, other.id], userId),
).rejects.toMatchObject({ status: 409 });
expect(await statusOf(open.id)).toBe('OPEN');
expect(
await prisma.task.count({ where: { sourceSuggestionId: open.id } }),
).toBe(0);
});
it('reopens within 30 days and answers 409 after that', async () => {
const created = (await propose(proposal('o1')))!;
await service.dismiss(created.id, null, userId);
const reopened = await service.reopen(created.id, userId);
expect(reopened).toMatchObject({ status: 'OPEN', decidedAt: null });
await service.dismiss(created.id, null, userId);
await prisma.$executeRaw`UPDATE "Suggestion" SET "decidedAt" = now() - interval '31 days' WHERE id = ${created.id}`;
await expect(service.reopen(created.id, userId)).rejects.toMatchObject({
status: 409,
});
expect(await statusOf(created.id)).toBe('DISMISSED');
});
it('refuses to reopen while the same intent is open again', async () => {
const dismissed = (await propose(proposal('q1')))!;
await service.dismiss(dismissed.id, null, userId);
expect(await propose(proposal('q2'))).not.toBeNull();
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
status: 409,
});
expect(await statusOf(dismissed.id)).toBe('DISMISSED');
});
it('answers 400 for an unknown or inactive assignee, never 500', async () => {
const created = (await propose(proposal('u1')))!;
const inactive = await prisma.user.create({
data: {
email: `suggestions-e2e-inactive-${suffix}@example.com`,
passwordHash: 'x',
name: 'Inactive',
isActive: false,
role: { connect: { key: 'STAFF' } },
},
});
try {
for (const assignedUserId of ['no-such-user', inactive.id]) {
await expect(
service.accept(created.id, { assignedUserId }, userId),
).rejects.toMatchObject({ status: 400 });
}
expect(await statusOf(created.id)).toBe('OPEN');
const ok = await service.accept(
created.id,
{ assignedUserId: userId },
userId,
);
expect(ok.tasks.every((t) => t.assignedUserId === userId)).toBe(true);
} finally {
await prisma.task.deleteMany({
where: { sourceSuggestionId: created.id },
});
await prisma.user.delete({ where: { id: inactive.id } });
}
});
describe('concurrency', () => {
it('accept against dismiss on one suggestion: one wins, the other gets 409 with the status', async () => {
const created = (await propose(proposal('c1')))!;
const settled = await Promise.allSettled([
service.accept(created.id, { dueDate: '2026-05-12' }, userId),
service.dismiss(created.id, null, userId),
]);
expect(settled.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
const [failure] = settled.filter(
(r): r is PromiseRejectedResult => r.status === 'rejected',
);
expect(failure.reason).toMatchObject({ status: 409 });
const winner = await statusOf(created.id);
expect(['ACCEPTED', 'DISMISSED']).toContain(winner);
expect(
(failure.reason as { response: { status: string } }).response.status,
).toBe(winner);
});
it('two proposals for one colony and intent from different rules leave one row', async () => {
const results = await Promise.all([
propose({ ...proposal('k1'), ruleKey: 'SPLIT_SCHEDULE' }),
propose({
...proposal('k2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
source: 'RULE',
}),
propose({
...proposal('k3'),
ruleKey: 'INSPECTION_INTERVAL',
source: 'AUTOPILOT',
}),
]);
expect(results.filter((r) => r !== null)).toHaveLength(1);
expect(
await prisma.suggestion.count({
where: { colonyId, intent: 'SPLIT_PLAN', status: 'OPEN' },
}),
).toBe(1);
});
it('overlapping bulkAccept and accept do not deadlock and stay consistent', async () => {
const ids: string[] = [];
for (const [index, intent] of (
['SPLIT_PLAN', 'QUEEN_ACCEPTANCE', 'FEED'] as const
).entries()) {
const created = await propose({
...proposal(`ov${index}`),
ruleKey:
intent === 'SPLIT_PLAN'
? 'SPLIT_SCHEDULE'
: 'QUEEN_ACCEPTANCE_CHECK',
intent,
plan: null,
dueDate: '2026-05-20',
});
ids.push(created!.id);
}
const settled = await Promise.allSettled([
service.bulkAccept([ids[0], ids[1], ids[2]], userId),
service.accept(ids[1], {}, userId),
service.bulkAccept([ids[2], ids[1]], userId),
]);
for (const result of settled) {
if (result.status === 'rejected') {
expect(result.reason).toMatchObject({ status: 409 });
}
}
expect(settled.some((r) => r.status === 'fulfilled')).toBe(true);
for (const id of ids) {
// Accepted exactly once, or not at all: never a task without it.
const accepted = (await statusOf(id)) === 'ACCEPTED';
expect(
await prisma.task.count({ where: { sourceSuggestionId: id } }),
).toBe(accepted ? 1 : 0);
}
});
});
it('answers 409 for accept and reopen once the colony has ended', async () => {
const open = (await propose(proposal('end1')))!;
const dismissed = (await propose({
...proposal('end2'),
ruleKey: 'QUEEN_ACCEPTANCE_CHECK',
intent: 'QUEEN_ACCEPTANCE',
plan: null,
dueDate: '2026-05-20',
}))!;
await service.dismiss(dismissed.id, null, userId);
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'SOLD' },
});
try {
await expect(service.accept(open.id, {}, userId)).rejects.toMatchObject({
status: 409,
});
await expect(service.reopen(dismissed.id, userId)).rejects.toMatchObject({
status: 409,
});
} finally {
await prisma.colony.update({
where: { id: colonyId },
data: { status: 'ACTIVE' },
});
}
});
it('proposes nothing for a colony that does not exist', async () => {
expect(
await propose({ ...proposal('nc1'), colonyId: 'no-such-colony' }),
).toBeNull();
});
it('bulkAccept answers 400 naming the DO suggestions without a date', async () => {
const undated = (await propose({
...proposal('bd1'),
mode: 'DO',
plan: null,
dueDate: null,
}))!;
await expect(
service.bulkAccept([undated.id], userId),
).rejects.toMatchObject({
status: 400,
response: { issues: [{ field: 'ids', ids: [undated.id] }] },
});
expect(await statusOf(undated.id)).toBe('OPEN');
});
it('obsoleteForColonyIn closes open suggestions and keeps the key', async () => {
const created = (await propose(proposal('x1')))!;
expect(
await prisma.$transaction((tx) =>
service.obsoleteForColonyIn(tx, colonyId),
),
).toBe(1);
const obsolete = await prisma.suggestion.findUniqueOrThrow({
where: { id: created.id },
});
expect(obsolete.status).toBe('OBSOLETE');
expect(obsolete.dedupeKey).not.toBeNull();
});
});
@@ -0,0 +1,218 @@
import {
ScratchDatabase,
createScratchDatabase,
} from './utils/migration-harness';
const FEED_VOCABULARY = '20261014100200_t4_feed_vocabulary';
const SPLIT_QUEEN_SOURCE = '20261014100300_t4_split_queen_source';
const VARROA_SAMPLE_SIZE = '20261014100400_t4_varroa_sample_size';
const TIMEOUT_MS = 120_000;
interface InspectionSeed {
id: string;
createdAt: string;
feedStatus: string | null;
notes: string | null;
}
/** Minimal FK targets, written as raw SQL (Prisma ids and updatedAt have no DB default). */
async function seedColony(db: ScratchDatabase): Promise<void> {
await db.query(
`INSERT INTO "Role" ("id", "key", "name", "updatedAt")
VALUES ('role-1', 'MIG_TEST', 'Migration test', now())`,
);
await db.query(
`INSERT INTO "User" ("id", "email", "passwordHash", "name", "roleId", "updatedAt")
VALUES ('user-1', '[email protected]', 'x', 'Mig', 'role-1', now())`,
);
await db.query(
`INSERT INTO "Apiary" ("id", "name", "updatedAt")
VALUES ('apiary-1', 'Stand', now())`,
);
await db.query(
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
VALUES ('colony-1', 'V-1', 'apiary-1', 'PRODUCTION_COLONY', now())`,
);
}
async function seedInspections(
db: ScratchDatabase,
seeds: InspectionSeed[],
): Promise<void> {
for (const seed of seeds) {
await db.query(
`INSERT INTO "Inspection" ("id", "colonyId", "userId", "date", "feedStatus", "notes", "createdAt")
VALUES ($1, 'colony-1', 'user-1', '2026-05-01', $2, $3, $4)`,
[seed.id, seed.feedStatus, seed.notes, seed.createdAt],
);
}
}
describe('T4 schema II migrations (e2e)', () => {
let db: ScratchDatabase | null = null;
afterEach(async () => {
if (db) {
await db.drop();
db = null;
}
});
it(
'free-text feed moves into notes and known words map to the enum',
async () => {
db = await createScratchDatabase(FEED_VOCABULARY);
await seedColony(db);
await seedInspections(db, [
{
id: 'i-1',
createdAt: '2026-05-01T10:00:00Z',
feedStatus: 'niedrig',
notes: null,
},
{
id: 'i-2',
createdAt: '2026-05-01T11:00:00Z',
feedStatus: 'halb voll',
notes: null,
},
{
id: 'i-3',
createdAt: '2026-05-01T12:00:00Z',
feedStatus: 'halb voll',
notes: 'x',
},
]);
await db.migrateThrough(FEED_VOCABULARY);
expect(
await db.query(
'SELECT "feedStatus", notes FROM "Inspection" ORDER BY "createdAt"',
),
).toEqual([
{ feedStatus: 'WEAK', notes: null },
{ feedStatus: null, notes: 'Futter (alt): halb voll' },
{ feedStatus: null, notes: 'x\nFutter (alt): halb voll' },
]);
},
TIMEOUT_MS,
);
it(
'leaves no inspection whose old feed text was lost',
async () => {
db = await createScratchDatabase(FEED_VOCABULARY);
await seedColony(db);
const rawSeeds: [string | null, string | null][] = [
[' Ausreichend ', null],
['KRITISCH', 'Hinweis'],
['Niedrig', ''],
['sehr viel', 'a'],
[' Wabe voll ', null],
['', 'leer'],
[' ', null],
[null, 'ohne'],
['\tniedrig', null],
['\r\n Halb\tvoll\n', null],
];
const seeds: InspectionSeed[] = rawSeeds.map(
([feedStatus, notes], index) => ({
id: `i-${index}`,
createdAt: `2026-05-01T0${index}:00:00Z`,
feedStatus,
notes,
}),
);
await seedInspections(db, seeds);
await db.migrateThrough(FEED_VOCABULARY);
const rows = await db.query<{
id: string;
feedStatus: string | null;
notes: string | null;
}>('SELECT id, "feedStatus", notes FROM "Inspection" ORDER BY id');
const byId = new Map(rows.map((row) => [row.id, row]));
// Spec 4.12: every old text ends up as an enum value or in the notes.
const known = ['ausreichend', 'niedrig', 'kritisch'];
for (const seed of seeds) {
const text = seed.feedStatus?.trim() ?? '';
if (text === '') {
continue;
}
const row = byId.get(seed.id)!;
if (known.includes(text.toLowerCase())) {
expect(row.feedStatus).not.toBeNull();
} else {
expect(row.feedStatus).toBeNull();
expect(row.notes).toContain(`Futter (alt): ${text}`);
}
}
expect(
rows.filter(
(row) => row.feedStatus === null && (row.notes ?? '') === '',
),
).toEqual([
// Only the blank text with no notes remains empty - nothing was lost.
{ id: 'i-6', feedStatus: null, notes: null },
]);
expect(byId.get('i-1')?.notes).toBe('Hinweis');
expect(byId.get('i-3')?.notes).toBe('a\nFutter (alt): sehr viel');
expect(byId.get('i-5')?.notes).toBe('leer');
expect(byId.get('i-7')?.notes).toBe('ohne');
expect(byId.get('i-8')).toMatchObject({
feedStatus: 'WEAK',
notes: null,
});
expect(byId.get('i-9')).toMatchObject({
feedStatus: null,
notes: 'Futter (alt): Halb\tvoll',
});
},
TIMEOUT_MS,
);
it(
'adds the optional split queen source and varroa sample size without touching old rows',
async () => {
db = await createScratchDatabase(SPLIT_QUEEN_SOURCE);
await seedColony(db);
await db.query(
`INSERT INTO "Colony" ("id", "code", "apiaryId", "colonyType", "updatedAt")
VALUES ('colony-2', 'V-2', 'apiary-1', 'NUCLEUS_COLONY', now())`,
);
await db.query(
`INSERT INTO "ColonyLineage" ("id", "type", "fromColonyId", "toColonyId", "date")
VALUES ('l-1', 'SPLIT', 'colony-1', 'colony-2', '2026-05-01')`,
);
await db.query(
`INSERT INTO "VarroaMeasurement" ("id", "colonyId", "userId", "date", "method", "miteCount")
VALUES ('v-1', 'colony-1', 'user-1', '2026-05-02', 'WASHING_METHOD', 4)`,
);
await db.migrateThrough(SPLIT_QUEEN_SOURCE);
await db.migrateThrough(VARROA_SAMPLE_SIZE);
expect(
await db.query('SELECT "queenSource" FROM "ColonyLineage"'),
).toEqual([{ queenSource: null }]);
expect(
await db.query(
'SELECT "sampleBees", "miteCount" FROM "VarroaMeasurement"',
),
).toEqual([{ sampleBees: null, miteCount: 4 }]);
await db.query(
`UPDATE "ColonyLineage" SET "queenSource" = 'RAISE_OWN' WHERE id = 'l-1'`,
);
await expect(
db.query(
`UPDATE "ColonyLineage" SET "queenSource" = 'BOUGHT' WHERE id = 'l-1'`,
),
).rejects.toThrow();
},
TIMEOUT_MS,
);
});
+217
View File
@@ -0,0 +1,217 @@
import { Prisma } from '../generated/prisma';
import { PrismaService } from '../src/prisma/prisma.service';
import {
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
} from '../src/prisma/row-lock';
import {
DEFAULT_RULES,
lockRuleIn,
readRule,
} from '../src/automation/rule-config';
/**
* Schema-level guarantees of the T4 suggestion migrations that the Prisma
* schema alone cannot express (nullable unique dedupe key, enum values the
* database really accepts) and the row locks against a real PostgreSQL.
*/
describe('T4 schema I (e2e)', () => {
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const createdSuggestionIds: string[] = [];
const createdTaskIds: string[] = [];
const suggestionData = (
overrides: Partial<Prisma.SuggestionCreateManyInput> = {},
): Prisma.SuggestionCreateManyInput => ({
ruleKey: 'INSPECTION_INTERVAL',
source: 'RULE',
mode: 'DO',
intent: 'INSPECT',
relatedType: 'COLONY',
reasonCode: 'T4_SCHEMA_TEST',
evidence: { colonies: 1 },
...overrides,
});
beforeAll(() => {
prisma = new PrismaService();
});
afterAll(async () => {
await prisma.task.deleteMany({ where: { id: { in: createdTaskIds } } });
await prisma.suggestion.deleteMany({
where: {
OR: [
{ id: { in: createdSuggestionIds } },
{ reasonCode: `T4_SCHEMA_TEST_${suffix}` },
],
},
});
await prisma.$disconnect();
});
it('keeps at most one suggestion per dedupe key', async () => {
const data = suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
dedupeKey: `INSPECTION_INTERVAL:none:t-${suffix}`,
});
const first = await prisma.suggestion.createMany({
data: [data],
skipDuplicates: true,
});
const second = await prisma.suggestion.createMany({
data: [data],
skipDuplicates: true,
});
expect(first.count).toBe(1);
expect(second.count).toBe(0);
});
it('lets any number of suggestions share a missing dedupe key', async () => {
const reasonCode = `T4_SCHEMA_TEST_${suffix}`;
const result = await prisma.suggestion.createMany({
data: [
suggestionData({ reasonCode, dedupeKey: null }),
suggestionData({ reasonCode, dedupeKey: null }),
],
skipDuplicates: true,
});
expect(result.count).toBe(2);
});
it('starts a suggestion OPEN with normal priority', async () => {
const created = await prisma.suggestion.create({
data: suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
}),
});
createdSuggestionIds.push(created.id);
expect(created.status).toBe('OPEN');
expect(created.priority).toBe('NORMAL');
expect(created.dedupeKey).toBeNull();
});
it('keeps a task when its source suggestion is deleted', async () => {
const suggestion = await prisma.suggestion.create({
data: suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
}),
});
const task = await prisma.task.create({
data: { title: `T4 task ${suffix}`, sourceSuggestionId: suggestion.id },
});
createdTaskIds.push(task.id);
await prisma.suggestion.delete({ where: { id: suggestion.id } });
const after = await prisma.task.findUniqueOrThrow({
where: { id: task.id },
});
expect(after.sourceSuggestionId).toBeNull();
});
it('accepts the new enum values', async () => {
const rows = await prisma.$queryRaw<
{ enum: string; values: string[] }[]
>`SELECT t.typname AS enum, array_agg(e.enumlabel::text) AS values
FROM pg_type t JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname IN ('AutomationTriggerType', 'BatchMovementType', 'CalendarSyncSourceType')
GROUP BY t.typname`;
const valuesOf = (name: string) =>
rows.find((row) => row.enum === name)?.values ?? [];
expect(valuesOf('AutomationTriggerType')).toEqual(
expect.arrayContaining([
'INSPECTION_INTERVAL',
'QUEEN_CELLS_SWARM_CHECK',
'QUEEN_ACCEPTANCE_CHECK',
'SPLIT_SCHEDULE',
'COLONY_ENDED_CLEANUP',
'WITHDRAWAL_ENDED',
'HONEY_WATER_CONTENT',
'SEASON_AUTOPILOT',
'PHENOLOGY',
]),
);
expect(valuesOf('BatchMovementType')).toContain('BOTTLING');
expect(valuesOf('CalendarSyncSourceType')).toContain('WITHDRAWAL');
});
describe('row locks on the new tables', () => {
it('locks a suggestion by id and reports a missing one', async () => {
const suggestion = await prisma.suggestion.create({
data: suggestionData({
reasonCode: `T4_SCHEMA_TEST_${suffix}`,
}),
});
createdSuggestionIds.push(suggestion.id);
const result = await prisma.$transaction(async (tx) => {
const existing = await lockRowForUpdate(
tx,
'Suggestion',
suggestion.id,
);
await lockRowsForUpdate(tx, 'Suggestion', [suggestion.id, 'nope']);
await lockRowsForShare(tx, 'Suggestion', [suggestion.id]);
const missing = await lockRowForUpdate(tx, 'Suggestion', 'nope');
return { existing, missing };
});
expect(result).toEqual({ existing: true, missing: false });
});
it('creates a missing rule from the defaults, locks it and reads it parsed', async () => {
await prisma.automationRule.deleteMany({
where: { triggerType: 'COLONY_ENDED_CLEANUP' },
});
const before = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
expect(before.id).toBeNull();
expect(before.active).toBe(true);
const locked = await prisma.$transaction((tx) =>
lockRuleIn(tx, 'COLONY_ENDED_CLEANUP'),
);
expect(locked.active).toBe(true);
expect(locked.config).toEqual({});
expect(locked.actionTemplate).toBe(
DEFAULT_RULES.COLONY_ENDED_CLEANUP.actionTemplate,
);
const after = await readRule(prisma, 'COLONY_ENDED_CLEANUP');
expect(after.id).toBe(locked.id);
});
it('leaves an existing rule as it is', async () => {
const rule = await prisma.automationRule.upsert({
where: { triggerType: 'WITHDRAWAL_ENDED' },
create: {
triggerType: 'WITHDRAWAL_ENDED',
active: false,
config: { notify: false },
actionTemplate: '',
},
update: { active: false, config: { notify: false } },
});
const locked = await prisma.$transaction((tx) =>
lockRuleIn(tx, 'WITHDRAWAL_ENDED'),
);
expect(locked.id).toBe(rule.id);
expect(locked.active).toBe(false);
expect(locked.config).toEqual({ notify: false });
await prisma.automationRule.update({
where: { id: rule.id },
data: { active: true, config: { notify: true } },
});
});
});
});
@@ -0,0 +1,187 @@
import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { promisify } from 'node:util';
import { Client } from 'pg';
const execFileAsync = promisify(execFile);
const BACKEND_ROOT = path.resolve(__dirname, '../..');
const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations');
const SCHEMA_FILE = path.join(BACKEND_ROOT, 'prisma', 'schema.prisma');
const SCRATCH_ROOT = path.join(BACKEND_ROOT, '.tmp-migrations');
const PRISMA_CLI = path.join(
BACKEND_ROOT,
'node_modules',
'prisma',
'build',
'index.js',
);
const DEPLOY_TIMEOUT_MS = 100_000;
const SCHEMA_NAME_PATTERN = /^mig_[0-9a-f]{12}$/;
export interface ScratchDatabase {
readonly schema: string;
query<T = Record<string, unknown>>(
sql: string,
params?: unknown[],
): Promise<T[]>;
/** Applies every migration up to and including the named one. */
migrateThrough(migrationName: string): Promise<void>;
drop(): Promise<void>;
}
function baseConnectionString(): string {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL is not set');
}
return url;
}
function withSchema(connectionString: string, schema: string): string {
const url = new URL(connectionString);
url.searchParams.set('schema', schema);
return url.toString();
}
function withoutSchemaParam(connectionString: string): string {
const url = new URL(connectionString);
url.searchParams.delete('schema');
return url.toString();
}
async function listMigrationNames(): Promise<string[]> {
const entries = await fs.readdir(MIGRATIONS_DIR, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
/**
* Creates a fresh Postgres schema and migrates it up to, but not including,
* `beforeMigration`. The migration folders are copied below
* `.tmp-migrations/` inside the backend so that the prisma config resolves
* the installed `prisma` package.
*/
export async function createScratchDatabase(
beforeMigration: string,
): Promise<ScratchDatabase> {
const names = await listMigrationNames();
const target = names.indexOf(beforeMigration);
if (target < 0) {
throw new Error(`Unknown migration: ${beforeMigration}`);
}
const id = randomBytes(6).toString('hex');
const schema = `mig_${id}`;
if (!SCHEMA_NAME_PATTERN.test(schema)) {
throw new Error('Unexpected scratch schema name');
}
const workDir = path.join(SCRATCH_ROOT, id);
const scratchUrl = withSchema(baseConnectionString(), schema);
const admin = new Client({
connectionString: withoutSchemaParam(baseConnectionString()),
});
await admin.connect();
await admin.query(`CREATE SCHEMA "${schema}"`);
await fs.mkdir(workDir, { recursive: true });
await fs.copyFile(
path.join(MIGRATIONS_DIR, 'migration_lock.toml'),
path.join(workDir, 'migration_lock.toml'),
);
// The URL reaches the config through the environment, never through disk.
await fs.writeFile(
path.join(workDir, 'prisma.config.ts'),
[
"import { defineConfig } from 'prisma/config';",
'',
'export default defineConfig({',
` schema: ${JSON.stringify(SCHEMA_FILE)},`,
` migrations: { path: ${JSON.stringify(workDir)} },`,
" datasource: { url: process.env['MIGRATION_HARNESS_URL'] },",
'});',
'',
].join('\n'),
);
const copied = new Set<string>();
const deploy = async (upToExclusive: number): Promise<void> => {
for (const name of names.slice(0, upToExclusive)) {
if (!copied.has(name)) {
await fs.cp(path.join(MIGRATIONS_DIR, name), path.join(workDir, name), {
recursive: true,
});
copied.add(name);
}
}
await execFileAsync(
process.execPath,
[
PRISMA_CLI,
'migrate',
'deploy',
'--config',
path.join(workDir, 'prisma.config.ts'),
],
{
cwd: BACKEND_ROOT,
env: { ...process.env, MIGRATION_HARNESS_URL: scratchUrl },
timeout: DEPLOY_TIMEOUT_MS,
},
);
};
let client: Client | null = null;
const connected = async (): Promise<Client> => {
if (!client) {
client = new Client({
connectionString: withoutSchemaParam(baseConnectionString()),
});
await client.connect();
await client.query(`SET search_path TO "${schema}"`);
}
return client;
};
try {
await deploy(target);
} catch (error) {
await fs.rm(workDir, { recursive: true, force: true });
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
await admin.end();
throw error;
}
return {
schema,
async query<T = Record<string, unknown>>(
sql: string,
params: unknown[] = [],
): Promise<T[]> {
const active = await connected();
const result = await active.query(sql, params);
return result.rows as T[];
},
async migrateThrough(migrationName: string): Promise<void> {
const index = names.indexOf(migrationName);
if (index < 0) {
throw new Error(`Unknown migration: ${migrationName}`);
}
await deploy(index + 1);
},
async drop(): Promise<void> {
if (client) {
await client.end();
client = null;
}
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
await admin.end();
await fs.rm(workDir, { recursive: true, force: true });
},
};
}
@@ -0,0 +1,263 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AutomationRulesTab } from "./automation-rules-tab";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
import { ApiError } from "@/lib/auth/api-client";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/automation/api", () => ({
fetchAutomationRules: vi.fn(),
updateAutomationRule: vi.fn(),
resetAutomationRuleSequence: vi.fn(),
}));
function rule(
triggerType: AutomationTriggerType,
config: Record<string, unknown> | null,
active = true,
): AutomationRule {
return {
id: `rule-${triggerType}`,
triggerType,
active,
config,
actionTemplate: null,
sequenceValue: 0,
createdAt: "2026-10-01T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
};
}
function renderTab(rules: AutomationRule[]) {
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
vi.mocked(updateAutomationRule).mockImplementation(
async (id, input) =>
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<AutomationRulesTab />
</QueryClientProvider>,
);
}
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
type LooseConfig = ReturnType<typeof JSON.parse>;
function savedConfig(): LooseConfig {
return vi.mocked(updateAutomationRule).mock.calls[0][1].config as LooseConfig;
}
const VARROA = rule("VARROA_THRESHOLD", { thresholdMitesPerDay: 10 });
const VARROA_TITLE = "Varroa-Schwellenwert überschritten";
const RED_LABEL = /Rot ab mehr als/;
/** The red limit of the natural mite fall, found by the group it sits in. */
function naturalFallRed(view: ReturnType<typeof within>) {
const group = view.getByRole("group", { name: /Natürlicher Milbenfall/ });
return within(group).getByLabelText(RED_LABEL);
}
beforeEach(() => vi.clearAllMocks());
describe("varroa rule form", () => {
it("the varroa form saves bands and shows „LWG Veitshöchheim“ next to the values", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
const view = await card(VARROA_TITLE);
expect(view.getAllByText(/LWG Veitshöchheim/).length).toBeGreaterThan(0);
// The old single threshold shows up as the red limit of the natural fall.
const red = naturalFallRed(view);
expect(red).toHaveValue("10");
await user.clear(red);
await user.type(red, "12,5");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0][0]).toBe("rule-VARROA_THRESHOLD");
const config = savedConfig();
expect(config.naturalFall).toMatchObject({ redAbove: 12.5, yellowFrom: 5, winterRedAbove: 0.5 });
expect(config.naturalFall.seasonMonths).toEqual([3, 4, 5, 6, 7, 8, 9, 10]);
expect(config.youngColonyTypes).toEqual(["YOUNG_COLONY", "NUCLEUS_COLONY", "MATING_NUCLEUS"]);
expect(config.notify).toBe(true);
});
it("a 400 issue appears under its field", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "Die Einstellung ist ungültig.", {
message: "Die Einstellung ist ungültig.",
issues: [{ field: "naturalFall.redAbove", message: "Gelb darf nicht über Rot liegen." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
const message = await view.findByText("Gelb darf nicht über Rot liegen.");
const field = naturalFallRed(view);
expect(field.getAttribute("aria-describedby")).toContain(message.id);
});
it("does not send a value that is not a number", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
const view = await card(VARROA_TITLE);
const red = naturalFallRed(view);
await user.clear(red);
await user.type(red, "abc");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte eine Zahl angeben.")).toBeInTheDocument();
expect(updateAutomationRule).not.toHaveBeenCalled();
});
});
describe("season autopilot form", () => {
it("autopilot rules A1–A10 switch individually", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", { honeySuperMinFrames: 9 })]);
const view = await card("Saison-Autopilot");
const switches = view.getAllByRole("checkbox", { name: /^A\d+ / });
expect(switches).toHaveLength(10);
switches.forEach((box) => expect(box).toBeChecked());
await user.click(view.getByRole("checkbox", { name: /^A3 / }));
expect(view.getByRole("checkbox", { name: /^A3 / })).not.toBeChecked();
expect(view.getByRole("checkbox", { name: /^A4 / })).toBeChecked();
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.rules.A3).toBe(false);
expect(Object.values(config.rules).filter(Boolean)).toHaveLength(9);
expect(config.honeySuperMinFrames).toBe(9);
expect(config.winterVarroaWindow).toEqual({ from: "11-15", to: "12-20" });
});
it("shows server issues of the MM-DD fields under them", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", null)]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "winterVarroaWindow.from", message: "Bitte Monat und Tag als MM-TT angeben." }],
}),
);
const view = await card("Saison-Autopilot");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte Monat und Tag als MM-TT angeben.")).toBeInTheDocument();
});
});
describe("other rule forms", () => {
it("phenology saves calendar fallbacks as MM-DD", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null)]);
const view = await card("Pflanzenentwicklung");
const salweide = view.getByLabelText(/^Salweide blüht/);
expect(salweide).toHaveValue("03-20");
await user.clear(salweide);
await user.type(salweide, "03-25");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.calendarFallback.SALWEIDE).toBe("03-25");
expect(config.calendarFallback.WINTER).toBe("11-15");
expect(config.gtsThresholds).toEqual({ SALWEIDE: 200 });
expect(config.swarmSeason).toEqual({ from: "KIRSCHE", until: "LINDE" });
});
it("inspection intervals save per section", async () => {
const user = userEvent.setup();
renderTab([rule("INSPECTION_INTERVAL", null)]);
const view = await card("Durchsicht-Rhythmus");
await user.click(view.getByRole("button", { name: /\+.*Schwarmzeit/ }));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig().intervalDays).toEqual({
FRUEHJAHR: 14,
SCHWARMZEIT: 8,
SOMMER: 14,
SPAETSOMMER: 21,
HERBST: 21,
});
});
it("a chain rule saves the due days and the notify switch", async () => {
const user = userEvent.setup();
renderTab([rule("QUEEN_CELLS_SWARM_CHECK", { dueInDays: 7, notify: false })]);
const view = await card("Weiselzellen: Schwarmkontrolle");
await user.click(view.getByRole("checkbox", { name: "Zusätzlich melden" }));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig()).toEqual({ dueInDays: 7, notify: true });
});
it("the split schedule keeps its steps and saves the changed day", async () => {
const user = userEvent.setup();
renderTab([rule("SPLIT_SCHEDULE", null)]);
const view = await card("Ableger-Fahrplan");
const day = view.getByLabelText(/^Begattung prüfen(?!:)/);
expect(day).toHaveValue("21");
await user.clear(day);
await user.type(day, "22");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.RAISE_OWN[1]).toEqual({ step: "MATING_CHECK", offsetDays: 22, windowDays: 3 });
expect(config.QUEEN).toEqual([{ step: "ACCEPTANCE_CHECK", offsetDays: 7 }]);
expect(config.notify).toBe(false);
});
it("the water content form shows both sources and keeps the honey types", async () => {
const user = userEvent.setup();
renderTab([
rule("HONEY_WATER_CONTENT", {
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
}),
]);
const view = await card("Wassergehalt Honig");
expect(view.getAllByText(/D\.I\.B\./).length).toBeGreaterThan(0);
expect(view.getAllByText(/HonigV Anlage 2/).length).toBeGreaterThan(0);
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig()).toEqual({
warnAbovePercent: 18,
confirmAbovePercent: 20,
byHoneyType: { Heidehonig: { confirmAbovePercent: 23 } },
});
});
it("withdrawal ended offers only the notify switch", async () => {
const user = userEvent.setup();
renderTab([rule("WITHDRAWAL_ENDED", { notify: true })]);
const view = await card("Wartezeit zu Ende");
await user.click(view.getByRole("checkbox", { name: "Zusätzlich melden" }));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0][1]).toEqual({ config: { notify: false } });
});
it("an older rule keeps its notify setting when saved", async () => {
const user = userEvent.setup();
renderTab([rule("QUEENLESS_NO_EGGS", { notify: true, dueInDays: 2 })]);
const view = await card("Keine Königin gesehen und keine Stifte");
expect(view.getByRole("checkbox", { name: "Zusätzlich melden" })).toBeChecked();
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig()).toEqual({ notify: true, dueInDays: 2 });
});
});
@@ -8,64 +8,28 @@ import {
resetAutomationRuleSequence,
updateAutomationRule,
} from "@/lib/automation/api";
import {
RULE_DESCRIPTIONS,
RULE_LABELS,
SIMPLE_RULE_NOTIFY_DEFAULTS,
issuesByField,
} from "@/lib/automation/rule-configs";
import {
AutomationRule,
AutomationTriggerType,
UpdateAutomationRuleInput,
} from "@/lib/automation/types";
import { errorMessage } from "@/lib/forms/error-message";
import { emptyToNull } from "@/lib/forms/empty-to-null";
import { formatGeneratedNumber } from "@/lib/automation/number-format";
import { ToggleChip } from "@/components/form/toggle-chip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const TRIGGER_LABELS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS: "Keine Königin gesehen und keine Stifte",
VARROA_THRESHOLD: "Varroa-Schwellenwert überschritten",
FEED_STATUS_CRITICAL: "Futterstatus kritisch",
SWARM_MOOD_HIGH: "Hohe Schwarmstimmung",
COLONY_HEALTH_CRITICAL: "Gesundheitsstatus kritisch",
STOCK_ITEM_LOW: "Lagerartikel unter Mindestbestand",
HONEY_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Honigchargen)",
BOTTLING_LOT_NUMBER_FORMAT: "Los-/Chargennummer-Format (Abfüllungen)",
TREATMENT_FOLLOW_UP: "Kontrolle nach Behandlung",
FEEDING_FOLLOW_UP: "Kontrolle nach Fütterung",
RAW_MATERIAL_LOT_NUMBER_FORMAT: "Chargennummer-Format (Rohstoffe)",
PRODUCTION_BATCH_NUMBER_FORMAT: "Chargennummer-Format (Herstellung)",
RAW_MATERIAL_LOW: "Rohstoff unter Mindestmenge",
};
const TRIGGER_DESCRIPTIONS: Record<AutomationTriggerType, string> = {
QUEENLESS_NO_EGGS:
"Bei einer Durchsicht: Königin nicht gesehen und keine Stifte vorhanden, dann Kontrollaufgabe.",
VARROA_THRESHOLD:
"Milbenzahl (bzw. Milbenfall pro Tag) über dem Schwellenwert, dann Behandlung prüfen.",
FEED_STATUS_CRITICAL:
"Futterstatus bei einer Durchsicht auf kritisch gesetzt, dann Fütterungsaufgabe.",
SWARM_MOOD_HIGH:
"Bei einer Schwarmkontrolle Schwarmstimmung hoch erfasst, dann Kontrollaufgabe.",
COLONY_HEALTH_CRITICAL:
"Gesundheitsstatus eines Volkes wechselt auf kritisch, dann Kontrollaufgabe.",
STOCK_ITEM_LOW:
"Bestand eines Lagerartikels fällt nach einer Buchung unter den Mindestbestand, dann Nachbestellaufgabe.",
HONEY_BATCH_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Honigchargen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
BOTTLING_LOT_NUMBER_FORMAT:
"Wenn aktiv, wird die Los-/Chargennummer neuer Abfüllungen automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
TREATMENT_FOLLOW_UP:
"Nach jeder erfassten Behandlung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach Behandlungsende. Die passende Frist hängt vom Mittel ab (Packungsbeilage) – deshalb ab Werk aus und ohne voreingestellte Frist.",
FEEDING_FOLLOW_UP:
"Nach jeder erfassten Fütterung eine Kontrollaufgabe vorschlagen, fällig die eingestellten Tage nach der Fütterung. Ab Werk aus und ohne voreingestellte Frist.",
RAW_MATERIAL_LOT_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Rohstoff-Chargen (Wachs, Propolis) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
PRODUCTION_BATCH_NUMBER_FORMAT:
"Wenn aktiv, wird die Chargennummer neuer Herstellungschargen (Kerzen, Tinkturen, …) automatisch nach diesem Format erzeugt, statt sie manuell einzugeben.",
RAW_MATERIAL_LOW:
"Fällt der Gesamtbestand einer Rohstoff-Art nach Verbrauch, Entnahme oder Inventur unter die Mindestmenge, dann Aufgabe zum Nachschub. Leere Mindestmenge = diese Art wird nicht überwacht.",
};
import { hasRuleForm, SpecialRuleCard } from "./special-rule-card";
const NUMBER_FORMAT_TRIGGER_TYPES: AutomationTriggerType[] = [
"HONEY_BATCH_NUMBER_FORMAT",
@@ -124,15 +88,23 @@ function ruleDueInDays(rule: AutomationRule): string {
return config?.dueInDays === undefined ? "" : String(config.dueInDays);
}
function ruleThreshold(rule: AutomationRule): string {
const config = rule.config as { thresholdMitesPerDay?: number } | null;
return String(config?.thresholdMitesPerDay ?? "");
function ruleNotify(rule: AutomationRule): boolean {
const config = rule.config as { notify?: unknown } | null;
return typeof config?.notify === "boolean"
? config.notify
: (SIMPLE_RULE_NOTIFY_DEFAULTS[rule.triggerType] ?? false);
}
function RuleCard({ rule }: { rule: AutomationRule }) {
const queryClient = useQueryClient();
const [actionTemplate, setActionTemplate] = useState(rule.actionTemplate ?? "");
const [threshold, setThreshold] = useState(ruleThreshold(rule));
const [notify, setNotify] = useState(ruleNotify(rule));
const [issues, setIssues] = useState<Record<string, string>>({});
// A server message goes away as soon as its field is edited.
const clearIssues = (prefix: string) =>
setIssues((current) =>
Object.fromEntries(Object.entries(current).filter(([field]) => !field.startsWith(prefix))),
);
const [format, setFormat] = useState(ruleFormat(rule));
const [dueInDays, setDueInDays] = useState(ruleDueInDays(rule));
const [minGrams, setMinGrams] = useState<MinGrams>(ruleMinGrams(rule));
@@ -147,10 +119,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
mutationFn: (input: UpdateAutomationRuleInput) =>
updateAutomationRule(rule.id, input),
onSuccess: () => {
setIssues({});
void queryClient.invalidateQueries({ queryKey: ["automation-rules"] });
toast.success("Regel gespeichert");
},
onError: () => toast.error("Regel konnte nicht gespeichert werden"),
onError: (error) => {
setIssues(issuesByField(error));
toast.error(errorMessage(error, "Regel konnte nicht gespeichert werden"));
},
});
const resetMutation = useMutation({
@@ -173,10 +149,7 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
toast.error("Frist bitte als ganze Tage zwischen 0 und 365 angeben – oder leer lassen.");
return;
}
const config: Record<string, unknown> = { dueInDays: days };
if (rule.triggerType === "VARROA_THRESHOLD") {
config.thresholdMitesPerDay = Number(threshold) || 10;
}
const config: Record<string, unknown> = { notify, dueInDays: days };
if (rule.triggerType === "RAW_MATERIAL_LOW") {
const parsed = parseMinGrams(minGrams);
if (!parsed) {
@@ -191,13 +164,13 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{TRIGGER_LABELS[rule.triggerType]}</CardTitle>
<CardTitle>{RULE_LABELS[rule.triggerType]}</CardTitle>
<Badge variant={rule.active ? "default" : "secondary"}>
{rule.active ? "Aktiv" : "Inaktiv"}
</Badge>
</CardHeader>
<CardContent className="flex flex-col gap-4 text-sm">
<p className="text-muted-foreground">{TRIGGER_DESCRIPTIONS[rule.triggerType]}</p>
<p className="text-muted-foreground">{RULE_DESCRIPTIONS[rule.triggerType]}</p>
{numberFormatRule ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`format-${rule.id}`}>Format</Label>
@@ -205,7 +178,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
id={`format-${rule.id}`}
value={format}
placeholder="WH-{YYYY}-{SEQ:4}"
onChange={(event) => setFormat(event.target.value)}
onChange={(event) => {
clearIssues("format");
setFormat(event.target.value);
}}
/>
<p className="text-xs text-muted-foreground">
Platzhalter: {"{YYYY}"} Jahr, {"{MM}"} Monat, {"{DD}"} Tag, {"{SEQ}"} laufende
@@ -269,19 +245,10 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
max={365}
placeholder="leer = ohne Termin"
value={dueInDays}
onChange={(event) => setDueInDays(event.target.value)}
/>
</div>
) : null}
{rule.triggerType === "VARROA_THRESHOLD" ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`threshold-${rule.id}`}>Schwellenwert (Milben/Tag)</Label>
<Input
id={`threshold-${rule.id}`}
type="number"
inputMode="decimal"
value={threshold}
onChange={(event) => setThreshold(event.target.value)}
onChange={(event) => {
clearIssues("dueInDays");
setDueInDays(event.target.value);
}}
/>
</div>
) : null}
@@ -297,13 +264,27 @@ function RuleCard({ rule }: { rule: AutomationRule }) {
step={1}
placeholder="leer = nicht überwachen"
value={minGrams[kind] ?? ""}
onChange={(event) =>
setMinGrams((current) => ({ ...current, [kind]: event.target.value }))
}
onChange={(event) => {
clearIssues("minGrams");
setMinGrams((current) => ({ ...current, [kind]: event.target.value }));
}}
/>
</div>
))
: null}
{Object.entries(issues).map(([field, message]) => (
<p key={field} role="alert" className="text-sm font-medium text-destructive">
{message}
</p>
))}
{!numberFormatRule ? (
<ToggleChip label="Zusätzlich melden" checked={notify}
onChange={(checked) => {
clearIssues("notify");
setNotify(checked);
}}
/>
) : null}
<div className="flex justify-between gap-2">
<Button
type="button"
@@ -350,9 +331,13 @@ export function AutomationRulesTab() {
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{rules?.map((rule) => (
{rules?.map((rule) =>
hasRuleForm(rule.triggerType) ? (
<SpecialRuleCard key={rule.id} rule={rule} />
) : (
<RuleCard key={rule.id} rule={rule} />
))}
),
)}
</div>
</div>
);
@@ -0,0 +1,342 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AutomationRulesTab } from "./automation-rules-tab";
import { SpecialRuleCard } from "./special-rule-card";
import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api";
import { ApiError } from "@/lib/auth/api-client";
import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/automation/api", () => ({
fetchAutomationRules: vi.fn(),
updateAutomationRule: vi.fn(),
resetAutomationRuleSequence: vi.fn(),
}));
function rule(
triggerType: AutomationTriggerType,
config: Record<string, unknown> | null,
active = true,
): AutomationRule {
return {
id: `rule-${triggerType}`,
triggerType,
active,
config,
actionTemplate: null,
sequenceValue: 0,
createdAt: "2026-10-01T00:00:00Z",
updatedAt: "2026-10-01T00:00:00Z",
};
}
function renderTab(rules: AutomationRule[]) {
vi.mocked(fetchAutomationRules).mockResolvedValue(rules);
vi.mocked(updateAutomationRule).mockImplementation(
async (id, input) =>
({ ...rules.find((r) => r.id === id)!, ...input }) as AutomationRule,
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<AutomationRulesTab />
</QueryClientProvider>,
);
}
async function card(title: string) {
const heading = await screen.findByText(title);
return within(heading.closest("[data-slot='card']") as HTMLElement);
}
// Tests read nested keys of what was sent; the shape is checked by the matchers.
type LooseConfig = ReturnType<typeof JSON.parse>;
function savedConfig(): LooseConfig {
return vi.mocked(updateAutomationRule).mock.calls[0][1].config as LooseConfig;
}
const VARROA = rule("VARROA_THRESHOLD", { thresholdMitesPerDay: 10 });
const VARROA_TITLE = "Varroa-Schwellenwert überschritten";
function naturalFallRed(view: ReturnType<typeof within>) {
const group = view.getByRole("group", { name: /Natürlicher Milbenfall/ });
return within(group).getByLabelText(/Rot ab mehr als/);
}
beforeEach(() => vi.clearAllMocks());
describe("what a save keeps", () => {
it("keeps non-default month lists and colony types and drops the legacy threshold", async () => {
const user = userEvent.setup();
renderTab([
rule("VARROA_THRESHOLD", {
thresholdMitesPerDay: 8,
naturalFall: { seasonMonths: [4, 5, 6], yellowFrom: 3, redAbove: 8, winterRedAbove: 0.4 },
infestationPercent: { summerMonths: [6], autumnMonths: [11, 12, 1] },
youngColonyTypes: ["YOUNG_COLONY"],
}),
]);
const view = await card(VARROA_TITLE);
// The texts follow the stored months, not fixed words.
expect(view.getByText("Milben pro Tag. April bis Juni.")).toBeInTheDocument();
expect(view.getByText(/^Juli bis März\./)).toBeInTheDocument();
expect(view.getByText("Puderzucker und Auswaschung, November bis Januar")).toBeInTheDocument();
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config).not.toHaveProperty("thresholdMitesPerDay");
expect(config.naturalFall).toEqual({
seasonMonths: [4, 5, 6],
yellowFrom: 3,
redAbove: 8,
winterRedAbove: 0.4,
});
expect(config.infestationPercent.summerMonths).toEqual([6]);
expect(config.infestationPercent.autumnMonths).toEqual([11, 12, 1]);
expect(config.youngColonyTypes).toEqual(["YOUNG_COLONY"]);
});
it("keeps calibrated phases, other thresholds and unknown keys of the phenology rule", async () => {
const user = userEvent.setup();
renderTab([
rule("PHENOLOGY", {
gtsThresholds: { SALWEIDE: 150, KIRSCHE: 300 },
calibratePhases: ["KIRSCHE"],
futureKey: { a: 1 },
}),
]);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
const config = savedConfig();
expect(config.gtsThresholds).toEqual({ SALWEIDE: 150, KIRSCHE: 300 });
expect(config.calibratePhases).toEqual(["KIRSCHE"]);
expect(config.futureKey).toEqual({ a: 1 });
});
it("clearing the Salweide threshold sends an empty threshold group", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null)]);
const view = await card("Pflanzenentwicklung");
await user.clear(view.getByLabelText(/^Salweide: Wärmesumme ab/));
await user.click(view.getByRole("button", { name: "Speichern" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(savedConfig().gtsThresholds).toEqual({});
});
it("switches a rule on without a config", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null, false)]);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Aktivieren" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0]).toEqual([
"rule-PHENOLOGY",
{ active: true },
]);
});
it("switches a rule off without a config", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null, true)]);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Deaktivieren" }));
await waitFor(() => expect(updateAutomationRule).toHaveBeenCalled());
expect(vi.mocked(updateAutomationRule).mock.calls[0][1]).toEqual({ active: false });
});
});
describe("server issues", () => {
it("a server issue disappears once its field is edited", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "naturalFall.redAbove", message: "Gelb darf nicht über Rot liegen." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Gelb darf nicht über Rot liegen.")).toBeInTheDocument();
await user.type(naturalFallRed(view), "1");
expect(view.queryByText("Gelb darf nicht über Rot liegen.")).not.toBeInTheDocument();
});
it("shows issues of values without a field in an alert list", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [
{ field: "naturalFall.seasonMonths", message: "Monate nur einmal angeben." },
{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." },
],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Monate nur einmal angeben.")).toHaveAttribute("role", "alert");
expect(view.getByText("Unbekannter Volkstyp.")).toHaveAttribute("role", "alert");
});
it("shows a calendar order issue of the phenology rule", async () => {
const user = userEvent.setup();
renderTab([rule("PHENOLOGY", null)]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "calendarFallback.APFEL", message: "Das Datum darf nicht vor dem der vorherigen Phase liegen." }],
}),
);
const view = await card("Pflanzenentwicklung");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(
await view.findByText("Das Datum darf nicht vor dem der vorherigen Phase liegen."),
).toBeInTheDocument();
});
it("clears the messages when the rule is switched", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValueOnce(
new ApiError(400, "x", {
issues: [{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
await user.click(view.getByRole("button", { name: "Deaktivieren" }));
expect(view.queryByText("Unbekannter Volkstyp.")).not.toBeInTheDocument();
});
it("ties the hint and the message of a switch to it", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", null)]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", { issues: [{ field: "rules.A9", message: "Ungültig." }] }),
);
const view = await card("Saison-Autopilot");
const described = view.getByRole("checkbox", { name: /^A9 / }).getAttribute("aria-describedby");
expect(document.getElementById(String(described))).toHaveTextContent("Annahme");
await user.click(view.getByRole("button", { name: "Speichern" }));
const message = await view.findByText("Ungültig.");
expect(view.getByRole("checkbox", { name: /^A9 / }).getAttribute("aria-describedby")).toContain(
message.id,
);
});
});
describe("number checks", () => {
it("asks for whole days where days are meant", async () => {
const user = userEvent.setup();
renderTab([rule("QUEEN_CELLS_SWARM_CHECK", { dueInDays: 7, notify: false })]);
const view = await card("Weiselzellen: Schwarmkontrolle");
const days = view.getByLabelText(/^Fällig nach/);
for (const text of ["1,5", "-2", "x"]) {
await user.clear(days);
await user.type(days, text);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte ganze Tage angeben.")).toBeInTheDocument();
}
expect(updateAutomationRule).not.toHaveBeenCalled();
});
it("asks for a whole number for the frames", async () => {
const user = userEvent.setup();
renderTab([rule("SEASON_AUTOPILOT", null)]);
const view = await card("Saison-Autopilot");
const frames = view.getByLabelText(/^Honigraum ab besetzten Waben/);
await user.clear(frames);
await user.type(frames, "8,5");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte eine ganze Zahl angeben.")).toBeInTheDocument();
expect(updateAutomationRule).not.toHaveBeenCalled();
});
it("does not clamp an inspection interval outside its bounds", async () => {
const user = userEvent.setup();
renderTab([rule("INSPECTION_INTERVAL", null)]);
const view = await card("Durchsicht-Rhythmus");
for (let i = 0; i < 3; i += 1) {
await user.click(view.getByRole("button", { name: /\+.*Schwarmzeit/ }));
}
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Bitte 7 bis 9 Tage wählen.")).toBeInTheDocument();
expect(updateAutomationRule).not.toHaveBeenCalled();
});
});
describe("a refreshed rule", () => {
it("starts the form again when the rule was updated with a new shape", () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
</QueryClientProvider>
);
const first = rule("PHENOLOGY", null);
const view = render(wrap(first));
expect(screen.getByLabelText(/^Salweide: Wärmesumme ab/)).toHaveValue("200");
view.rerender(
wrap({
...first,
updatedAt: "2026-10-02T00:00:00Z",
config: { gtsThresholds: { SALWEIDE: 150 } },
}),
);
expect(screen.getByLabelText(/^Salweide: Wärmesumme ab/)).toHaveValue("150");
});
});
describe("fix round 2", () => {
it("keeps unsaved edits when the rule is switched", async () => {
const user = userEvent.setup();
const phenology = rule("PHENOLOGY", null);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.mocked(updateAutomationRule).mockResolvedValue(phenology);
const wrap = (current: AutomationRule) => (
<QueryClientProvider client={client}>
<SpecialRuleCard rule={current} />
</QueryClientProvider>
);
const view = render(wrap(phenology));
const field = screen.getByLabelText(/^Salweide blüht/);
await user.clear(field);
await user.type(field, "03-25");
view.rerender(wrap({ ...phenology, active: false, updatedAt: "2026-10-03T00:00:00Z" }));
expect(screen.getByLabelText(/^Salweide blüht/)).toHaveValue("03-25");
});
it("hides issues without a field as soon as the user edits anything", async () => {
const user = userEvent.setup();
renderTab([VARROA]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", {
issues: [{ field: "youngColonyTypes", message: "Unbekannter Volkstyp." }],
}),
);
const view = await card(VARROA_TITLE);
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
await user.type(naturalFallRed(view), "1");
expect(view.queryByText("Unbekannter Volkstyp.")).not.toBeInTheDocument();
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Unbekannter Volkstyp.")).toBeInTheDocument();
});
it("clears the message of an older rule when its field changes", async () => {
const user = userEvent.setup();
renderTab([rule("QUEENLESS_NO_EGGS", { notify: true, dueInDays: 2 })]);
vi.mocked(updateAutomationRule).mockRejectedValue(
new ApiError(400, "x", { issues: [{ field: "dueInDays", message: "Zu viele Tage." }] }),
);
const view = await card("Keine Königin gesehen und keine Stifte");
await user.click(view.getByRole("button", { name: "Speichern" }));
expect(await view.findByText("Zu viele Tage.")).toBeInTheDocument();
await user.type(view.getByLabelText(/Fällig nach/), "0");
expect(view.queryByText("Zu viele Tage.")).not.toBeInTheDocument();
});
});
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more