From d99a697739581d717bb237a40b8b2347aae8f024 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 14 Oct 2026 13:42:32 +0200 Subject: [PATCH] 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) --- ApiCore-Backend/.gitignore | 3 + .../migration.sql | 19 + .../migration.sql | 71 ++ .../migration.sql | 34 + .../migration.sql | 8 + .../migration.sql | 5 + ApiCore-Backend/prisma/schema.prisma | 127 +- ApiCore-Backend/src/app.module.ts | 2 + .../permissions/permission-resolver.module.ts | 16 + .../src/automation/automation.service.spec.ts | 120 +- .../src/automation/automation.service.ts | 156 +-- .../automation/rule-config/chain.config.ts | 40 + .../automation/rule-config/default-rules.ts | 72 ++ .../src/automation/rule-config/index.ts | 8 + .../rule-config/inspection-interval.config.ts | 44 + .../automation/rule-config/parse-helpers.ts | 349 ++++++ .../rule-config/phenology.config.ts | 158 +++ .../rule-config/rule-config.reader.ts | 100 ++ .../rule-config/rule-config.registry.ts | 91 ++ .../rule-config/rule-config.spec.ts | 440 +++++++ .../rule-config/rule-config.types.ts | 140 +++ .../rule-config/season-autopilot.config.ts | 102 ++ .../automation/rule-config/simple.config.ts | 93 ++ .../rule-config/split-schedule.config.ts | 106 ++ .../rule-config/varroa-threshold.config.ts | 188 +++ .../rule-config/water-content.config.ts | 112 ++ .../src/automation/varroa-threshold.spec.ts | 18 + .../src/automation/varroa-threshold.ts | 22 +- .../src/backup/backup.service.spec.ts | 152 +++ ApiCore-Backend/src/backup/backup.service.ts | 58 +- .../calendar-sync.service.spec.ts | 18 + .../calendar-sync/calendar-sync.service.ts | 5 + ApiCore-Backend/src/common/dates.spec.ts | 100 ++ ApiCore-Backend/src/common/dates.ts | 101 ++ .../src/common/feed-vocabulary.spec.ts | 56 + ApiCore-Backend/src/common/feed-vocabulary.ts | 67 ++ .../src/dashboard/dashboard.service.spec.ts | 43 +- .../src/dashboard/dashboard.service.ts | 21 +- .../src/domain-events/domain-event-types.ts | 14 +- .../src/export/colony-record-card.ts | 6 +- .../src/import/import.service.spec.ts | 36 + ApiCore-Backend/src/import/import.service.ts | 25 +- .../dto/create-inspection.dto.spec.ts | 39 + .../inspections/dto/create-inspection.dto.ts | 24 +- .../src/jobs/daily-evaluation-steps.spec.ts | 93 ++ .../src/jobs/daily-evaluation-steps.ts | 69 ++ .../src/jobs/daily-evaluation.job.spec.ts | 162 +-- .../src/jobs/daily-evaluation.job.ts | 75 +- .../src/jobs/daily-evaluation.rules.spec.ts | 506 +++++--- .../src/jobs/daily-evaluation.rules.ts | 308 +++-- ApiCore-Backend/src/jobs/jobs.module.ts | 22 +- .../src/jobs/steps/time-events.step.spec.ts | 168 +++ .../src/jobs/steps/time-events.step.ts | 76 ++ ApiCore-Backend/src/prisma/row-lock.spec.ts | 96 ++ ApiCore-Backend/src/prisma/row-lock.ts | 86 +- .../src/season/inspection-due.spec.ts | 73 ++ ApiCore-Backend/src/season/inspection-due.ts | 35 + .../src/season/queen-age-threshold.spec.ts | 30 + ApiCore-Backend/src/season/queen-age.spec.ts | 17 + ApiCore-Backend/src/season/queen-age.ts | 15 + .../src/season/season-calendar.spec.ts | 92 ++ ApiCore-Backend/src/season/season-calendar.ts | 92 ++ .../src/season/season-segment.spec.ts | 34 + ApiCore-Backend/src/season/season-segment.ts | 38 + ApiCore-Backend/src/season/season-types.ts | 51 + ApiCore-Backend/src/season/season.module.ts | 8 + .../src/season/season.service.spec.ts | 58 + ApiCore-Backend/src/season/season.service.ts | 30 + ApiCore-Backend/src/season/varroa-due.spec.ts | 128 ++ ApiCore-Backend/src/season/varroa-due.ts | 67 ++ .../src/stock/stock-ledger.spec.ts | 15 + ApiCore-Backend/src/stock/stock-ledger.ts | 9 +- .../src/suggestions/evidence-lookup.spec.ts | 71 ++ .../src/suggestions/evidence-lookup.ts | 109 ++ .../src/suggestions/suggestion-plan.spec.ts | 42 + .../src/suggestions/suggestion-plan.ts | 71 ++ .../src/suggestions/suggestion-texts.spec.ts | 96 ++ .../src/suggestions/suggestion-texts.ts | 127 ++ .../src/suggestions/suggestion.types.ts | 86 ++ .../src/suggestions/suggestions.module.ts | 15 + .../suggestions/suggestions.service.spec.ts | 1049 +++++++++++++++++ .../src/suggestions/suggestions.service.ts | 700 +++++++++++ .../src/suggestions/texts/autopilot-texts.ts | 4 + .../src/suggestions/texts/chain-texts.ts | 12 + .../src/suggestions/texts/cleanup-texts.ts | 4 + .../src/suggestions/texts/rule-texts.ts | 4 + .../src/system/job-run.service.spec.ts | 26 +- ApiCore-Backend/src/system/job-run.service.ts | 22 +- ApiCore-Backend/src/tasks/tasks.service.ts | 1 + ApiCore-Backend/src/worker/worker.module.ts | 4 + ApiCore-Backend/test/automation.e2e-spec.ts | 93 +- .../test/daily-evaluation.e2e-spec.ts | 106 +- ApiCore-Backend/test/inspections.e2e-spec.ts | 34 + ApiCore-Backend/test/suggestions.e2e-spec.ts | 415 +++++++ .../test/t4-migrations.e2e-spec.ts | 218 ++++ ApiCore-Backend/test/t4-schema.e2e-spec.ts | 217 ++++ .../test/utils/migration-harness.ts | 187 +++ .../automation/automation-rules-tab.spec.tsx | 263 +++++ .../automation/automation-rules-tab.tsx | 137 +-- .../settings/automation/rule-forms.spec.tsx | 342 ++++++ .../automation/rule-forms/chain-form.tsx | 31 + .../rule-forms/inspection-interval-form.tsx | 78 ++ .../automation/rule-forms/month-ranges.ts | 57 + .../automation/rule-forms/notify-form.tsx | 39 + .../automation/rule-forms/phenology-form.tsx | 120 ++ .../automation/rule-forms/rule-form-parts.tsx | 202 ++++ .../rule-forms/season-autopilot-form.tsx | 116 ++ .../rule-forms/split-schedule-form.tsx | 97 ++ .../automation/rule-forms/use-rule-draft.ts | 229 ++++ .../rule-forms/varroa-threshold-form.tsx | 97 ++ .../rule-forms/water-content-form.tsx | 41 + .../settings/automation/special-rule-card.tsx | 98 ++ .../src/components/form/toggle-chip.tsx | 5 +- .../src/lib/automation/rule-configs.spec.ts | 164 +++ .../src/lib/automation/rule-configs.ts | 309 +++++ ApiCore-Frontend/src/lib/automation/types.ts | 40 +- 116 files changed, 11239 insertions(+), 801 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261014100000_t4_enum_values/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261014100100_t4_suggestions/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261014100200_t4_feed_vocabulary/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261014100300_t4_split_queen_source/migration.sql create mode 100644 ApiCore-Backend/prisma/migrations/20261014100400_t4_varroa_sample_size/migration.sql create mode 100644 ApiCore-Backend/src/auth/permissions/permission-resolver.module.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/chain.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/default-rules.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/index.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/inspection-interval.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/parse-helpers.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/phenology.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/rule-config.reader.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/rule-config.registry.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/rule-config.spec.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/rule-config.types.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/season-autopilot.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/simple.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/split-schedule.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/varroa-threshold.config.ts create mode 100644 ApiCore-Backend/src/automation/rule-config/water-content.config.ts create mode 100644 ApiCore-Backend/src/common/dates.spec.ts create mode 100644 ApiCore-Backend/src/common/dates.ts create mode 100644 ApiCore-Backend/src/common/feed-vocabulary.spec.ts create mode 100644 ApiCore-Backend/src/common/feed-vocabulary.ts create mode 100644 ApiCore-Backend/src/inspections/dto/create-inspection.dto.spec.ts create mode 100644 ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts create mode 100644 ApiCore-Backend/src/jobs/daily-evaluation-steps.ts create mode 100644 ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts create mode 100644 ApiCore-Backend/src/jobs/steps/time-events.step.ts create mode 100644 ApiCore-Backend/src/season/inspection-due.spec.ts create mode 100644 ApiCore-Backend/src/season/inspection-due.ts create mode 100644 ApiCore-Backend/src/season/queen-age-threshold.spec.ts create mode 100644 ApiCore-Backend/src/season/queen-age.spec.ts create mode 100644 ApiCore-Backend/src/season/queen-age.ts create mode 100644 ApiCore-Backend/src/season/season-calendar.spec.ts create mode 100644 ApiCore-Backend/src/season/season-calendar.ts create mode 100644 ApiCore-Backend/src/season/season-segment.spec.ts create mode 100644 ApiCore-Backend/src/season/season-segment.ts create mode 100644 ApiCore-Backend/src/season/season-types.ts create mode 100644 ApiCore-Backend/src/season/season.module.ts create mode 100644 ApiCore-Backend/src/season/season.service.spec.ts create mode 100644 ApiCore-Backend/src/season/season.service.ts create mode 100644 ApiCore-Backend/src/season/varroa-due.spec.ts create mode 100644 ApiCore-Backend/src/season/varroa-due.ts create mode 100644 ApiCore-Backend/src/suggestions/evidence-lookup.spec.ts create mode 100644 ApiCore-Backend/src/suggestions/evidence-lookup.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestion-plan.spec.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestion-plan.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestion-texts.spec.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestion-texts.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestion.types.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestions.module.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestions.service.spec.ts create mode 100644 ApiCore-Backend/src/suggestions/suggestions.service.ts create mode 100644 ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts create mode 100644 ApiCore-Backend/src/suggestions/texts/chain-texts.ts create mode 100644 ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts create mode 100644 ApiCore-Backend/src/suggestions/texts/rule-texts.ts create mode 100644 ApiCore-Backend/test/suggestions.e2e-spec.ts create mode 100644 ApiCore-Backend/test/t4-migrations.e2e-spec.ts create mode 100644 ApiCore-Backend/test/t4-schema.e2e-spec.ts create mode 100644 ApiCore-Backend/test/utils/migration-harness.ts create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/chain-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/inspection-interval-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/month-ranges.ts create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/phenology-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/season-autopilot-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/split-schedule-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/use-rule-draft.ts create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/varroa-threshold-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/water-content-form.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx create mode 100644 ApiCore-Frontend/src/lib/automation/rule-configs.spec.ts create mode 100644 ApiCore-Frontend/src/lib/automation/rule-configs.ts diff --git a/ApiCore-Backend/.gitignore b/ApiCore-Backend/.gitignore index 1d398db..20822d6 100644 --- a/ApiCore-Backend/.gitignore +++ b/ApiCore-Backend/.gitignore @@ -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/ diff --git a/ApiCore-Backend/prisma/migrations/20261014100000_t4_enum_values/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100000_t4_enum_values/migration.sql new file mode 100644 index 0000000..d8b5362 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100000_t4_enum_values/migration.sql @@ -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'; diff --git a/ApiCore-Backend/prisma/migrations/20261014100100_t4_suggestions/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100100_t4_suggestions/migration.sql new file mode 100644 index 0000000..47cafd2 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100100_t4_suggestions/migration.sql @@ -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; + diff --git a/ApiCore-Backend/prisma/migrations/20261014100200_t4_feed_vocabulary/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100200_t4_feed_vocabulary/migration.sql new file mode 100644 index 0000000..2c18d97 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100200_t4_feed_vocabulary/migration.sql @@ -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): '. 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"; diff --git a/ApiCore-Backend/prisma/migrations/20261014100300_t4_split_queen_source/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100300_t4_split_queen_source/migration.sql new file mode 100644 index 0000000..49dd53c --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100300_t4_split_queen_source/migration.sql @@ -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"; diff --git a/ApiCore-Backend/prisma/migrations/20261014100400_t4_varroa_sample_size/migration.sql b/ApiCore-Backend/prisma/migrations/20261014100400_t4_varroa_sample_size/migration.sql new file mode 100644 index 0000000..48e009b --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261014100400_t4_varroa_sample_size/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 4b874dd..d987542 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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,16 +342,23 @@ 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()) + id String @id @default(cuid()) type LineageType fromColonyId String - fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id]) + fromColony Colony @relation("LineageFrom", fields: [fromColonyId], references: [id]) toColonyId String - toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id]) + toColony Colony @relation("LineageTo", fields: [toColonyId], references: [id]) date DateTime + queenSource SplitQueenSource? notes String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([fromColonyId]) @@index([toColonyId]) @@ -447,11 +456,11 @@ model QueenColonyHistory { // --------------------------------------------------------------------------- model Inspection { - id String @id @default(cuid()) + id String @id @default(cuid()) colonyId String - colony Colony @relation(fields: [colonyId], references: [id]) + colony Colony @relation(fields: [colonyId], references: [id]) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) date DateTime weather String? temperature Float? @@ -467,13 +476,13 @@ model Inspection { swarmMood Int? gentleness Int? combBehavior Int? - feedStatus String? + feedStatus ColonyCondition? honeySuperStatus String? healthIssues String? varroaIssues String? measures String? notes String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) tasks Task[] @@ -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? + // '::'; 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 { diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 20ffe7d..ae22fed 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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, diff --git a/ApiCore-Backend/src/auth/permissions/permission-resolver.module.ts b/ApiCore-Backend/src/auth/permissions/permission-resolver.module.ts new file mode 100644 index 0000000..1093a0d --- /dev/null +++ b/ApiCore-Backend/src/auth/permissions/permission-resolver.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/automation/automation.service.spec.ts b/ApiCore-Backend/src/automation/automation.service.spec.ts index 4f46ca6..ce10968 100644 --- a/ApiCore-Backend/src/automation/automation.service.spec.ts +++ b/ApiCore-Backend/src/automation/automation.service.spec.ts @@ -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', () => { diff --git a/ApiCore-Backend/src/automation/automation.service.ts b/ApiCore-Backend/src/automation/automation.service.ts index 5196e2a..06f1f76 100644 --- a/ApiCore-Backend/src/automation/automation.service.ts +++ b/ApiCore-Backend/src/automation/automation.service.ts @@ -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; - actionTemplate: string; - /** Rules without a sensible built-in setting start switched off. */ - active?: boolean; -} - -const DEFAULT_RULES: Record = { - 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,33 +152,58 @@ 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 { - const before = await this.findById(id); - const rule = await this.prisma.automationRule.update({ - where: { id }, - data: { - ...dto, - // null resets the rule to its defaults; a Json column is cleared - // with DbNull, a plain null would fail the write. - config: - dto.config === null - ? Prisma.DbNull - : (dto.config as Prisma.InputJsonValue | undefined), - }, + 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, + // null resets the rule to its defaults; a Json column is cleared + // with DbNull, a plain null would fail the write. + config: + dto.config === null + ? Prisma.DbNull + : (dto.config as Prisma.InputJsonValue | undefined), + }, + }); + await this.auditLog.record( + { + entityType: 'AutomationRule', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: rule, + }, + tx, + ); + return rule; }); - await this.auditLog.record({ - entityType: 'AutomationRule', - entityId: id, - action: 'UPDATE', - userId: actingUserId, - beforeData: before, - afterData: rule, - }); - 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, diff --git a/ApiCore-Backend/src/automation/rule-config/chain.config.ts b/ApiCore-Backend/src/automation/rule-config/chain.config.ts new file mode 100644 index 0000000..6d4fa14 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/chain.config.ts @@ -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(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>( + 'COLONY_ENDED_CLEANUP', + {}, + () => ({}), +); diff --git a/ApiCore-Backend/src/automation/rule-config/default-rules.ts b/ApiCore-Backend/src/automation/rule-config/default-rules.ts new file mode 100644 index 0000000..b50fb5b --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/default-rules.ts @@ -0,0 +1,72 @@ +import type { AutomationTriggerType } from '../../../generated/prisma'; +import { RULE_CONFIG_PARSERS } from './rule-config.registry'; + +export interface RuleDefaults { + config: Record; + 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 = { + 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 = 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 = + Object.fromEntries( + (Object.keys(ACTION_TEMPLATES) as AutomationTriggerType[]).map( + (trigger) => [trigger, defaultsOf(trigger)], + ), + ) as Record; diff --git a/ApiCore-Backend/src/automation/rule-config/index.ts b/ApiCore-Backend/src/automation/rule-config/index.ts new file mode 100644 index 0000000..f804a65 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/index.ts @@ -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'; diff --git a/ApiCore-Backend/src/automation/rule-config/inspection-interval.config.ts b/ApiCore-Backend/src/automation/rule-config/inspection-interval.config.ts new file mode 100644 index 0000000..6c53322 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/inspection-interval.config.ts @@ -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; + +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( + '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 }; + }, +); diff --git a/ApiCore-Backend/src/automation/rule-config/parse-helpers.ts b/ApiCore-Backend/src/automation/rule-config/parse-helpers.ts new file mode 100644 index 0000000..30df025 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/parse-helpers.ts @@ -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>; + readonly path: string; + readonly issues: RuleConfigIssue[]; +} + +const NO_VALUES: Readonly> = Object.freeze({}); + +export function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : 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( + 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( + 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( + 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( + trigger: string, + defaults: T, + read: (scope: Scope) => T, +): RuleConfigParser { + 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; + }, + }; +} diff --git a/ApiCore-Backend/src/automation/rule-config/phenology.config.ts b/ApiCore-Backend/src/automation/rule-config/phenology.config.ts new file mode 100644 index 0000000..a80fecb --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/phenology.config.ts @@ -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( + '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, + ), + }; + }, +); diff --git a/ApiCore-Backend/src/automation/rule-config/rule-config.reader.ts b/ApiCore-Backend/src/automation/rule-config/rule-config.reader.ts new file mode 100644 index 0000000..e3b5922 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/rule-config.reader.ts @@ -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 { + /** 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( + trigger: K, + row: RuleRow | null, +): ActiveRule { + 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( + db: DbClient, + trigger: K, +): Promise> { + 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( + tx: Prisma.TransactionClient, + trigger: K, +): Promise & { 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 }; +} diff --git a/ApiCore-Backend/src/automation/rule-config/rule-config.registry.ts b/ApiCore-Backend/src/automation/rule-config/rule-config.registry.ts new file mode 100644 index 0000000..610586a --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/rule-config.registry.ts @@ -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; +} = { + 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( + 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); +} diff --git a/ApiCore-Backend/src/automation/rule-config/rule-config.spec.ts b/ApiCore-Backend/src/automation/rule-config/rule-config.spec.ts new file mode 100644 index 0000000..c057dd0 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/rule-config.spec.ts @@ -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(''); + } + }); +}); diff --git a/ApiCore-Backend/src/automation/rule-config/rule-config.types.ts b/ApiCore-Backend/src/automation/rule-config/rule-config.types.ts new file mode 100644 index 0000000..2196441 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/rule-config.types.ts @@ -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 { + /** 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>; +} + +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, 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; +} + +export interface PhenologyConfig { + useGts: boolean; + gtsThresholds: Partial>; + calibrateFromReports: boolean; + calibratePhases: PhenologyPhaseKey[]; + calendarFallback: Record; + 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; + 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; +} diff --git a/ApiCore-Backend/src/automation/rule-config/season-autopilot.config.ts b/ApiCore-Backend/src/automation/rule-config/season-autopilot.config.ts new file mode 100644 index 0000000..add8915 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/season-autopilot.config.ts @@ -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( + '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, + }; + }, +); diff --git a/ApiCore-Backend/src/automation/rule-config/simple.config.ts b/ApiCore-Backend/src/automation/rule-config/simple.config.ts new file mode 100644 index 0000000..33d514c --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/simple.config.ts @@ -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 { + return makeParser( + 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> = {}; + 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 { + return makeParser( + trigger, + { format: defaultFormat }, + (scope) => ({ + format: stringIn( + scope, + 'format', + defaultFormat, + 1, + MAX_NUMBER_FORMAT_LENGTH, + ), + }), + ); +} diff --git a/ApiCore-Backend/src/automation/rule-config/split-schedule.config.ts b/ApiCore-Backend/src/automation/rule-config/split-schedule.config.ts new file mode 100644 index 0000000..89a779b --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/split-schedule.config.ts @@ -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( + '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), + }), +); diff --git a/ApiCore-Backend/src/automation/rule-config/varroa-threshold.config.ts b/ApiCore-Backend/src/automation/rule-config/varroa-threshold.config.ts new file mode 100644 index 0000000..bdafa57 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/varroa-threshold.config.ts @@ -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( + '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), + }; + }, +); diff --git a/ApiCore-Backend/src/automation/rule-config/water-content.config.ts b/ApiCore-Backend/src/automation/rule-config/water-content.config.ts new file mode 100644 index 0000000..49a9c00 --- /dev/null +++ b/ApiCore-Backend/src/automation/rule-config/water-content.config.ts @@ -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( + '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), + }; + }, +); diff --git a/ApiCore-Backend/src/automation/varroa-threshold.spec.ts b/ApiCore-Backend/src/automation/varroa-threshold.spec.ts index b8db7eb..808afc2 100644 --- a/ApiCore-Backend/src/automation/varroa-threshold.spec.ts +++ b/ApiCore-Backend/src/automation/varroa-threshold.spec.ts @@ -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 }], diff --git a/ApiCore-Backend/src/automation/varroa-threshold.ts b/ApiCore-Backend/src/automation/varroa-threshold.ts index 2b5bb2f..2299b40 100644 --- a/ApiCore-Backend/src/automation/varroa-threshold.ts +++ b/ApiCore-Backend/src/automation/varroa-threshold.ts @@ -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 | 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).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; } diff --git a/ApiCore-Backend/src/backup/backup.service.spec.ts b/ApiCore-Backend/src/backup/backup.service.spec.ts index 214b7fd..9b5d68a 100644 --- a/ApiCore-Backend/src/backup/backup.service.spec.ts +++ b/ApiCore-Backend/src/backup/backup.service.spec.ts @@ -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 }], + ] + ).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 }], + ] + ).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', () => { diff --git a/ApiCore-Backend/src/backup/backup.service.ts b/ApiCore-Backend/src/backup/backup.service.ts index b1e724c..10d1fa5 100644 --- a/ApiCore-Backend/src/backup/backup.service.ts +++ b/ApiCore-Backend/src/backup/backup.service.ts @@ -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 }, diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts index ae77bf6..6562ec3 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.spec.ts @@ -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' }), diff --git a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts index feb3246..fce9a6f 100644 --- a/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts +++ b/ApiCore-Backend/src/calendar-sync/calendar-sync.service.ts @@ -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; } } diff --git a/ApiCore-Backend/src/common/dates.spec.ts b/ApiCore-Backend/src/common/dates.spec.ts new file mode 100644 index 0000000..df505a4 --- /dev/null +++ b/ApiCore-Backend/src/common/dates.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Backend/src/common/dates.ts b/ApiCore-Backend/src/common/dates.ts new file mode 100644 index 0000000..42232b3 --- /dev/null +++ b/ApiCore-Backend/src/common/dates.ts @@ -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 = {}; + 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; +} diff --git a/ApiCore-Backend/src/common/feed-vocabulary.spec.ts b/ApiCore-Backend/src/common/feed-vocabulary.spec.ts new file mode 100644 index 0000000..f8eed6b --- /dev/null +++ b/ApiCore-Backend/src/common/feed-vocabulary.spec.ts @@ -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); + }); + }); +}); diff --git a/ApiCore-Backend/src/common/feed-vocabulary.ts b/ApiCore-Backend/src/common/feed-vocabulary.ts new file mode 100644 index 0000000..5181763 --- /dev/null +++ b/ApiCore-Backend/src/common/feed-vocabulary.ts @@ -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 = { + EXCELLENT: 'Reichlich', + GOOD: 'Ausreichend', + AVERAGE: 'Mittel', + WEAK: 'Niedrig', + CRITICAL: 'Kritisch', +}; + +export interface LegacyFeedMapping { + feedStatus: ColonyCondition | null; + /** 'Futter (alt): ' for text that maps to no condition, else null. */ + notesSuffix: string | null; +} + +const LEGACY_WORDS: ReadonlyMap = 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; +} diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts index 578a7a3..4002417 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts @@ -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); }); }); }); diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.ts b/ApiCore-Backend/src/dashboard/dashboard.service.ts index b36ac62..24ae32e 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.ts @@ -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`, diff --git a/ApiCore-Backend/src/domain-events/domain-event-types.ts b/ApiCore-Backend/src/domain-events/domain-event-types.ts index 4baa8a0..443cf25 100644 --- a/ApiCore-Backend/src/domain-events/domain-event-types.ts +++ b/ApiCore-Backend/src/domain-events/domain-event-types.ts @@ -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; diff --git a/ApiCore-Backend/src/export/colony-record-card.ts b/ApiCore-Backend/src/export/colony-record-card.ts index cbf3258..f581e17 100644 --- a/ApiCore-Backend/src/export/colony-record-card.ts +++ b/ApiCore-Backend/src/export/colony-record-card.ts @@ -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), ]), diff --git a/ApiCore-Backend/src/import/import.service.spec.ts b/ApiCore-Backend/src/import/import.service.spec.ts index 1248070..7e779d2 100644 --- a/ApiCore-Backend/src/import/import.service.spec.ts +++ b/ApiCore-Backend/src/import/import.service.spec.ts @@ -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' }); diff --git a/ApiCore-Backend/src/import/import.service.ts b/ApiCore-Backend/src/import/import.service.ts index 9a89928..6a5857d 100644 --- a/ApiCore-Backend/src/import/import.service.ts +++ b/ApiCore-Backend/src/import/import.service.ts @@ -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( diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.spec.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.spec.ts new file mode 100644 index 0000000..55040d2 --- /dev/null +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.spec.ts @@ -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'); + }); +}); diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts index 4bc6298..b2afe2e 100644 --- a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts @@ -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() diff --git a/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts new file mode 100644 index 0000000..c2483c6 --- /dev/null +++ b/ApiCore-Backend/src/jobs/daily-evaluation-steps.spec.ts @@ -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, +): { 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), + ); + }); +}); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation-steps.ts b/ApiCore-Backend/src/jobs/daily-evaluation-steps.ts new file mode 100644 index 0000000..80901e1 --- /dev/null +++ b/ApiCore-Backend/src/jobs/daily-evaluation-steps.ts @@ -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; +} + +/** + * 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 }; +} diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts index ef6469e..624d6c7 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.spec.ts @@ -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, RawCall>; - let taskFindMany: jest.Mock; - let transaction: jest.Mock; - let recordAll: jest.Mock< - Promise, - [Prisma.TransactionClient, readonly AnyNewDomainEvent[]] - >; + const COUNTS = { + WithdrawalEnded: 1, + TaskDue: 1, + QueenAged: 0, + VarroaMeasurementDue: 0, + InspectionOverdue: 0, + }; + let timeEvents: { name: 'time-events'; run: jest.Mock }; let job: DailyEvaluationJob; beforeEach(() => { - queryRaw = jest.fn, 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(); - 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); + 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); }); - afterEach(() => jest.restoreAllMocks()); + afterEach(() => { + jest.useRealTimers(); + 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({ - WithdrawalEnded: 1, - TaskDue: 1, - QueenAged: 0, - VarroaMeasurementDue: 0, - InspectionOverdue: 0, - }); - expect(queryRaw.mock.calls[0][0].join('?')).toContain('SELECT now()'); + 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('writes each type in its own transaction, with its daily dedupe keys', async () => { + 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(); + 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(await job.run()).toEqual({ - WithdrawalEnded: 0, - TaskDue: 0, - QueenAged: 0, - VarroaMeasurementDue: 0, - InspectionOverdue: 0, + 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', }); }); - - 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', - ); - - expect(recordAll).toHaveBeenCalledWith(tx, [ - expect.objectContaining({ type: 'WithdrawalEnded' }), - ]); - expect(logged).toHaveBeenCalledWith( - 'Rule "TaskDue" failed: connection reset', - expect.any(String), - ); - }); }); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts index 8bdeed6..6da6a61 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.job.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.job.ts @@ -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 { - 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 { - 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 { - 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; } } diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts index 9d17967..a0b846a 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.spec.ts @@ -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; } 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 }) => + 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 { 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, 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]); + it('fires on the job day, once per queen and year under the January 1 key', async () => { + const { db } = fakeDb({ + 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 } }, + ], + }); - // 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', - ]); + expect(await eventsOn(db, '2026-09-09')).toEqual([ + { + type: 'QueenAged', + aggregateType: 'Queen', + 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 }) => + 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', + ]); + }); }); - it('reports a queen once a year on January 1 from her second year after hatching', 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 }, - ], + 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', + }, + ]); }); - const store = await runDaily(db, daysBetween('2027-12-20', '2029-01-20')); + 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') }], + }); - expect([...store.values()]).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', - }, - ]); + 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 } }, - }), - ); }); }); diff --git a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts index 22b98a1..0573042 100644 --- a/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts +++ b/ApiCore-Backend/src/jobs/daily-evaluation.rules.ts @@ -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 = export interface DailyOccurrence { day: string; event: AnyNewDomainEvent; + /** + * 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 { readonly type: T; - occurrences(db: PrismaService, today: Date): Promise[]>; + occurrences( + db: PrismaService, + today: Date, + ctx: DailyRuleContext, + ): Promise[]>; } /** 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: { - status: 'ACTIVE', - hatchYear: { lte: year - QUEEN_AGED_MIN_YEARS }, + ...LIVING_COLONY_WHERE, + currentQueen: { + status: 'ACTIVE', + 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 { + 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, - intervalDays: number, - today: Date, -): Promise { - const colonies = await db.colony.findMany({ - where: LIVING_COLONY_WHERE, - select: { id: true, createdAt: true }, - }); - return colonies.flatMap(({ id, createdAt }) => { - const last = lastRecordOn.get(id) ?? null; - const day = latestCycleOccurrence(last ?? createdAt, intervalDays, today); - if (day === null || !inActiveSeason(day)) { - return []; - } - return [{ colonyId: id, day, lastOn: last === null ? null : utcDay(last) }]; - }); -} - -function latestByColony( - rows: { colonyId: string; _max: { date: Date | null } }[], -): ReadonlyMap { - 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'> = { +const varroaDue: DailyRule<'VarroaMeasurementDue'> = { type: 'VarroaMeasurementDue', - async occurrences(db, today) { - const latest = await db.varroaMeasurement.groupBy({ - by: ['colonyId'], + async occurrences(db, _today, { seasons, day }) { + const colonies = await livingColonies(db); + const latest = await db.varroaMeasurement.findMany({ where: { colony: LIVING_COLONY_WHERE }, - _max: { date: true }, + distinct: ['colonyId'], + orderBy: [{ colonyId: 'asc' }, { date: 'desc' }, { id: 'desc' }], + select: { id: true, colonyId: true, date: true }, }); - const due = await colonyCyclesDue( + const { config } = await readRule(db, 'SEASON_AUTOPILOT'); + const states = await seasons.statesFor( db, - latestByColony(latest), - VARROA_MEASUREMENT_INTERVAL_DAYS, - today, - ); - return due.map(({ colonyId, day, lastOn }) => ({ + colonies.map((colony) => colony.apiaryId), day, - event: { - type: 'VarroaMeasurementDue', - aggregateType: 'Colony', - aggregateId: colonyId, - payload: { dueOn: day, lastMeasuredOn: lastOn }, - }, - })); + ); + 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 [ + { + day: due.dueOn, + event: { + type: 'VarroaMeasurementDue', + aggregateType: 'Colony', + 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, - event: { - type: 'InspectionOverdue', - aggregateType: 'Colony', - aggregateId: colonyId, - payload: { dueOn: day, lastInspectedOn: lastOn }, - }, - })); + ); + 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: 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, ]; diff --git a/ApiCore-Backend/src/jobs/jobs.module.ts b/ApiCore-Backend/src/jobs/jobs.module.ts index 60c510b..3f3b47e 100644 --- a/ApiCore-Backend/src/jobs/jobs.module.ts +++ b/ApiCore-Backend/src/jobs/jobs.module.ts @@ -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, ], diff --git a/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts b/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts new file mode 100644 index 0000000..91fe764 --- /dev/null +++ b/ApiCore-Backend/src/jobs/steps/time-events.step.spec.ts @@ -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, + [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(); + 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'); + }); +}); diff --git a/ApiCore-Backend/src/jobs/steps/time-events.step.ts b/ApiCore-Backend/src/jobs/steps/time-events.step.ts new file mode 100644 index 0000000..ca4e655 --- /dev/null +++ b/ApiCore-Backend/src/jobs/steps/time-events.step.ts @@ -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 { + 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 { + 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)); + } +} diff --git a/ApiCore-Backend/src/prisma/row-lock.spec.ts b/ApiCore-Backend/src/prisma/row-lock.spec.ts index 8d4ae47..ba04f6e 100644 --- a/ApiCore-Backend/src/prisma/row-lock.spec.ts +++ b/ApiCore-Backend/src/prisma/row-lock.spec.ts @@ -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', + ]); + }); +}); diff --git a/ApiCore-Backend/src/prisma/row-lock.ts b/ApiCore-Backend/src/prisma/row-lock.ts index a2078f9..84333b5 100644 --- a/ApiCore-Backend/src/prisma/row-lock.ts +++ b/ApiCore-Backend/src/prisma/row-lock.ts @@ -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 { 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( + 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 { 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 { 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`, ); } diff --git a/ApiCore-Backend/src/season/inspection-due.spec.ts b/ApiCore-Backend/src/season/inspection-due.spec.ts new file mode 100644 index 0000000..a6ca973 --- /dev/null +++ b/ApiCore-Backend/src/season/inspection-due.spec.ts @@ -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 })); +}); diff --git a/ApiCore-Backend/src/season/inspection-due.ts b/ApiCore-Backend/src/season/inspection-due.ts new file mode 100644 index 0000000..5895612 --- /dev/null +++ b/ApiCore-Backend/src/season/inspection-due.ts @@ -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, + 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, + }; +} diff --git a/ApiCore-Backend/src/season/queen-age-threshold.spec.ts b/ApiCore-Backend/src/season/queen-age-threshold.spec.ts new file mode 100644 index 0000000..97fd3df --- /dev/null +++ b/ApiCore-Backend/src/season/queen-age-threshold.spec.ts @@ -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([])); +}); diff --git a/ApiCore-Backend/src/season/queen-age.spec.ts b/ApiCore-Backend/src/season/queen-age.spec.ts new file mode 100644 index 0000000..f7829ad --- /dev/null +++ b/ApiCore-Backend/src/season/queen-age.spec.ts @@ -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)); +}); diff --git a/ApiCore-Backend/src/season/queen-age.ts b/ApiCore-Backend/src/season/queen-age.ts new file mode 100644 index 0000000..228bc7a --- /dev/null +++ b/ApiCore-Backend/src/season/queen-age.ts @@ -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; +} diff --git a/ApiCore-Backend/src/season/season-calendar.spec.ts b/ApiCore-Backend/src/season/season-calendar.spec.ts new file mode 100644 index 0000000..f9ccdf4 --- /dev/null +++ b/ApiCore-Backend/src/season/season-calendar.spec.ts @@ -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, + }); + }); +}); diff --git a/ApiCore-Backend/src/season/season-calendar.ts b/ApiCore-Backend/src/season/season-calendar.ts new file mode 100644 index 0000000..220f4a0 --- /dev/null +++ b/ApiCore-Backend/src/season/season-calendar.ts @@ -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; + +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, + }; +} diff --git a/ApiCore-Backend/src/season/season-segment.spec.ts b/ApiCore-Backend/src/season/season-segment.spec.ts new file mode 100644 index 0000000..80f062e --- /dev/null +++ b/ApiCore-Backend/src/season/season-segment.spec.ts @@ -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', + ); + }); +}); diff --git a/ApiCore-Backend/src/season/season-segment.ts b/ApiCore-Backend/src/season/season-segment.ts new file mode 100644 index 0000000..9c27f93 --- /dev/null +++ b/ApiCore-Backend/src/season/season-segment.ts @@ -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'; + } + } +} diff --git a/ApiCore-Backend/src/season/season-types.ts b/ApiCore-Backend/src/season/season-types.ts new file mode 100644 index 0000000..694b2a5 --- /dev/null +++ b/ApiCore-Backend/src/season/season-types.ts @@ -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; +} diff --git a/ApiCore-Backend/src/season/season.module.ts b/ApiCore-Backend/src/season/season.module.ts new file mode 100644 index 0000000..eb22219 --- /dev/null +++ b/ApiCore-Backend/src/season/season.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SeasonService } from './season.service'; + +@Module({ + providers: [SeasonService], + exports: [SeasonService], +}) +export class SeasonModule {} diff --git a/ApiCore-Backend/src/season/season.service.spec.ts b/ApiCore-Backend/src/season/season.service.spec.ts new file mode 100644 index 0000000..2d994f6 --- /dev/null +++ b/ApiCore-Backend/src/season/season.service.spec.ts @@ -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', + }); + }); +}); diff --git a/ApiCore-Backend/src/season/season.service.ts b/ApiCore-Backend/src/season/season.service.ts new file mode 100644 index 0000000..8db9949 --- /dev/null +++ b/ApiCore-Backend/src/season/season.service.ts @@ -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> { + const states = new Map(); + 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; + } +} diff --git a/ApiCore-Backend/src/season/varroa-due.spec.ts b/ApiCore-Backend/src/season/varroa-due.spec.ts new file mode 100644 index 0000000..2d0132e --- /dev/null +++ b/ApiCore-Backend/src/season/varroa-due.spec.ts @@ -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' })); +}); diff --git a/ApiCore-Backend/src/season/varroa-due.ts b/ApiCore-Backend/src/season/varroa-due.ts new file mode 100644 index 0000000..e814879 --- /dev/null +++ b/ApiCore-Backend/src/season/varroa-due.ts @@ -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, + 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}`, + }; +} diff --git a/ApiCore-Backend/src/stock/stock-ledger.spec.ts b/ApiCore-Backend/src/stock/stock-ledger.spec.ts index 597026c..4a383d1 100644 --- a/ApiCore-Backend/src/stock/stock-ledger.spec.ts +++ b/ApiCore-Backend/src/stock/stock-ledger.spec.ts @@ -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 }, diff --git a/ApiCore-Backend/src/stock/stock-ledger.ts b/ApiCore-Backend/src/stock/stock-ledger.ts index f57b325..9376383 100644 --- a/ApiCore-Backend/src/stock/stock-ledger.ts +++ b/ApiCore-Backend/src/stock/stock-ledger.ts @@ -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; } diff --git a/ApiCore-Backend/src/suggestions/evidence-lookup.spec.ts b/ApiCore-Backend/src/suggestions/evidence-lookup.spec.ts new file mode 100644 index 0000000..73ebfa0 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/evidence-lookup.spec.ts @@ -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(); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/evidence-lookup.ts b/ApiCore-Backend/src/suggestions/evidence-lookup.ts new file mode 100644 index 0000000..38ccadc --- /dev/null +++ b/ApiCore-Backend/src/suggestions/evidence-lookup.ts @@ -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; + +function idsOf( + evidences: readonly SuggestionEvidence[], + keys: readonly LookupKey[], +): string[] { + const ids = new Set(); + 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 { + 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 { + 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, + }; +} diff --git a/ApiCore-Backend/src/suggestions/suggestion-plan.spec.ts b/ApiCore-Backend/src/suggestions/suggestion-plan.spec.ts new file mode 100644 index 0000000..c289b90 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion-plan.spec.ts @@ -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'); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/suggestion-plan.ts b/ApiCore-Backend/src/suggestions/suggestion-plan.ts new file mode 100644 index 0000000..e5b55d1 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion-plan.ts @@ -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; + 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, + 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, + }; + }); +} diff --git a/ApiCore-Backend/src/suggestions/suggestion-texts.spec.ts b/ApiCore-Backend/src/suggestions/suggestion-texts.spec.ts new file mode 100644 index 0000000..549b408 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion-texts.spec.ts @@ -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(); + } + }); +}); diff --git a/ApiCore-Backend/src/suggestions/suggestion-texts.ts b/ApiCore-Backend/src/suggestions/suggestion-texts.ts new file mode 100644 index 0000000..adf54a7 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion-texts.ts @@ -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 +>; + +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 = {}; + 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> = + CHAIN_PLAN_STEP_TITLES; + +/** Title of a suggestion whose reason code has no template. */ +export const INTENT_TITLES: Readonly> = { + 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, + 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(); + +/** 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); +} diff --git a/ApiCore-Backend/src/suggestions/suggestion.types.ts b/ApiCore-Backend/src/suggestions/suggestion.types.ts new file mode 100644 index 0000000..69895a3 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestion.types.ts @@ -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>; + +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> +> = { + 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', +}; + +/** `::`, 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 }; diff --git a/ApiCore-Backend/src/suggestions/suggestions.module.ts b/ApiCore-Backend/src/suggestions/suggestions.module.ts new file mode 100644 index 0000000..345c2a8 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestions.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts b/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts new file mode 100644 index 0000000..c26515c --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestions.service.spec.ts @@ -0,0 +1,1049 @@ +import { BadRequestException, ConflictException, Logger } from '@nestjs/common'; +import { lockRuleIn, readRule } from '../automation/rule-config'; +import type { AuditLogService } from '../audit/audit-log.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import { + lockRowForUpdate, + lockRowsForShare, + lockRowsForUpdate, +} from '../prisma/row-lock'; +import type { TasksService } from '../tasks/tasks.service'; +import { SuggestionsService } from './suggestions.service'; +import type { ProposeInput } from './suggestion.types'; + +jest.mock('../prisma/row-lock'); +jest.mock('../automation/rule-config'); + +const lockRow = jest.mocked(lockRowForUpdate); +const lockRows = jest.mocked(lockRowsForUpdate); +const lockShare = jest.mocked(lockRowsForShare); +const lockRule = jest.mocked(lockRuleIn); +const readRuleMock = jest.mocked(readRule); + +const PLAN = [ + { step: 'RAISE_CELLS_CHECK', offsetDays: 9 }, + { step: 'MATING_CHECK', offsetDays: 21, windowDays: 3 }, + { step: 'EGGS_CHECK', offsetDays: 28 }, +]; + +function suggestionRow(overrides: Record = {}) { + return { + id: 'sug-1', + ruleKey: 'SPLIT_SCHEDULE', + source: 'CHAIN', + mode: 'PLAN', + intent: 'SPLIT_PLAN', + status: 'OPEN', + priority: 'NORMAL', + relatedType: 'COLONY', + relatedId: 'col-1', + colonyId: 'col-1', + reasonCode: 'SPLIT_RAISE_OWN', + evidence: { baseDate: '2026-05-01' }, + requiredPermission: 'colonies:read', + dueDate: null, + expiresAt: null, + plan: PLAN, + dedupeKey: 'SPLIT_SCHEDULE:col-1:anchor', + decidedAt: null, + decidedByUserId: null, + decisionNote: null, + ...overrides, + }; +} + +const proposal: ProposeInput = { + ruleKey: 'INSPECTION_INTERVAL', + source: 'AUTOPILOT', + mode: 'DO', + intent: 'INSPECT', + relatedType: 'COLONY', + relatedId: 'col-1', + colonyId: 'col-1', + reasonCode: 'INSPECT_DUE', + evidence: { colonyId: 'col-1' }, + requiredPermission: 'inspections:read', + dueDate: '2026-05-10', + anchor: 'insp-9', +}; + +/** The `arg`-th argument of the `call`-th call of a mock. */ +function argOf(mock: jest.Mock, call: number, arg: number): T { + const calls = mock.mock.calls as unknown[][]; + return calls[call][arg] as T; +} + +function sqlOf(call: unknown[]): string { + return (call[0] as readonly string[]).join('?'); +} + +describe('SuggestionsService', () => { + let service: SuggestionsService; + let tx: { + $executeRaw: jest.Mock; + suggestion: { + createManyAndReturn: jest.Mock; + findMany: jest.Mock; + findUnique: jest.Mock; + findUniqueOrThrow: jest.Mock; + updateMany: jest.Mock; + }; + task: { findMany: jest.Mock }; + inspection: { count: jest.Mock }; + user: { findUnique: jest.Mock }; + colony: { findUnique: jest.Mock }; + }; + let prisma: { $connect: jest.Mock; $transaction: jest.Mock } & { + $executeRaw: jest.Mock; + }; + let audit: { record: jest.Mock }; + let tasks: { createInTx: jest.Mock }; + let callOrder: string[]; + + beforeEach(() => { + jest.resetAllMocks(); + callOrder = []; + tx = { + $executeRaw: jest.fn().mockResolvedValue(1), + suggestion: { + createManyAndReturn: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + task: { findMany: jest.fn().mockResolvedValue([]) }, + inspection: { count: jest.fn().mockResolvedValue(1) }, + user: { + findUnique: jest.fn().mockResolvedValue({ isActive: true }), + }, + colony: { + findUnique: jest.fn().mockResolvedValue({ status: 'ACTIVE' }), + }, + }; + prisma = { + $connect: jest.fn(), + $executeRaw: jest.fn().mockResolvedValue(0), + $transaction: jest.fn((work: (client: typeof tx) => Promise) => + work(tx), + ), + }; + audit = { record: jest.fn().mockResolvedValue(undefined) }; + let taskCounter = 0; + tasks = { + createInTx: jest.fn(() => { + taskCounter += 1; + return Promise.resolve({ id: `task-${taskCounter}` }); + }), + }; + lockRow.mockImplementation((_tx, table) => { + callOrder.push(`row:${table}`); + return Promise.resolve(true); + }); + lockRows.mockResolvedValue(undefined); + lockRule.mockImplementation((_tx, trigger) => { + callOrder.push(`rule:${trigger}`); + return Promise.resolve({ + id: 'rule-1', + active: true, + config: {}, + actionTemplate: '', + } as never); + }); + readRuleMock.mockResolvedValue({ + id: null, + active: true, + config: {}, + actionTemplate: '', + }); + service = new SuggestionsService( + prisma as unknown as PrismaService, + audit as unknown as AuditLogService, + tasks as unknown as TasksService, + ); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('proposeIn', () => { + it('locks the colony row, then the rule row, and writes one suggestion with an audit entry', async () => { + const created = suggestionRow({ id: 'sug-new' }); + tx.suggestion.createManyAndReturn.mockResolvedValue([created]); + + await expect(service.proposeIn(tx as never, proposal)).resolves.toBe( + created, + ); + + expect(callOrder).toEqual(['row:Colony', 'rule:INSPECTION_INTERVAL']); + expect(lockRow).toHaveBeenCalledWith(tx, 'Colony', 'col-1'); + const args = argOf<{ + data: Record[]; + skipDuplicates: boolean; + }>(tx.suggestion.createManyAndReturn, 0, 0); + expect(args.skipDuplicates).toBe(true); + expect(args.data).toHaveLength(1); + expect(args.data[0]).toMatchObject({ + ruleKey: 'INSPECTION_INTERVAL', + intent: 'INSPECT', + priority: 'NORMAL', + dedupeKey: 'INSPECTION_INTERVAL:col-1:insp-9', + requiredPermission: 'inspections:read', + }); + expect((args.data[0].dueDate as Date).toISOString()).toBe( + '2026-05-10T00:00:00.000Z', + ); + expect(audit.record).toHaveBeenCalledWith( + { + entityType: 'Suggestion', + entityId: 'sug-new', + action: 'CREATE', + afterData: created, + }, + tx, + ); + }); + + it('does not lock a colony when the suggestion has none', async () => { + tx.suggestion.createManyAndReturn.mockResolvedValue([suggestionRow()]); + + await service.proposeIn(tx as never, { + ...proposal, + colonyId: null, + relatedType: 'STORAGE', + relatedId: 'stock-1', + }); + + expect(callOrder).toEqual(['rule:INSPECTION_INTERVAL']); + }); + + it('with an existing dedupe key creates nothing', async () => { + tx.suggestion.createManyAndReturn.mockResolvedValue([]); + + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.toBeNull(); + + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('skips when an open task of the same intent exists (via sourceSuggestion or legacy trigger)', async () => { + tx.task.findMany.mockResolvedValue([ + { + sourceAutomationTrigger: null, + sourceSuggestion: { intent: 'INSPECT' }, + }, + ]); + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.toBeNull(); + + tx.task.findMany.mockResolvedValue([ + { sourceAutomationTrigger: 'VARROA_THRESHOLD', sourceSuggestion: null }, + ]); + await expect( + service.proposeIn(tx as never, { + ...proposal, + intent: 'VARROA_TREAT', + }), + ).resolves.toBeNull(); + + expect(tx.suggestion.createManyAndReturn).not.toHaveBeenCalled(); + expect(argOf(tx.task.findMany, 0, 0)).toMatchObject({ + where: { status: { in: ['OPEN', 'IN_PROGRESS'] } }, + }); + }); + + it('counts an open task without a source as a follow-up', async () => { + tx.task.findMany.mockResolvedValue([ + { sourceAutomationTrigger: null, sourceSuggestion: null }, + ]); + + await expect( + service.proposeIn(tx as never, { ...proposal, intent: 'FOLLOW_UP' }), + ).resolves.toBeNull(); + }); + + it('skips when an open suggestion of the same intent exists for the colony', async () => { + tx.suggestion.findMany.mockResolvedValue([{ intent: 'INSPECT' }]); + + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.toBeNull(); + + expect(tx.suggestion.findMany).toHaveBeenCalledWith({ + where: { status: 'OPEN', colonyId: 'col-1' }, + select: { intent: true }, + }); + expect(tx.suggestion.createManyAndReturn).not.toHaveBeenCalled(); + }); + + it('proposes when only other intents are open', async () => { + tx.suggestion.findMany.mockResolvedValue([{ intent: 'FEED' }]); + tx.task.findMany.mockResolvedValue([ + { sourceAutomationTrigger: 'SWARM_MOOD_HIGH', sourceSuggestion: null }, + ]); + tx.suggestion.createManyAndReturn.mockResolvedValue([suggestionRow()]); + + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.not.toBeNull(); + }); + + it('rejects the root client', async () => { + await expect( + service.proposeIn(prisma as never, proposal), + ).rejects.toThrow('needs the transaction client'); + }); + }); + + describe('openIntentsIn', () => { + it('matches by related object when there is no colony', async () => { + tx.suggestion.findMany.mockResolvedValue([{ intent: 'STOCK_REORDER' }]); + + const intents = await service.openIntentsIn(tx as never, { + colonyId: null, + relatedType: 'STORAGE', + relatedId: 'stock-1', + }); + + expect([...intents]).toEqual(['STOCK_REORDER']); + expect(tx.suggestion.findMany).toHaveBeenCalledWith({ + where: { status: 'OPEN', relatedType: 'STORAGE', relatedId: 'stock-1' }, + select: { intent: true }, + }); + }); + }); + + describe('accept', () => { + it('creates one task per plan step through createInTx with sourceSuggestionId', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + const result = await service.accept('sug-1', {}, 'user-1'); + + expect(result.status).toBe('ACCEPTED'); + expect(result.tasks).toHaveLength(3); + const calls = tasks.createInTx.mock.calls as [ + unknown, + Record & { dueDate?: Date }, + string, + ][]; + expect( + calls.map((c) => c[1].dueDate?.toISOString().slice(0, 10)), + ).toEqual(['2026-05-10', '2026-05-22', '2026-05-29']); + expect(calls[1][1].description).toContain('bis 25.05.'); + expect(calls[0][1].description).toBeNull(); + expect(calls.map((c) => c[1].title)).toEqual([ + 'Weiselzellen kontrollieren', + 'Begattung kontrollieren', + 'Eier und Stifte kontrollieren', + ]); + for (const [client, input, actingUserId] of calls) { + expect(client).toBe(tx); + expect(actingUserId).toBe('user-1'); + expect(input).toMatchObject({ + sourceSuggestionId: 'sug-1', + sourceAutomationTrigger: 'SPLIT_SCHEDULE', + relatedType: 'COLONY', + relatedId: 'col-1', + }); + } + }); + + it('locks the suggestion row and reads it inside the transaction', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', {}, 'user-1'); + + expect(lockRow).toHaveBeenCalledWith(tx, 'Suggestion', 'sug-1'); + expect(callOrder[0]).toBe('row:Suggestion'); + expect(callOrder).not.toContain('row:Colony'); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('records the audit entry with before and after data', async () => { + const before = suggestionRow(); + const after = suggestionRow({ status: 'ACCEPTED' }); + tx.suggestion.findUnique.mockResolvedValue(before); + tx.suggestion.findUniqueOrThrow.mockResolvedValue(after); + + await service.accept('sug-1', {}, 'user-1'); + + expect(audit.record).toHaveBeenCalledWith( + { + entityType: 'Suggestion', + entityId: 'sug-1', + action: 'UPDATE', + userId: 'user-1', + beforeData: before, + afterData: after, + }, + tx, + ); + }); + + it('with a new date shifts every plan step by the same distance', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', { dueDate: '2026-05-12' }, 'user-1'); + + const days = ( + tasks.createInTx.mock.calls as [unknown, { dueDate: Date }][] + ).map((c) => c[1].dueDate.toISOString().slice(0, 10)); + expect(days).toEqual(['2026-05-12', '2026-05-24', '2026-05-31']); + }); + + it('hands the chosen assignee to every task', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', { assignedUserId: 'user-7' }, 'user-1'); + + for (const call of tasks.createInTx.mock.calls as [ + unknown, + { assignedUserId: string }, + ][]) { + expect(call[1].assignedUserId).toBe('user-7'); + } + }); + + it('creates one task for a DO suggestion with its own date and the rule title', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ + mode: 'DO', + plan: null, + ruleKey: 'VARROA_THRESHOLD', + intent: 'VARROA_TREAT', + reasonCode: 'UNKNOWN_CODE', + dueDate: new Date('2026-06-01T00:00:00.000Z'), + evidence: { inspectionId: 'insp-1' }, + }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + readRuleMock.mockResolvedValue({ + id: 'rule-1', + active: true, + config: {}, + actionTemplate: 'Behandlung prüfen', + }); + + await service.accept('sug-1', {}, 'user-1'); + + expect(tasks.createInTx).toHaveBeenCalledTimes(1); + const input = argOf>(tasks.createInTx, 0, 1); + expect(input).toMatchObject({ + title: 'Behandlung prüfen', + sourceInspectionId: 'insp-1', + }); + expect((input.dueDate as Date).toISOString()).toBe( + '2026-06-01T00:00:00.000Z', + ); + }); + + it('leaves out an inspection that no longer exists', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ + plan: null, + evidence: { inspectionId: 'gone' }, + dueDate: new Date('2026-06-01T00:00:00.000Z'), + }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + tx.inspection.count.mockResolvedValue(0); + + await service.accept('sug-1', {}, 'user-1'); + + expect( + argOf<{ sourceInspectionId: unknown }>(tasks.createInTx, 0, 1) + .sourceInspectionId, + ).toBeNull(); + }); + + it('of a DO suggestion without a date answers 400 "Bitte ein Datum wählen."', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ mode: 'DO', plan: null, dueDate: null }), + ); + + const attempt = service.accept('sug-1', {}, 'user-1'); + + await expect(attempt).rejects.toThrow(BadRequestException); + await expect(attempt).rejects.toThrow('Bitte ein Datum wählen.'); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('answers 409 with the current status when the suggestion is not open', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'DISMISSED' }), + ); + + const attempt = service.accept('sug-1', {}, 'user-1'); + + await expect(attempt).rejects.toThrow(ConflictException); + await expect(attempt).rejects.toMatchObject({ + response: { + message: 'Der Vorschlag ist nicht mehr offen.', + status: 'DISMISSED', + }, + }); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('answers 409 with the current status when the CAS finds no OPEN row', async () => { + tx.suggestion.findUnique + .mockResolvedValueOnce(suggestionRow()) + .mockResolvedValueOnce({ status: 'ACCEPTED' }); + tx.$executeRaw.mockResolvedValue(0); + + await expect(service.accept('sug-1', {}, 'user-1')).rejects.toMatchObject( + { response: { status: 'ACCEPTED' } }, + ); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('moves OPEN to ACCEPTED in one statement against now()', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', {}, 'user-1'); + + expect(tx.$executeRaw).toHaveBeenCalledTimes(1); + const sql = sqlOf(tx.$executeRaw.mock.calls[0] as unknown[]); + expect(sql).toContain(`status = 'ACCEPTED'`); + expect(sql).toContain('"decidedAt" = now()'); + expect(sql).toContain(`status = 'OPEN'`); + expect(sql).not.toContain('"dedupeKey"'); + }); + + it('of a PLAN suggestion without plan and without a date creates a task due today (Berlin)', async () => { + // 00:30 on 2026-05-05 in Berlin, still the 4th in UTC. + jest.useFakeTimers({ now: new Date('2026-05-04T22:30:00.000Z') }); + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ plan: null, dueDate: null }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', {}, 'user-1'); + + expect(tasks.createInTx).toHaveBeenCalledTimes(1); + expect( + argOf<{ dueDate: Date }>(tasks.createInTx, 0, 1).dueDate.toISOString(), + ).toBe('2026-05-05T00:00:00.000Z'); + }); + + it('rejects an invalid day with 400', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + + await expect( + service.accept('sug-1', { dueDate: '2026-02-30' }, 'user-1'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('bulkAccept', () => { + it('locks all rows in one statement and accepts each in request order', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ plan: null, dueDate: new Date('2026-06-01') }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + const results = await service.bulkAccept(['b', 'a'], 'user-1'); + + expect(lockRows).toHaveBeenCalledWith(tx, 'Suggestion', ['b', 'a']); + expect(results).toHaveLength(2); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('is all or nothing', async () => { + tx.suggestion.findUnique + .mockResolvedValueOnce( + suggestionRow({ plan: null, dueDate: new Date('2026-06-01') }), + ) + .mockResolvedValueOnce(suggestionRow({ status: 'DISMISSED' })); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await expect(service.bulkAccept(['a', 'b'], 'user-1')).rejects.toThrow( + ConflictException, + ); + // One transaction around everything: the failure rolls the first back. + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('accepts 1 to 50 distinct ids only', async () => { + await expect(service.bulkAccept([], 'user-1')).rejects.toThrow( + BadRequestException, + ); + await expect( + service.bulkAccept( + Array.from({ length: 51 }, (_, index) => `s-${index}`), + 'user-1', + ), + ).rejects.toThrow(BadRequestException); + await expect(service.bulkAccept(['a', 'a'], 'user-1')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('dismiss', () => { + it('moves OPEN to DISMISSED with the note, keeps the dedupe key and audits', async () => { + const before = suggestionRow(); + const after = suggestionRow({ status: 'DISMISSED' }); + tx.suggestion.findUnique.mockResolvedValue(before); + tx.suggestion.findUniqueOrThrow.mockResolvedValue(after); + + await expect( + service.dismiss('sug-1', 'passt nicht', 'user-1'), + ).resolves.toBe(after); + + const sql = sqlOf(tx.$executeRaw.mock.calls[0] as unknown[]); + expect(sql).toContain(`status = 'DISMISSED'`); + expect(sql).not.toContain('"dedupeKey"'); + expect(lockRow).toHaveBeenCalledWith(tx, 'Suggestion', 'sug-1'); + expect(audit.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Suggestion', + beforeData: before, + afterData: after, + }), + tx, + ); + }); + + it('answers 409 when it is not open and 400 when the note is too long', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + await expect(service.dismiss('sug-1', null, 'user-1')).rejects.toThrow( + ConflictException, + ); + await expect( + service.dismiss('sug-1', 'x'.repeat(501), 'user-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('bulkDismiss counts the dismissed suggestions, all or nothing', async () => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'DISMISSED' }), + ); + + await expect(service.bulkDismiss(['a', 'b'], 'user-1')).resolves.toBe(2); + expect(lockRows).toHaveBeenCalledWith(tx, 'Suggestion', ['a', 'b']); + }); + }); + + describe('reopen', () => { + it('within 30 days restores OPEN', async () => { + const after = suggestionRow({ status: 'OPEN' }); + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'DISMISSED' }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue(after); + + await expect(service.reopen('sug-1', 'user-1')).resolves.toBe(after); + + const sql = sqlOf(tx.$executeRaw.mock.calls[0] as unknown[]); + expect(sql).toContain(`status = 'OPEN'`); + expect(sql).toContain(`status = 'DISMISSED'`); + expect(sql).toContain(`"decidedAt" > now() - interval '30 days'`); + expect(sql).not.toContain('"dedupeKey"'); + }); + + it('after 30 days it answers 409', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'DISMISSED' }), + ); + tx.$executeRaw.mockResolvedValue(0); + + await expect(service.reopen('sug-1', 'user-1')).rejects.toThrow( + ConflictException, + ); + expect(audit.record).not.toHaveBeenCalled(); + }); + + it('answers 409 with the status when the suggestion was not dismissed', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await expect(service.reopen('sug-1', 'user-1')).rejects.toMatchObject({ + response: { status: 'ACCEPTED' }, + }); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + }); + }); + + describe('reopen guards', () => { + it('answers 409 when an open suggestion of the same intent exists', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'DISMISSED' }), + ); + tx.suggestion.findMany.mockResolvedValue([{ intent: 'SPLIT_PLAN' }]); + + const attempt = service.reopen('sug-1', 'user-1'); + + await expect(attempt).rejects.toThrow(ConflictException); + await expect(attempt).rejects.toThrow( + 'Für dieses Volk ist schon ein Vorschlag „Ableger kontrollieren“ offen.', + ); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + }); + + it('answers 409 when an open task of the same intent exists', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: 'DISMISSED' }), + ); + tx.task.findMany.mockResolvedValue([ + { + sourceAutomationTrigger: null, + sourceSuggestion: { intent: 'SPLIT_PLAN' }, + }, + ]); + + await expect(service.reopen('sug-1', 'user-1')).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('assignee check', () => { + it.each([ + ['unknown', null], + ['inactive', { isActive: false }], + ])('accept answers 400 for an %s assignee', async (_label, user) => { + tx.suggestion.findUnique.mockResolvedValue(suggestionRow()); + tx.user.findUnique.mockResolvedValue(user); + + const attempt = service.accept( + 'sug-1', + { assignedUserId: 'user-9' }, + 'user-1', + ); + + await expect(attempt).rejects.toThrow(BadRequestException); + await expect(attempt).rejects.toMatchObject({ + response: { + issues: [ + { field: 'assignedUserId', message: expect.any(String) as string }, + ], + }, + }); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + + it('bulkAccept needs no assignee and checks none', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ plan: null, dueDate: new Date('2026-06-01') }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.bulkAccept(['a'], 'user-1'); + + expect(tx.user.findUnique).not.toHaveBeenCalled(); + }); + }); + + describe('fix round 2', () => { + it('proposeIn returns null when the colony row does not exist', async () => { + lockRow.mockResolvedValue(false); + + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.toBeNull(); + + expect(tx.suggestion.createManyAndReturn).not.toHaveBeenCalled(); + }); + + it('reads open tasks only after the open suggestions', async () => { + const order: string[] = []; + tx.suggestion.findMany.mockImplementation(() => { + order.push('suggestions-start'); + return Promise.resolve([]).then((rows) => { + order.push('suggestions-end'); + return rows; + }); + }); + tx.task.findMany.mockImplementation(() => { + order.push('tasks'); + return Promise.resolve([]); + }); + + await service.openIntentsIn(tx as never, proposal); + + expect(order).toEqual(['suggestions-start', 'suggestions-end', 'tasks']); + }); + + it('scopes the intent check of an object-less proposal to its rule', async () => { + await service.openIntentsIn(tx as never, { + ruleKey: 'STOCK_ITEM_LOW', + colonyId: null, + relatedType: 'GENERAL', + relatedId: null, + }); + + expect(tx.suggestion.findMany).toHaveBeenCalledWith({ + where: { + status: 'OPEN', + relatedType: 'GENERAL', + relatedId: null, + ruleKey: 'STOCK_ITEM_LOW', + }, + select: { intent: true }, + }); + expect(argOf(tx.task.findMany, 0, 0)).toMatchObject({ + where: { + OR: [ + { + relatedType: 'GENERAL', + relatedId: null, + sourceAutomationTrigger: 'STOCK_ITEM_LOW', + }, + ], + }, + }); + }); + + it.each(['reopen', 'accept'] as const)( + '%s answers 409 when the colony is no longer alive', + async (action) => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ status: action === 'reopen' ? 'DISMISSED' : 'OPEN' }), + ); + tx.colony.findUnique.mockResolvedValue({ status: 'SOLD' }); + + const attempt = + action === 'reopen' + ? service.reopen('sug-1', 'user-1') + : service.accept('sug-1', {}, 'user-1'); + + await expect(attempt).rejects.toThrow(ConflictException); + await expect(attempt).rejects.toThrow('Das Volk ist nicht mehr aktiv.'); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + }, + ); + + it('reopen words the conflict by object kind', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ + status: 'DISMISSED', + relatedType: 'STORAGE', + relatedId: 'stock-1', + colonyId: null, + }), + ); + tx.suggestion.findMany.mockResolvedValue([{ intent: 'SPLIT_PLAN' }]); + + await expect(service.reopen('sug-1', 'user-1')).rejects.toThrow( + 'Für diesen Eintrag ist schon ein Vorschlag „Ableger kontrollieren“ offen.', + ); + }); + + it('ignores a plan step named like an Object property', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ plan: [{ step: 'constructor', offsetDays: 1 }] }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', {}, 'user-1'); + + expect( + (tasks.createInTx.mock.calls[0] as [unknown, { title: unknown }])[1] + .title, + ).toBe('Ableger kontrollieren'); + }); + + it('warns about a stored plan that cannot be used and falls back to one task', async () => { + const warn = jest + .spyOn(Logger.prototype, 'warn') + .mockImplementation(() => undefined); + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ plan: [{ nonsense: true }] }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + + await service.accept('sug-1', {}, 'user-1'); + + expect(tasks.createInTx).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('sug-1')); + warn.mockRestore(); + }); + + it('locks the source inspection for share before checking it', async () => { + tx.suggestion.findUnique.mockResolvedValue( + suggestionRow({ plan: null, evidence: { inspectionId: 'insp-1' } }), + ); + tx.suggestion.findUniqueOrThrow.mockResolvedValue( + suggestionRow({ status: 'ACCEPTED' }), + ); + lockShare.mockImplementation(() => { + callOrder.push('share:Inspection'); + return Promise.resolve(); + }); + tx.inspection.count.mockImplementation(() => { + callOrder.push('count'); + return Promise.resolve(1); + }); + + await service.accept('sug-1', {}, 'user-1'); + + expect(lockShare).toHaveBeenCalledWith(tx, 'Inspection', ['insp-1']); + expect(callOrder.indexOf('share:Inspection')).toBeLessThan( + callOrder.indexOf('count'), + ); + }); + + it('bulkAccept names every DO suggestion without a date in a 400', async () => { + tx.suggestion.findMany.mockResolvedValue([ + { id: 'a', mode: 'DO', dueDate: null, plan: null }, + { id: 'b', mode: 'DO', dueDate: new Date('2026-06-01'), plan: null }, + { id: 'c', mode: 'DO', dueDate: null, plan: null }, + { id: 'd', mode: 'PLAN', dueDate: null, plan: null }, + ]); + + const attempt = service.bulkAccept(['a', 'b', 'c', 'd'], 'user-1'); + + await expect(attempt).rejects.toThrow(BadRequestException); + await expect(attempt).rejects.toMatchObject({ + response: { + issues: [ + { + field: 'ids', + message: expect.any(String) as string, + ids: ['a', 'c'], + }, + ], + }, + }); + expect(tasks.createInTx).not.toHaveBeenCalled(); + }); + }); + + describe('system transitions', () => { + it('expireDue moves OPEN past expiresAt to EXPIRED in one statement', async () => { + prisma.$executeRaw.mockResolvedValue(4); + + await expect(service.expireDue('2026-05-05')).resolves.toBe(4); + + expect(prisma.$executeRaw).toHaveBeenCalledTimes(1); + const call = prisma.$executeRaw.mock.calls[0] as unknown[]; + const sql = sqlOf(call); + expect(sql).toContain(`status = 'EXPIRED'`); + expect(sql).toContain(`status = 'OPEN'`); + expect(sql).toContain('"expiresAt" <'); + expect((call[1] as Date).toISOString()).toBe('2026-05-05T00:00:00.000Z'); + }); + + it('expireDue and resolveIn set dedupeKey to NULL with the status change; DISMISSED and ACCEPTED keep it', async () => { + prisma.$executeRaw.mockResolvedValue(0); + await service.expireDue('2026-05-05'); + await service.resolveIn(tx as never, ['a'], 'Aufgeräumt'); + await service.obsoleteForColonyIn(tx as never, 'col-1'); + + const expireSql = sqlOf(prisma.$executeRaw.mock.calls[0] as unknown[]); + const resolveSql = sqlOf(tx.$executeRaw.mock.calls[0] as unknown[]); + const obsoleteSql = sqlOf(tx.$executeRaw.mock.calls[1] as unknown[]); + expect(expireSql).toContain('"dedupeKey" = NULL'); + expect(resolveSql).toContain(`status = 'RESOLVED'`); + expect(resolveSql).toContain('"dedupeKey" = NULL'); + expect(obsoleteSql).toContain(`status = 'OBSOLETE'`); + expect(obsoleteSql).not.toContain('"dedupeKey"'); + }); + + it('resolveIn passes the ids and the note and returns the count', async () => { + tx.$executeRaw.mockResolvedValue(2); + + await expect( + service.resolveIn(tx as never, ['a', 'b'], 'Bedingung entfällt'), + ).resolves.toBe(2); + + const call = tx.$executeRaw.mock.calls[0] as unknown[]; + expect(call).toContain('Bedingung entfällt'); + expect(call).toContainEqual(['a', 'b']); + }); + + it('resolveIn of no ids does nothing', async () => { + await expect( + service.resolveIn(tx as never, [], 'Aufgeräumt'), + ).resolves.toBe(0); + expect(tx.$executeRaw).not.toHaveBeenCalled(); + }); + + it('refreshIn updates an open suggestion only', async () => { + await service.refreshIn(tx as never, 'sug-1', { + evidence: { colonyId: 'col-1' }, + dueDate: '2026-05-10', + priority: 'HIGH', + expiresAt: null, + }); + + const args = argOf<{ + where: unknown; + data: Record; + }>(tx.suggestion.updateMany, 0, 0); + expect(args.where).toEqual({ id: 'sug-1', status: 'OPEN' }); + expect(args.data).toMatchObject({ priority: 'HIGH', expiresAt: null }); + expect((args.data.dueDate as Date).toISOString()).toBe( + '2026-05-10T00:00:00.000Z', + ); + }); + }); + + describe('dedupe by status', () => { + it('an EXPIRED or RESOLVED suggestion of the same anchor can be proposed again; a DISMISSED one cannot', async () => { + // The unique key decides: expire/resolve free it (NULL), dismiss keeps + // it, so skipDuplicates swallows the second proposal. + tx.suggestion.createManyAndReturn + .mockResolvedValueOnce([suggestionRow({ id: 'again' })]) + .mockResolvedValueOnce([]); + + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.toMatchObject({ id: 'again' }); + await expect( + service.proposeIn(tx as never, proposal), + ).resolves.toBeNull(); + + const keys = ( + tx.suggestion.createManyAndReturn.mock.calls as { + data: { dedupeKey: string }[]; + }[][] + ).map((call) => call[0].data[0].dedupeKey); + expect(keys).toEqual([ + 'INSPECTION_INTERVAL:col-1:insp-9', + 'INSPECTION_INTERVAL:col-1:insp-9', + ]); + }); + }); +}); diff --git a/ApiCore-Backend/src/suggestions/suggestions.service.ts b/ApiCore-Backend/src/suggestions/suggestions.service.ts new file mode 100644 index 0000000..aaf0091 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/suggestions.service.ts @@ -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; + +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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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.`, + ); + } + } +} diff --git a/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts b/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts new file mode 100644 index 0000000..659be51 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/autopilot-texts.ts @@ -0,0 +1,4 @@ +import type { SuggestionTextRegister } from '../suggestion-texts'; + +/** Texts of the season autopilot suggestions, by reason code. */ +export const AUTOPILOT_TEXTS: SuggestionTextRegister = {}; diff --git a/ApiCore-Backend/src/suggestions/texts/chain-texts.ts b/ApiCore-Backend/src/suggestions/texts/chain-texts.ts new file mode 100644 index 0000000..37d639b --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/chain-texts.ts @@ -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> = { + RAISE_CELLS_CHECK: 'Weiselzellen kontrollieren', + MATING_CHECK: 'Begattung kontrollieren', + EGGS_CHECK: 'Eier und Stifte kontrollieren', + ACCEPTANCE_CHECK: 'Annahme der Königin kontrollieren', +}; diff --git a/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts b/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts new file mode 100644 index 0000000..df27da0 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/cleanup-texts.ts @@ -0,0 +1,4 @@ +import type { SuggestionTextRegister } from '../suggestion-texts'; + +/** Texts of the clean-up suggestions, by reason code. */ +export const CLEANUP_TEXTS: SuggestionTextRegister = {}; diff --git a/ApiCore-Backend/src/suggestions/texts/rule-texts.ts b/ApiCore-Backend/src/suggestions/texts/rule-texts.ts new file mode 100644 index 0000000..1c97811 --- /dev/null +++ b/ApiCore-Backend/src/suggestions/texts/rule-texts.ts @@ -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 = {}; diff --git a/ApiCore-Backend/src/system/job-run.service.spec.ts b/ApiCore-Backend/src/system/job-run.service.spec.ts index ec80719..a8eaaf6 100644 --- a/ApiCore-Backend/src/system/job-run.service.spec.ts +++ b/ApiCore-Backend/src/system/job-run.service.spec.ts @@ -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([ diff --git a/ApiCore-Backend/src/system/job-run.service.ts b/ApiCore-Backend/src/system/job-run.service.ts index 8de0be2..f3fb23d 100644 --- a/ApiCore-Backend/src/system/job-run.service.ts +++ b/ApiCore-Backend/src/system/job-run.service.ts @@ -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 { 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` UPDATE "JobRun" diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts index 9d611a9..f8381c9 100644 --- a/ApiCore-Backend/src/tasks/tasks.service.ts +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -34,6 +34,7 @@ export interface NewTaskInput { sourceInspectionId?: string | null; sourceTemplateId?: string | null; sourceAutomationTrigger?: AutomationTriggerType | null; + sourceSuggestionId?: string | null; } export interface TaskFilter { diff --git a/ApiCore-Backend/src/worker/worker.module.ts b/ApiCore-Backend/src/worker/worker.module.ts index eb3d6fa..e4ac838 100644 --- a/ApiCore-Backend/src/worker/worker.module.ts +++ b/ApiCore-Backend/src/worker/worker.module.ts @@ -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], diff --git a/ApiCore-Backend/test/automation.e2e-spec.ts b/ApiCore-Backend/test/automation.e2e-spec.ts index 9350192..d1c951a 100644 --- a/ApiCore-Backend/test/automation.e2e-spec.ts +++ b/ApiCore-Backend/test/automation.e2e-spec.ts @@ -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]); diff --git a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts index 4b76292..45eeeea 100644 --- a/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts +++ b/ApiCore-Backend/test/daily-evaluation.e2e-spec.ts @@ -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 = {}; 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', diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts index d9d18d6..1cbef10 100644 --- a/ApiCore-Backend/test/inspections.e2e-spec.ts +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -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(); + }); }); diff --git a/ApiCore-Backend/test/suggestions.e2e-spec.ts b/ApiCore-Backend/test/suggestions.e2e-spec.ts new file mode 100644 index 0000000..d3c4594 --- /dev/null +++ b/ApiCore-Backend/test/suggestions.e2e-spec.ts @@ -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; + 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(); + }); +}); diff --git a/ApiCore-Backend/test/t4-migrations.e2e-spec.ts b/ApiCore-Backend/test/t4-migrations.e2e-spec.ts new file mode 100644 index 0000000..6ccec46 --- /dev/null +++ b/ApiCore-Backend/test/t4-migrations.e2e-spec.ts @@ -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 { + 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', 'mig@example.com', '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 { + 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, + ); +}); diff --git a/ApiCore-Backend/test/t4-schema.e2e-spec.ts b/ApiCore-Backend/test/t4-schema.e2e-spec.ts new file mode 100644 index 0000000..68b27d1 --- /dev/null +++ b/ApiCore-Backend/test/t4-schema.e2e-spec.ts @@ -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 => ({ + 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 } }, + }); + }); + }); +}); diff --git a/ApiCore-Backend/test/utils/migration-harness.ts b/ApiCore-Backend/test/utils/migration-harness.ts new file mode 100644 index 0000000..bf3f1e6 --- /dev/null +++ b/ApiCore-Backend/test/utils/migration-harness.ts @@ -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>( + sql: string, + params?: unknown[], + ): Promise; + /** Applies every migration up to and including the named one. */ + migrateThrough(migrationName: string): Promise; + drop(): Promise; +} + +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 { + 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 { + 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(); + const deploy = async (upToExclusive: number): Promise => { + 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 => { + 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>( + sql: string, + params: unknown[] = [], + ): Promise { + const active = await connected(); + const result = await active.query(sql, params); + return result.rows as T[]; + }, + async migrateThrough(migrationName: string): Promise { + const index = names.indexOf(migrationName); + if (index < 0) { + throw new Error(`Unknown migration: ${migrationName}`); + } + await deploy(index + 1); + }, + async drop(): Promise { + 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 }); + }, + }; +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx new file mode 100644 index 0000000..a0d2750 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.spec.tsx @@ -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 | 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( + + + , + ); +} + +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; + +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) { + 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 }); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 29eb27b..0aef7ab 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -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 = { - 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 = { - 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>({}); + // 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(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 = { dueInDays: days }; - if (rule.triggerType === "VARROA_THRESHOLD") { - config.thresholdMitesPerDay = Number(threshold) || 10; - } + const config: Record = { 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 ( - {TRIGGER_LABELS[rule.triggerType]} + {RULE_LABELS[rule.triggerType]} {rule.active ? "Aktiv" : "Inaktiv"} -

{TRIGGER_DESCRIPTIONS[rule.triggerType]}

+

{RULE_DESCRIPTIONS[rule.triggerType]}

{numberFormatRule ? (
@@ -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); + }} />

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)} - /> -

- ) : null} - {rule.triggerType === "VARROA_THRESHOLD" ? ( -
- - setThreshold(event.target.value)} + onChange={(event) => { + clearIssues("dueInDays"); + setDueInDays(event.target.value); + }} />
) : 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 })); + }} /> )) : null} + {Object.entries(issues).map(([field, message]) => ( +

+ {message} +

+ ))} + {!numberFormatRule ? ( + { + clearIssues("notify"); + setNotify(checked); + }} + /> + ) : null}
); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx new file mode 100644 index 0000000..5816979 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms.spec.tsx @@ -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 | 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( + + + , + ); +} + +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; + +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) { + 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) => ( + + + + ); + 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) => ( + + + + ); + 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(); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/chain-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/chain-form.tsx new file mode 100644 index 0000000..9e8525e --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/chain-form.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { NotifyToggle } from "./notify-form"; +import { + RuleFormProps, + RuleFormShell, + RuleInputField, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; + +const FIELDS = { dueInDays: "int", notify: "bool" } as const; + +/** Follow-up task after an event (Weiselzellen gesehen, Königin zugesetzt). */ +export function ChainForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues }); + return ( + submitDraft(draft, onSave)}> + + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/inspection-interval-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/inspection-interval-form.tsx new file mode 100644 index 0000000..03f427e --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/inspection-interval-form.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Field } from "@/components/form/field"; +import { NumberStepper } from "@/components/form/number-stepper"; +import { + RuleFormProps, + RuleFormShell, + hintWithSource, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; + +export const SEGMENT_LABELS = { + FRUEHJAHR: "Frühjahr", + SCHWARMZEIT: "Schwarmzeit", + SOMMER: "Sommer", + SPAETSOMMER: "Spätsommer", + HERBST: "Herbst", +} as const; + +type Segment = keyof typeof SEGMENT_LABELS; +const SEGMENTS = Object.keys(SEGMENT_LABELS) as Segment[]; +const MIN_DAYS = 7; +const MAX_SWARM_DAYS = 9; +const MAX_DAYS = 35; + +const RANGES = Object.fromEntries( + SEGMENTS.map((segment) => { + const max = segment === "SCHWARMZEIT" ? MAX_SWARM_DAYS : MAX_DAYS; + return [ + `intervalDays.${segment}`, + { min: MIN_DAYS, max, message: `Bitte ${MIN_DAYS} bis ${max} Tage wählen.` }, + ]; + }), +); + +const FIELDS = Object.fromEntries( + SEGMENTS.map((segment) => [`intervalDays.${segment}`, "number"]), +) as Record<`intervalDays.${Segment}`, "number">; + +/** Days between two inspections per part of the season. */ +export function InspectionIntervalForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues, ranges: RANGES }); + return ( + submitDraft(draft, onSave)}> + {SEGMENTS.map((segment) => { + const path = `intervalDays.${segment}`; + const label = SEGMENT_LABELS[segment]; + const hint = + segment === "SCHWARMZEIT" ? `${MIN_DAYS} bis ${MAX_SWARM_DAYS} Tage.` : `${MIN_DAYS} bis ${MAX_DAYS} Tage.`; + return ( + + {({ id, describedBy, invalid }) => ( + draft.set(path, value)} + min={7} + unitLabel="Tage" + decrementLabel={`${label} einen Tag weniger`} + incrementLabel={`${label} einen Tag mehr`} + describedBy={describedBy} + invalid={invalid} + /> + )} + + ); + })} + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/month-ranges.ts b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/month-ranges.ts new file mode 100644 index 0000000..d5817fc --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/month-ranges.ts @@ -0,0 +1,57 @@ +const MONTH_NAMES = [ + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember", +]; + +function validMonths(months: unknown): number[] { + if (!Array.isArray(months)) return []; + const valid = months.filter( + (month): month is number => Number.isInteger(month) && month >= 1 && month <= 12, + ); + return [...new Set(valid)].sort((a, b) => a - b); +} + +/** Runs of neighbouring months; December and January join into one run. */ +function runs(months: number[]): number[][] { + const result: number[][] = []; + for (const month of months) { + const last = result[result.length - 1]; + if (last && last[last.length - 1] === month - 1) last.push(month); + else result.push([month]); + } + const first = result[0]; + const last = result[result.length - 1]; + if (result.length > 1 && first[0] === 1 && last[last.length - 1] === 12) { + result.pop(); + result[0] = [...last, ...first]; + } + return result; +} + +function describeRun(run: number[]): string { + const first = MONTH_NAMES[run[0] - 1]; + if (run.length === 1) return first; + const last = MONTH_NAMES[run[run.length - 1] - 1]; + return run.length === 2 ? `${first} und ${last}` : `${first} bis ${last}`; +} + +/** "März bis Oktober", "Mai bis Juli", "November bis Februar"; "" for no month. */ +export function describeMonths(months: unknown): string { + return runs(validMonths(months)).map(describeRun).join(", "); +} + +/** The months of the year that are not in the list. */ +export function otherMonths(months: unknown): number[] { + const used = new Set(validMonths(months)); + return Array.from({ length: 12 }, (_, index) => index + 1).filter((month) => !used.has(month)); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx new file mode 100644 index 0000000..858e3f7 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/notify-form.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { + RuleFormProps, + RuleFormShell, + RuleToggle, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; +import { AutomationTriggerType } from "@/lib/automation/types"; +import { RuleDraft } from "./use-rule-draft"; + +export const NOTIFY_LABEL = "Zusätzlich melden"; +export const NOTIFY_HINT = "Du bekommst dann zusätzlich eine Meldung."; + +/** The switch every rule with a notification has. */ +export function NotifyToggle({ + trigger, + draft, +}: { + trigger: AutomationTriggerType; + draft: RuleDraft; +}) { + return ( + + ); +} + +const FIELDS = { notify: "bool" } as const; + +/** Rules that only decide whether they also notify (Wartezeit zu Ende). */ +export function NotifyForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues }); + return ( + submitDraft(draft, onSave)}> + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/phenology-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/phenology-form.tsx new file mode 100644 index 0000000..feef5c0 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/phenology-form.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { EnumSelect } from "@/components/form/enum-select"; +import { Field } from "@/components/form/field"; +import { FormSection } from "@/components/form/form-section"; +import { + hintWithSource, + RuleFormProps, + RuleFormShell, + RuleInputField, + RuleToggle, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; + +export const PHASE_LABELS = { + SALWEIDE: "Salweide blüht", + KIRSCHE: "Kirsche blüht", + APFEL: "Apfel blüht", + HOLUNDER: "Holunder blüht", + LINDE: "Linde blüht", + TRACHTENDE: "Tracht vorbei", + HERBST: "Efeu blüht", + WINTER: "Winterruhe", +} as const; + +type Phase = keyof typeof PHASE_LABELS; +const PHASES = Object.keys(PHASE_LABELS) as Phase[]; + +const FIELDS = { + useGts: "bool", + "gtsThresholds.SALWEIDE": "optionalNumber", + calibrateFromReports: "bool", + ...Object.fromEntries(PHASES.map((phase) => [`calendarFallback.${phase}`, "text"])), + "swarmSeason.from": "text", + "swarmSeason.until": "text", + maxGapShare: "number", +} as const; + +/** How the season is found: report, temperature sum (GTS) or calendar. */ +export function PhenologyForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues }); + const shared = { trigger: rule.triggerType, draft } as const; + const trigger = rule.triggerType; + + return ( + submitDraft(draft, onSave)}> + + + + + + + + {PHASES.map((phase) => ( + + ))} + + + {(["from", "until"] as const).map((edge) => { + const path = `swarmSeason.${edge}`; + return ( + + {({ id, describedBy, invalid }) => ( + draft.set(path, value)} + describedBy={describedBy} + invalid={invalid} + /> + )} + + ); + })} + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx new file mode 100644 index 0000000..b71b4b8 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/rule-form-parts.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { FormEvent, ReactNode, useId, useMemo } from "react"; +import { Field } from "@/components/form/field"; +import { ToggleChip } from "@/components/form/toggle-chip"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + RULE_FORM_DEFAULTS, + RULE_VALUE_SOURCES, + ValueSource, +} from "@/lib/automation/rule-configs"; +import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types"; +import { + FieldSpecs, + RuleConfigValue, + RuleDraft, + RuleDraftOptions, + mergeConfig, + useRuleDraft, +} from "./use-rule-draft"; + +/** What every rule form gets from its card. */ +export interface RuleFormProps { + rule: AutomationRule; + /** Field messages of a rejected save (400 `issues`). */ + serverIssues: Record; + pending: boolean; + onSave: (config: RuleConfigValue) => void; +} + +export function sourceText(source: ValueSource): string { + return source.kind === "SOURCE" ? `Quelle: ${source.label}` : source.label; +} + +/** The source as a hint line, so it is text on the page and not only a tooltip. */ +export function hintWithSource( + trigger: AutomationTriggerType, + path: string, + hint?: string, +): string | undefined { + const source = RULE_VALUE_SOURCES[trigger]?.[path]; + return [hint, source ? sourceText(source) : null].filter(Boolean).join(" · ") || undefined; +} + +interface FieldContext { + trigger: AutomationTriggerType; + draft: RuleDraft; +} + +interface RuleInputFieldProps extends FieldContext { + path: string; + label: string; + unit?: string; + hint?: string; + placeholder?: string; + required?: boolean; + /** `decimal` shows the number keypad with a comma. */ + inputMode?: "decimal" | "numeric" | "text"; +} + +/** One text or number setting with its source and the server's message under it. */ +export function RuleInputField({ + trigger, + draft, + path, + label, + unit, + hint, + placeholder, + required = true, + inputMode = "decimal", +}: RuleInputFieldProps) { + const error = draft.issue(path) ?? null; + return ( + + {({ id, describedBy, invalid }) => ( +
+ draft.set(path, event.target.value)} + /> + {unit ? {unit} : null} +
+ )} +
+ ); +} + +interface RuleToggleProps { + trigger: AutomationTriggerType; + draft: RuleDraft; + path: string; + label: string; + hint?: string; +} + +/** A yes/no setting as a large tile (tick and word, not only colour). */ +export function RuleToggle({ trigger, draft, path, label, hint }: RuleToggleProps) { + const baseId = useId(); + const hintText = hintWithSource(trigger, path, hint); + const error = draft.issue(path); + const hintId = hintText ? `${baseId}-hint` : undefined; + const errorId = error ? `${baseId}-error` : undefined; + return ( +
+ draft.set(path, checked)} + describedBy={[hintId, errorId].filter(Boolean).join(" ") || undefined} + /> + {hintText ? ( +

+ {hintText} +

+ ) : null} + {error ? ( + + ) : null} +
+ ); +} + +interface RuleFormShellProps { + draft: RuleDraft; + pending: boolean; + /** Called on submit; builds the config and hands it to `onSave`. */ + onSubmit: () => void; + children: ReactNode; +} + +/** The fields of a rule and its one main button. */ +export function RuleFormShell({ draft, pending, onSubmit, children }: RuleFormShellProps) { + const unmatched = draft.unmatchedIssues(); + function handleSubmit(event: FormEvent) { + event.preventDefault(); + onSubmit(); + } + return ( +
+ {children} + {unmatched.length > 0 ? ( +
    + {unmatched.map((message, index) => ( +
  • + {message} +
  • + ))} +
+ ) : null} +
+ +
+
+ ); +} + +/** Submits the draft: nothing is sent while a field is not usable. */ +export function submitDraft(draft: RuleDraft, onSave: (config: RuleConfigValue) => void): void { + const config = draft.build(); + if (config) onSave(config); +} + +/** + * Draft of a rule from its stored config on top of the built-in values. + * `normalize` may fix up older stored shapes before the form reads them. + */ +export function useRuleFormDraft( + rule: AutomationRule, + fields: FieldSpecs, + options: RuleDraftOptions & { + normalize?: (merged: RuleConfigValue, stored: unknown) => RuleConfigValue; + } = {}, +): RuleDraft { + const { normalize, ...draftOptions } = options; + const base = useMemo(() => { + const defaults = + (RULE_FORM_DEFAULTS as Partial>)[rule.triggerType] ?? {}; + const merged = mergeConfig(defaults as RuleConfigValue, rule.config); + return normalize ? normalize(merged, rule.config) : merged; + }, [rule.triggerType, rule.config, normalize]); + return useRuleDraft(base, fields, draftOptions); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/season-autopilot-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/season-autopilot-form.tsx new file mode 100644 index 0000000..2638364 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/season-autopilot-form.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { FormSection } from "@/components/form/form-section"; +import { + RuleFormProps, + RuleFormShell, + RuleInputField, + RuleToggle, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; + +export const AUTOPILOT_RULES = [ + { id: "A1", label: "Durchsicht fällig" }, + { id: "A2", label: "Varroa messen" }, + { id: "A3", label: "Behandlungserfolg messen" }, + { id: "A4", label: "Sommerbehandlung planen" }, + { id: "A5", label: "Einfüttern" }, + { id: "A6", label: "Füttern bei wenig Futter" }, + { id: "A7", label: "Honigraum prüfen" }, + { id: "A8", label: "Umweiseln planen" }, + { id: "A9", label: "Einwinterung erfassen" }, + { id: "A10", label: "Auswinterung erfassen" }, +] as const; + +const FIELDS = { + honeySuperMinFrames: "count", + varroaMeasureIntervalDays: "int", + "winterVarroaWindow.from": "text", + "winterVarroaWindow.to": "text", + queenMaxAgeYears: "count", + suggestionTtlDays: "int", + maxStepsPerColony: "count", + ...Object.fromEntries(AUTOPILOT_RULES.map(({ id }) => [`rules.${id}`, "bool"])), +} as const; + +/** Numbers and dates of the season autopilot, and one switch per rule A1 to A10. */ +export function SeasonAutopilotForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues }); + const shared = { + trigger: rule.triggerType, + draft, + serverIssues, + inputMode: "numeric", + } as const; + return ( + submitDraft(draft, onSave)}> + +
+ {AUTOPILOT_RULES.map(({ id, label }) => ( + + ))} +
+
+ + + + + + + + + +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/split-schedule-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/split-schedule-form.tsx new file mode 100644 index 0000000..01c2907 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/split-schedule-form.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useMemo } from "react"; +import { FormSection } from "@/components/form/form-section"; +import { RULE_FORM_DEFAULTS } from "@/lib/automation/rule-configs"; +import { NotifyToggle } from "./notify-form"; +import { + RuleFormProps, + RuleFormShell, + RuleInputField, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; +import { FieldSpecs, RuleConfigValue, getPath, mergeConfig } from "./use-rule-draft"; + +const VARIANTS = [ + { key: "RAISE_OWN", title: "Ableger mit eigener Nachzucht" }, + { key: "QUEEN_CELL", title: "Ableger mit Weiselzelle" }, + { key: "QUEEN", title: "Ableger mit zugesetzter Königin" }, +] as const; + +export const STEP_LABELS: Record = { + RAISE_CELLS_CHECK: "Weiselzellen prüfen", + MATING_CHECK: "Begattung prüfen", + EGGS_CHECK: "Stifte prüfen", + ACCEPTANCE_CHECK: "Annahme prüfen", +}; + +interface StepRow { + path: string; + windowPath: string | null; + label: string; +} + +function stepRows(config: unknown): StepRow[][] { + return VARIANTS.map(({ key }) => { + const steps = getPath(config, key); + return (Array.isArray(steps) ? steps : []).map((entry, index) => { + const name = String((entry as { step?: unknown }).step ?? ""); + const hasWindow = (entry as { windowDays?: unknown }).windowDays !== undefined; + return { + path: `${key}[${index}].offsetDays`, + windowPath: hasWindow ? `${key}[${index}].windowDays` : null, + label: STEP_LABELS[name] ?? name, + }; + }); + }); +} + +/** Follow-up checks after a split, in days from the day it was made. */ +export function SplitScheduleForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const merged = useMemo( + () => mergeConfig(RULE_FORM_DEFAULTS.SPLIT_SCHEDULE as unknown as RuleConfigValue, rule.config), + [rule.config], + ); + const rows = useMemo(() => stepRows(merged), [merged]); + const fields = useMemo(() => { + const specs: Record = { notify: "bool" }; + for (const row of rows.flat()) { + specs[row.path] = "int"; + if (row.windowPath) specs[row.windowPath] = "int"; + } + return specs; + }, [rows]); + const draft = useRuleFormDraft(rule, fields, { serverIssues }); + const shared = { trigger: rule.triggerType, draft, inputMode: "numeric" } as const; + + return ( + submitDraft(draft, onSave)}> + {VARIANTS.map(({ key, title }, variantIndex) => ( + + {rows[variantIndex].map((row) => ( +
+ + {row.windowPath ? ( + + ) : null} +
+ ))} +
+ ))} + +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/use-rule-draft.ts b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/use-rule-draft.ts new file mode 100644 index 0000000..084cf2f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/use-rule-draft.ts @@ -0,0 +1,229 @@ +import { useCallback, useMemo, useState } from "react"; + +export type RuleConfigValue = Record; +/** + * `number` takes decimals; `int` whole days (no decimals, no negatives); + * `count` a whole number; `optionalNumber` may be left empty. + */ +export type FieldType = "number" | "optionalNumber" | "int" | "count" | "text" | "bool"; +export type FieldSpecs = Readonly>; +export type DraftValue = string | boolean; + +const NOT_A_NUMBER_MESSAGE = "Bitte eine Zahl angeben."; +const NUMBER_PATTERN = /^-?\d+(\.\d+)?$/; +const WHOLE_PATTERN = /^\d+$/; +const WHOLE_MESSAGES = { + int: "Bitte ganze Tage angeben.", + count: "Bitte eine ganze Zahl angeben.", +} as const; +/** Keys whose object is a list of entries: the stored object replaces the default as a whole. */ +const WHOLE_OBJECT_KEYS = new Set(["byHoneyType", "gtsThresholds"]); + +function isRecord(value: unknown): value is RuleConfigValue { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The stored config on top of the defaults; arrays and listed objects come over whole. */ +export function mergeConfig(defaults: RuleConfigValue, stored: unknown): RuleConfigValue { + const result: RuleConfigValue = structuredClone(defaults); + if (!isRecord(stored)) return result; + for (const [key, value] of Object.entries(stored)) { + const base = result[key]; + result[key] = + isRecord(value) && isRecord(base) && !WHOLE_OBJECT_KEYS.has(key) + ? mergeConfig(base, value) + : structuredClone(value); + } + return result; +} + +/** `a.b[1].c` -> ["a", "b", 1, "c"], the same notation the server uses for field paths. */ +function tokens(path: string): (string | number)[] { + return path + .split(/\.|\[(\d+)\]/) + .filter((part) => part !== undefined && part !== "") + .map((part) => (/^\d+$/.test(part) ? Number(part) : part)); +} + +export function getPath(source: unknown, path: string): unknown { + let current: unknown = source; + for (const token of tokens(path)) { + if (current === null || typeof current !== "object") return undefined; + current = (current as Record)[token]; + } + return current; +} + +function setPath(target: RuleConfigValue, path: string, value: unknown): void { + const parts = tokens(path); + let current = target as Record; + parts.slice(0, -1).forEach((token) => { + current = current[token] as Record; + }); + current[parts[parts.length - 1]] = value; +} + +function deletePath(target: RuleConfigValue, path: string): void { + const parts = tokens(path); + let current: unknown = target; + for (const token of parts.slice(0, -1)) { + if (current === null || typeof current !== "object") return; + current = (current as Record)[token]; + } + if (current !== null && typeof current === "object") { + delete (current as Record)[parts[parts.length - 1]]; + } +} + +/** Decimal comma or point; anything else is not a number. */ +export function parseNumber(text: string): number | null { + const normalized = text.trim().replace(",", "."); + return NUMBER_PATTERN.test(normalized) ? Number(normalized) : null; +} + +function initialValue(base: RuleConfigValue, path: string, type: FieldType): DraftValue { + const value = getPath(base, path); + if (type === "bool") return value === true; + if (typeof value === "number") return String(value).replace(".", ","); + return typeof value === "string" ? value : ""; +} + +export interface FieldRange { + min: number; + max: number; + message: string; +} + +export interface RuleDraftOptions { + /** Field messages of a rejected save; one disappears once its field is edited. */ + serverIssues?: Record; + /** Local bounds checked on submit, shown as a field error. */ + ranges?: Readonly>; +} + +export interface RuleDraft { + /** The stored config on top of the defaults, as the form started from it. */ + base: RuleConfigValue; + values: Record; + /** Message of a field: a local mistake first, then the server's. */ + issue: (path: string) => string | undefined; + /** Server messages of paths that have no field in the form. */ + unmatchedIssues: () => string[]; + set: (path: string, value: DraftValue) => void; + /** The config to send, or `null` when a field is not usable (errors are then set). */ + build: () => RuleConfigValue | null; +} + +const NO_ISSUES: Record = {}; + +function readNumber( + type: FieldType, + text: string, +): { value: number } | { error: string } | "empty" { + if (text.trim() === "") return type === "optionalNumber" ? "empty" : { error: errorFor(type) }; + if (type === "int" || type === "count") { + const normalized = text.trim(); + return WHOLE_PATTERN.test(normalized) + ? { value: Number(normalized) } + : { error: WHOLE_MESSAGES[type] }; + } + const parsed = parseNumber(text); + return parsed === null ? { error: NOT_A_NUMBER_MESSAGE } : { value: parsed }; +} + +function errorFor(type: FieldType): string { + return type === "int" || type === "count" ? WHOLE_MESSAGES[type] : NOT_A_NUMBER_MESSAGE; +} + +/** + * Form state for one rule: the fields as text (so half-typed numbers stay), + * everything else of the config (month lists, colony types, honey types...) + * passes through unchanged. + */ +export function useRuleDraft( + base: RuleConfigValue, + fields: FieldSpecs, + options: RuleDraftOptions = {}, +): RuleDraft { + const serverIssues = options.serverIssues ?? NO_ISSUES; + const ranges = options.ranges; + const [values, setValues] = useState>(() => + Object.fromEntries( + Object.entries(fields).map(([path, type]) => [path, initialValue(base, path, type)]), + ), + ); + const [errors, setErrors] = useState>({}); + const [seenIssues, setSeenIssues] = useState(serverIssues); + const [edited, setEdited] = useState>(new Set()); + const [acted, setActed] = useState(false); + + // A new answer of the server shows all its messages again. + if (seenIssues !== serverIssues) { + setSeenIssues(serverIssues); + setEdited(new Set()); + setActed(false); + } + + const set = useCallback((path: string, value: DraftValue) => { + setValues((current) => ({ ...current, [path]: value })); + setEdited((current) => new Set(current).add(path)); + setActed(true); + setErrors((current) => { + if (!(path in current)) return current; + const rest = { ...current }; + delete rest[path]; + return rest; + }); + }, []); + + const build = useCallback((): RuleConfigValue | null => { + const config = structuredClone(base); + const found: Record = {}; + for (const [path, type] of Object.entries(fields)) { + const value = values[path]; + if (type === "bool") { + setPath(config, path, value === true); + continue; + } + if (type === "text") { + setPath(config, path, String(value).trim()); + continue; + } + const read = readNumber(type, String(value)); + if (read === "empty") { + deletePath(config, path); + continue; + } + if ("error" in read) { + found[path] = read.error; + continue; + } + const range = ranges?.[path]; + if (range && (read.value < range.min || read.value > range.max)) { + found[path] = range.message; + continue; + } + setPath(config, path, read.value); + } + setErrors(found); + return Object.keys(found).length > 0 ? null : config; + }, [base, fields, ranges, values]); + + const issue = useCallback( + (path: string) => errors[path] ?? (edited.has(path) ? undefined : serverIssues[path]), + [errors, edited, serverIssues], + ); + + const unmatchedIssues = useCallback( + () => + Object.entries(serverIssues) + .filter(([path]) => !acted && !(path in fields)) + .map(([, message]) => message), + [serverIssues, fields, acted], + ); + + return useMemo( + () => ({ base, values, issue, unmatchedIssues, set, build }), + [base, values, issue, unmatchedIssues, set, build], + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/varroa-threshold-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/varroa-threshold-form.tsx new file mode 100644 index 0000000..e166b52 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/varroa-threshold-form.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { FormSection } from "@/components/form/form-section"; +import { NotifyToggle } from "./notify-form"; +import { + RuleFormProps, + RuleFormShell, + RuleInputField, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; +import { describeMonths, otherMonths } from "./month-ranges"; +import { RuleConfigValue, getPath } from "./use-rule-draft"; + +const FIELDS = { + "naturalFall.yellowFrom": "number", + "naturalFall.redAbove": "number", + "naturalFall.winterRedAbove": "number", + "infestationPercent.summer.yellowFrom": "number", + "infestationPercent.summer.redAbove": "number", + "infestationPercent.autumn.yellowFrom": "number", + "infestationPercent.autumn.redAbove": "number", + youngColonyFactor: "number", + recheckAfterDays: "int", + notify: "bool", +} as const; + +/** The single threshold of before is the red limit of the natural mite fall. */ +function fromLegacyThreshold(merged: RuleConfigValue, stored: unknown): RuleConfigValue { + const legacy = (stored as { thresholdMitesPerDay?: unknown } | null)?.thresholdMitesPerDay; + const rest = { ...merged }; + delete rest.thresholdMitesPerDay; + const hasBands = Boolean( + (stored as { naturalFall?: { redAbove?: unknown } } | null)?.naturalFall?.redAbove, + ); + if (typeof legacy === "number" && !hasBands) { + return { ...rest, naturalFall: { ...(rest.naturalFall as object), redAbove: legacy } }; + } + return rest; +} + +/** Colour bands of the varroa traffic light per method and season. */ +export function VarroaThresholdForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues, normalize: fromLegacyThreshold }); + const shared = { trigger: rule.triggerType, draft } as const; + const seasonMonths = getPath(draft.base, "naturalFall.seasonMonths"); + const seasonText = describeMonths(seasonMonths); + const winterText = describeMonths(otherMonths(seasonMonths)); + const summerText = describeMonths(getPath(draft.base, "infestationPercent.summerMonths")); + const autumnText = describeMonths(getPath(draft.base, "infestationPercent.autumnMonths")); + return ( + submitDraft(draft, onSave)}> + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/water-content-form.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/water-content-form.tsx new file mode 100644 index 0000000..4507954 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-forms/water-content-form.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { + RuleFormProps, + RuleFormShell, + RuleInputField, + submitDraft, + useRuleFormDraft, +} from "./rule-form-parts"; + +const FIELDS = { warnAbovePercent: "number", confirmAbovePercent: "number" } as const; + +/** Thresholds for the water content of honey. Sorts with their own limit stay as they are. */ +export function WaterContentForm({ rule, serverIssues, pending, onSave }: RuleFormProps) { + const draft = useRuleFormDraft(rule, FIELDS, { serverIssues }); + const sorts = Object.keys( + ((rule.config as { byHoneyType?: Record } | null)?.byHoneyType ?? {}), + ); + const shared = { trigger: rule.triggerType, draft, unit: "%" } as const; + return ( + submitDraft(draft, onSave)}> + + + {sorts.length > 0 ? ( +

+ Eigene Grenzen für: {sorts.join(", ")}. Sie bleiben unverändert. +

+ ) : null} +
+ ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx new file mode 100644 index 0000000..6c2adbd --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/special-rule-card.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { ComponentType, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { updateAutomationRule } from "@/lib/automation/api"; +import { RULE_DESCRIPTIONS, RULE_LABELS, issuesByField } from "@/lib/automation/rule-configs"; +import { AutomationRule, AutomationTriggerType } from "@/lib/automation/types"; +import { errorMessage } from "@/lib/forms/error-message"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ChainForm } from "./rule-forms/chain-form"; +import { InspectionIntervalForm } from "./rule-forms/inspection-interval-form"; +import { NotifyForm } from "./rule-forms/notify-form"; +import { PhenologyForm } from "./rule-forms/phenology-form"; +import { RuleFormProps } from "./rule-forms/rule-form-parts"; +import { SeasonAutopilotForm } from "./rule-forms/season-autopilot-form"; +import { SplitScheduleForm } from "./rule-forms/split-schedule-form"; +import { RuleConfigValue } from "./rule-forms/use-rule-draft"; +import { VarroaThresholdForm } from "./rule-forms/varroa-threshold-form"; +import { WaterContentForm } from "./rule-forms/water-content-form"; + +function NoSettingsForm() { + return

Hier gibt es nichts einzustellen.

; +} + +const RULE_FORMS: Partial>> = { + VARROA_THRESHOLD: VarroaThresholdForm, + INSPECTION_INTERVAL: InspectionIntervalForm, + QUEEN_CELLS_SWARM_CHECK: ChainForm, + QUEEN_ACCEPTANCE_CHECK: ChainForm, + SPLIT_SCHEDULE: SplitScheduleForm, + COLONY_ENDED_CLEANUP: NoSettingsForm, + WITHDRAWAL_ENDED: NotifyForm, + HONEY_WATER_CONTENT: WaterContentForm, + SEASON_AUTOPILOT: SeasonAutopilotForm, + PHENOLOGY: PhenologyForm, +}; + +/** Rules with a form of their own instead of the generic card. */ +export function hasRuleForm(trigger: AutomationTriggerType): boolean { + return trigger in RULE_FORMS; +} + +export function SpecialRuleCard({ rule }: { rule: AutomationRule }) { + const queryClient = useQueryClient(); + const [issues, setIssues] = useState>({}); + const Form = RULE_FORMS[rule.triggerType] as ComponentType; + + const mutation = useMutation({ + mutationFn: (input: { active?: boolean; config?: RuleConfigValue }) => + updateAutomationRule(rule.id, input), + onSuccess: () => { + setIssues({}); + void queryClient.invalidateQueries({ queryKey: ["automation-rules"] }); + toast.success("Regel gespeichert"); + }, + onError: (error) => { + setIssues(issuesByField(error)); + toast.error(errorMessage(error, "Regel konnte nicht gespeichert werden")); + }, + }); + + return ( + + + {RULE_LABELS[rule.triggerType]} + + {rule.active ? "Aktiv" : "Inaktiv"} + + + +

{RULE_DESCRIPTIONS[rule.triggerType]}

+
mutation.mutate({ config })} + /> +
+ +
+ + + ); +} diff --git a/ApiCore-Frontend/src/components/form/toggle-chip.tsx b/ApiCore-Frontend/src/components/form/toggle-chip.tsx index 5096094..3209814 100644 --- a/ApiCore-Frontend/src/components/form/toggle-chip.tsx +++ b/ApiCore-Frontend/src/components/form/toggle-chip.tsx @@ -7,15 +7,18 @@ interface ToggleChipProps { label: string; checked: boolean; onChange: (checked: boolean) => void; + /** Ids of a hint or error shown next to the tile. */ + describedBy?: string; } /** A yes/no observation as a large tile; state shown by tick AND colour. */ -export function ToggleChip({ label, checked, onChange }: ToggleChipProps) { +export function ToggleChip({ label, checked, onChange, describedBy }: ToggleChipProps) { return (