Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2533 lines
248 KiB
Markdown
2533 lines
248 KiB
Markdown
# T4 Logik-Ketten und Saison-Autopilot Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Was der Imker erfasst, wirkt weiter: Aus Durchsicht, Messung, Ableger, Behandlung und Pflanzenentwicklung entstehen datierte Vorschläge, je Volk „die nächsten 3 Schritte“ mit Begründung, Warnungen entlang der Honig-Kette, ein Bewegungsbuch der Honigcharge und Erinnerungen in der App und per Telegram. Automatik plant nie heimlich: Sie schlägt vor, der Imker übernimmt oder verwirft.
|
||
|
||
**Architecture:** Alle Folgen hängen an Handlern des Ereignis-Rückgrats (`@OnDomainEvent`, Schreiben nur über `tx`) oder an Schritten des Jobs `daily-evaluation`. Netz gibt es nur in drei neuen Worker-Schleifen (`phenology-weather`, `telegram-send`, `telegram-updates`). Ein Modell `Suggestion` mit einem `SuggestionsService` trägt Regel-, Ketten- und Autopilot-Vorschläge; Titel und Gründe entstehen beim Lesen aus `reasonCode` + `evidence` (nur IDs, Zahlen, Tage). Jeder Fachwert kommt aus `AutomationRule.config` über die Parser in `src/automation/rule-config/`. Die Saison kommt je Stand aus Meldung, Grünlandtemperatursumme oder Kalender (`SeasonService`). Benachrichtigungen werden je Nutzer mit Rechteprüfung geschrieben (Fan-out) und optional über die Outbox `NotificationDelivery` an Telegram zugestellt.
|
||
|
||
**Tech Stack:** NestJS 11, Prisma 7.9 (`@prisma/adapter-pg`), PostgreSQL 18, Jest 30 (Unit + e2e gegen Test-DB), Next.js 16.3.1 App Router, React 19.2, TanStack Query 5, `idb` 8, Vitest 4 + RTL. Keine neue Abhängigkeit (Netz über das eingebaute `fetch`).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-10-13-t4-logik-ketten-design.md` (bindend, mit Inhaber-Entscheidungen und -Antworten). Bausteine aus `docs/superpowers/plans/2026-10-13-plattform-rueckgrat.md` (Rückgrat) und `docs/superpowers/plans/2026-10-13-t3-erfassung-am-stand.md` (T3).
|
||
|
||
---
|
||
|
||
## Global Constraints
|
||
|
||
**Voraussetzungen (nicht neu bauen, genau so nutzen)**
|
||
- Rückgrat Tasks 1–26 sind gemergt. Genutzt werden: `TasksService.createInTx(tx, input, actingUserId)`, `TasksService.update/complete` (gemeinsames `markDoneIn`), `DomainEventsService.record/recordAll(tx, …)`, `@OnDomainEvent(types, '<consumer>.<name>')`, `HANDLER_ID_PATTERN`, `dailyDedupeKey` und `utcDay` (beide `src/domain-events/domain-event-types.ts`), `JobRunService.run`, `DailyEvaluationJob` mit `CATCH_UP_DAYS = 7`, `claimBatch`/`releaseStaleClaims`/`settleSuccess`/`settleFailure` (`src/common/outbox/outbox-claims.ts`), `retryDelayMs`/`MAX_ATTEMPTS = 8` (`src/common/retry-policy.ts`), `notifyWorker`/`WAKE_CHANNELS`, `loggableError`, `withTimeout`, `DbClient`, `assertTransactionClient`, `PollingLoop`, Loop-Tabelle in `WorkerRuntime`, `DOMAIN_EVENT_WEBHOOKS`, `CalendarSyncService.enqueueIn`, `ReauthService.verify`, `PermissionResolverService.getPermissions`, `SystemQueueService`, `drainDomainEvents` (`test/utils/domain-events.ts`), `deliverWebhooks`.
|
||
- T3 ist gemergt: `ApiaryFieldService` (`colony-overview`, `offline-pack`), `assertLivingColonies`, Frontend `PreviousValueHint`, `NumberStepper`, `StatusLightBadge`, `colonyStatusLight`, `startRound`, `useCaptureMutation`, Warteschlange `src/lib/offline/db.ts` (Version 3), `takeover.ts`, `varroaLight`, `fetchVarroaThreshold`, `nav-config.ts`.
|
||
- Alle Backend-Pfade relativ zu `ApiCore-Backend/`, alle Frontend-Pfade relativ zu `ApiCore-Frontend/`, sofern nicht anders angegeben.
|
||
|
||
**Lieferungen:** T4a = Tasks 1–22, T4b = Tasks 23–33, T4c = Tasks 34–38. T4c braucht nur T4a. T4b und T4c werden parallel gebaut und **gemeinsam** ausgerollt (geteiltes `schema.prisma`, Migration 6 ändert Daten und braucht den Ledger-Code). Soll T4c früher live, läuft die Lane 34–38 vollständig vor Task 23, und die Zeitstempel-Blöcke tauschen (T4c `20261015100000`, T4b ab `20261015110000`).
|
||
|
||
**Migrationen (Zeitstempel fest, Reihenfolge = Erzeugungsreihenfolge; Nummern aus Spec 4.12)**
|
||
|
||
| Zeitstempel | Name | Spec-Nr | Task |
|
||
|---|---|---|---|
|
||
| 20261014100000 | `t4_enum_values` | 1 | 1 |
|
||
| 20261014100100 | `t4_suggestions` | 2 | 1 |
|
||
| 20261014100200 | `t4_feed_vocabulary` | 3 | 2 |
|
||
| 20261014100300 | `t4_split_queen_source` | 5 | 2 |
|
||
| 20261014100400 | `t4_varroa_sample_size` | 7 (Teil `sampleBees`) | 2 |
|
||
| 20261014100500 | `t4_notification_fanout` | 8 | 3 |
|
||
| 20261014100600 | `t4_calendar_withdrawal` | 9 | 3 |
|
||
| 20261014100700 | `t4_occupied_frames` | 10 | 3 |
|
||
| 20261014100800 | `t4_notification_settings` | 11 (Teil Kategorie, Link, Rechte, `NotificationPreference`) | 3 |
|
||
| 20261015100000 | `t4_phenology` | 4 | 23 |
|
||
| 20261015100100 | `t4_honey_ledger` | 6 | 23 |
|
||
| 20261015100200 | `t4_product_shelf_life` | 7 (Teil `shelfLifeMonths`) | 23 |
|
||
| 20261015110000 | `t4_telegram` | 11 (Teil Telegram und `NotificationDelivery`) | 34 |
|
||
|
||
- Erzeugt mit `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script` gegen die Test-DB, danach von Hand nachgearbeitet (Kopfkommentar wie `20261012100000_calendar_sync_claims`). Nie `db push`, nie `migrate dev`.
|
||
- Bindend bleibt aus 4.12: Enum-Werte in eigener Migration vor jeder Nutzung; 8 vor 11; `category` der Altzeilen wird belegt, danach `NOT NULL`.
|
||
- Läuft ein Schema-Task in einer Welle mit anderen Tasks, wendet er seine Migrationen zuerst an (`migrate deploy`), erst danach starten die anderen ihre e2e-Läufe (Orchestrator sequenziert).
|
||
- Test-DB migrieren (Git Bash, in `ApiCore-Backend/`): `export DATABASE_URL="$(grep '^DATABASE_URL=' .env.test | cut -d= -f2- | tr -d '"')" && npx prisma migrate deploy && npx prisma generate`.
|
||
|
||
**Zeit**
|
||
- „Heute“ = Kalendertag Europe/Berlin (`berlinToday()`). Tage als `YYYY-MM-DD`; gespeichert als `DateTime` Mitternacht UTC (`dayToDate`), wie `Task.dueDate`.
|
||
- Zeitvergleiche für Claims, Leases, Token-Ablauf, Wiedereröffnen (30 Tage) und Veraltung laufen in SQL gegen `now()`. Berliner Uhrzeiten (Morgenzeit, Ruhezeit) werden aus einem einmal gelesenen `SELECT now()` berechnet (`berlinDateTime`).
|
||
- Job `daily-evaluation` 04:30 Europe/Berlin. Schritte in fester Reihenfolge `phenology` → `time-events` → `autopilot` → `suggestions-expiry` → `digest`, jeder in eigenen Transaktionen; ein gescheiterter Schritt hält die anderen nicht auf, `JobRun` ist dann `FAILED` **mit** allen Zählern. Zähler zusätzlich zu den bisherigen: `phenologyApiaries`, `seasonChanges`, `autopilotColonies`, `suggestionsOpened`, `suggestionsResolved`, `suggestionsExpired`, `digests`.
|
||
|
||
**Vorschläge**
|
||
- Enums exakt wie Spec 4.1 (`SuggestionStatus`, `SuggestionMode`, `SuggestionSource`, `SuggestionIntent`).
|
||
- `dedupeKey = "<ruleKey>:<relatedId|none>:<anker>"`, z. B. `INSPECTION_INTERVAL:col_1:insp_9`. Nullbar, `@unique`, kein partieller Index.
|
||
- Höchstens eine offene Sache je Absicht und Objekt: Ein offener Vorschlag oder eine offene Aufgabe (`OPEN`, `IN_PROGRESS`) derselben Absicht verhindert einen neuen Vorschlag. Absicht einer Aufgabe = `sourceSuggestion.intent`, sonst `INTENT_OF_LEGACY_TRIGGER[sourceAutomationTrigger]`, sonst `FOLLOW_UP`.
|
||
- Ein Vorschlag mit vorhandenem `dedupeKey` entsteht nie neu. Den Schlüssel behalten `OPEN`, `ACCEPTED`, `DISMISSED` und `OBSOLETE`; `EXPIRED` und `RESOLVED` setzen `dedupeKey = NULL` (im selben Statement wie der Statuswechsel). Sonst wäre derselbe Anker nach Ablauf (30 Tage) oder Erledigung nie wieder möglich und Erinnerungen wie A1 blieben danach aus. Neuer Anker = neuer Vorschlag; dieselbe Bedingung nach `EXPIRED`/`RESOLVED` darf beim nächsten Abgleich neu vorgeschlagen werden, ein verworfener oder übernommener Anker nicht.
|
||
- Wieder öffnen nur bis 30 Tage nach `decidedAt`. Bulk 1–50 IDs, alles oder nichts. `note` ≤ 500 Zeichen.
|
||
- `expiresAt = Fälligkeit + suggestionTtlDays (30)` für Autopilot-Vorschläge.
|
||
- Texte: höchstens 2 Gründe, je ≤ 90 Zeichen, deutsch formatiert (`12,4`, `05.05.`). `actionTemplate` einer Regel ersetzt nur den Titel, wenn es nicht leer ist.
|
||
- Jeder Übergang ist ein Compare-and-Set (`updateMany … where status = 'OPEN'` bzw. `'DISMISSED'`) mit Audit `entityType: 'Suggestion'` in derselben Transaktion. Fehlschlag → 409 `{ message: 'Der Vorschlag ist nicht mehr offen.', status }`.
|
||
- Art: Regel- und Kettenvorschläge sind `PLAN`; `CLEANUP` ist `DO`; Autopilot A1, A2, A3, A5, A6, A7, A9, A10 `DO`, A4 und A8 `PLAN`.
|
||
- Leserecht je Vorschlag (`requiredPermission`); Gründe nennen nur Daten aus diesem Bereich plus Volk-Code, Stand-Name und Saison:
|
||
|
||
| Absicht (Quelle) | `requiredPermission` |
|
||
|---|---|
|
||
| `QUEEN_CHECK` (Regel), `FEED` (Regel, A6), `SWARM_CHECK` (Kette Weiselzellen), `INSPECT` (A1), `HONEY_SUPER` (A7) | `inspections:read` |
|
||
| `VARROA_TREAT` (Regel), `VARROA_MEASURE` (A2) | `varroa:read` |
|
||
| `VARROA_RECHECK` (A3), `SUMMER_TREATMENT` (A4), `FOLLOW_UP` (Behandlung) | `treatments:read` |
|
||
| `FEED` (A5), `FOLLOW_UP` (Fütterung) | `feedings:read` |
|
||
| `SWARM_CHECK` (Regel Schwarmstimmung) | `swarm-control:read` |
|
||
| `QUEEN_ACCEPTANCE`, `QUEEN_REPLACE` (A8) | `queens:read` |
|
||
| `HEALTH_CHECK`, `SPLIT_PLAN`, `CLEANUP` | `colonies:read` |
|
||
| `WINTER_CHECK` (A9, A10) | `winter-checks:read` |
|
||
| `STOCK_REORDER` | `storage:read` (Lagerartikel) bzw. `raw-material:read` (Rohstoff) |
|
||
|
||
- `INTENT_OF_LEGACY_TRIGGER = { 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' }`.
|
||
|
||
**Regeln (`DEFAULT_RULES`, ein Parser je Trigger; ungültige Werte → Standard + Log-Warnung; `PATCH` → 400)**
|
||
|
||
| Trigger | aktiv | Standard-Config |
|
||
|---|---|---|
|
||
| `QUEENLESS_NO_EGGS`, `COLONY_HEALTH_CRITICAL` | ja | `{ notify: true }` (+ optional `dueInDays`) |
|
||
| `FEED_STATUS_CRITICAL`, `SWARM_MOOD_HIGH`, `STOCK_ITEM_LOW` | ja | `{ notify: false }` |
|
||
| `RAW_MATERIAL_LOW` | nein | `{ notify: false }` (+ `minGrams` je Sorte) |
|
||
| `TREATMENT_FOLLOW_UP`, `FEEDING_FOLLOW_UP` | nein | `{ notify: false }` (+ `dueInDays`) |
|
||
| `*_NUMBER_FORMAT` (4) | wie bisher | `{ format }` wie bisher |
|
||
| `VARROA_THRESHOLD` | ja | `{ 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 }`; Altwert `thresholdMitesPerDay` = `naturalFall.redAbove` |
|
||
| `INSPECTION_INTERVAL` | ja | `{ intervalDays: { FRUEHJAHR: 14, SCHWARMZEIT: 7, SOMMER: 14, SPAETSOMMER: 21, HERBST: 21 } }`; erlaubt `SCHWARMZEIT` 7–9, sonst 7–35 |
|
||
| `QUEEN_CELLS_SWARM_CHECK`, `QUEEN_ACCEPTANCE_CHECK` | ja | `{ dueInDays: 7, notify: false }` |
|
||
| `SPLIT_SCHEDULE` | ja | `{ 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 }` |
|
||
| `COLONY_ENDED_CLEANUP` | ja | `{}` |
|
||
| `WITHDRAWAL_ENDED` | ja | `{ notify: true }` |
|
||
| `HONEY_WATER_CONTENT` | ja | `{ warnAbovePercent: 18, confirmAbovePercent: 20, byHoneyType: {} }` (Heidekraut nur per Config, z. B. `{ "Heidehonig": { confirmAbovePercent: 23 } }`) |
|
||
| `SEASON_AUTOPILOT` | ja | `{ honeySuperMinFrames: 8, varroaMeasureIntervalDays: 28, winterVarroaWindow: { from: '11-15', to: '12-20' }, queenMaxAgeYears: 2, suggestionTtlDays: 30, maxStepsPerColony: 3, rules: { A1: true, …, A10: true } }` |
|
||
| `PHENOLOGY` | ja | `{ 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 }` |
|
||
|
||
- Grenzen (Validierung): `dueInDays` 0–365; Varroa-Werte endlich ≥ 0, `yellowFrom ≤ redAbove`, Monate 1–12 eindeutig, `youngColonyFactor` (0, 1], `recheckAfterDays` 0–60; Wassergehalt 10–30, `warn ≤ confirm`; `honeySuperMinFrames` 1–40; `varroaMeasureIntervalDays` 7–90; `queenMaxAgeYears` 1–10; `suggestionTtlDays` 1–365; `maxStepsPerColony` 1–3; `MM-DD` gültig, Kalender-Rückfälle in Phasenreihenfolge nicht fallend; `swarmSeason.from` vor `until`; `maxGapShare` 0–0,5; GTS-Schwellen > 0; Plan-Schritte `offsetDays` 0–120, `windowDays` 0–30.
|
||
- `PATCH /automation/rules/:id` mit ungültigem bekanntem Schlüssel → 400 `{ message: 'Die Einstellung ist ungültig.', issues: [{ field, message }] }` (deutsche Feldmeldung). Unbekannte Schlüssel bleiben unverändert.
|
||
|
||
**Fachwerte**
|
||
- Varroa (Spec 7.1): rot `Wert > redAbove`, gelb `Wert ≥ yellowFrom` (10,0/Tag = gelb). Natürlicher Milbenfall März–Oktober 5/10, November–Februar nur rot über 0,5. Puderzucker/Auswaschung Mai–Juli 1/5 %, August–Oktober 2/5 %, November–April keine Ampel. Drohnenbrut keine Ampel. Jungvölker (`youngColonyTypes`): Schwellen des natürlichen Milbenfalls × 0,5. Messtag ab Behandlungsbeginn bis vor `(endDate ?? date) + 14` → keine Ampel, Text „Behandlungseffekt – Nachkontrolle ab {TT.MM.}“, die Regel schlägt nichts vor. Rot → `VARROA_TREAT`, `HIGH`, Anker Mess-ID. Probe `sampleBees` 1–2000; bei `POWDERED_SUGAR`/`WASHING_METHOD` ist `calculatedValue = miteCount / sampleBees × 100`.
|
||
- Durchsicht fällig (Spec 7.2): `inspectionDueOn` = letzte Durchsicht (sonst `colony.createdAt`) + Intervall des heutigen Abschnitts; liegt die letzte Durchsicht vor dem Beginn von `FRUEHJAHR`, ist das Volk ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“); in `WINTERRUHE` `null`. A1 ab `inspectionDueOn ≤ heute + 2`, überfällig → `HIGH`.
|
||
- Saison (Spec 7.3): GTS = Σ positiver Tagesmittel ab 1. Januar, Januar × 0,5, Februar × 0,75, ab März × 1; > 10 % fehlende Tage → „Wetterdaten lückenhaft“, nicht benutzt; GTS älter als 3 Tage → Kalender. Effektive Phase = Maximum aus Meldung, GTS, Kalender; läuft im Jahr nur vorwärts, außer nach einer zurückgezogenen Meldung. Abschnitte: `WINTERRUHE` (ab `WINTER` oder vor `SALWEIDE`), `FRUEHJAHR` (ab `SALWEIDE`), `SCHWARMZEIT` (ab `swarmSeason.from`), `SOMMER` (ab `swarmSeason.until`, Standard `LINDE`), `SPAETSOMMER` (ab `TRACHTENDE`), `HERBST` (ab `HERBST`).
|
||
- Ketten (Spec 7.4): Weiselzellen → `SWARM_CHECK` +7; Königin zugesetzt → `QUEEN_ACCEPTANCE` +7; Ableger `RAISE_OWN` → `SPLIT_PLAN` mit +9 / +21 (Fenster bis +24) / +28; `QUEEN_CELL` → `SPLIT_PLAN` +21; `QUEEN` → `QUEEN_ACCEPTANCE` +7; ohne Angabe keine Kette. Fälligkeit ab fachlichem Datum.
|
||
- Wartezeit-Fenster: von Behandlungsbeginn (`startDate`, falls vor `date`, sonst `date`) bis ausschließlich `(endDate ?? date) + withdrawalPeriodDays` (= erster erntefähiger Tag = `WithdrawalEnded.endedOn`). Ohne `withdrawalPeriodDays` kein Fenster, keine Warnung.
|
||
- Alter der Königin: gealtert, wenn `aktuelles Jahr − hatchYear ≥ queenMaxAgeYears` (2). Gilt für `QueenAged`, Dashboard `OLD_QUEEN` und A8; `OLD_QUEEN_AGE_YEARS` entfällt.
|
||
- Volksstärke: `Colony.occupiedFrames` und `Inspection.colonyStrength` 0–40 besetzte Waben, 400 „Höchstens 40 besetzte Waben.“; `Colony.strength` (1–10) bleibt unverändert, Swagger `deprecated`, nie umgerechnet.
|
||
- Futter: `FEED_CONDITION_LABELS` `EXCELLENT` „Reichlich“, `GOOD` „Ausreichend“, `AVERAGE` „Mittel“, `WEAK` „Niedrig“, `CRITICAL` „Kritisch“; Altwerte `ausreichend`→`GOOD`, `niedrig`→`WEAK`, `kritisch`→`CRITICAL` (getrimmt, ohne Groß-/Kleinschreibung), leer → `null`, sonst `null` + „Futter (alt): {Text}“ am Ende von `notes`.
|
||
- Honig: Warnungen in einem Format (`PlausibilityWarning`, Spec 10.3) mit Stufe je Code: `WITHDRAWAL_ACTIVE` WARNUNG, `EXTRACTION_EXCEEDS_HARVEST` WARNUNG, `HARVEST_NET_MISSING` HINWEIS, `BATCHES_EXCEED_EXTRACTION` WARNUNG, `WATER_CONTENT_HIGH` WARNUNG, `WATER_CONTENT_ABOVE_LEGAL` BESTAETIGEN, `WATER_CONTENT_MISSING` HINWEIS, `JAR_SIZE_DIFFERS` HINWEIS. Nie 4xx außer `POST /bottling` über der gesetzlichen Grenze ohne `acknowledgedWarnings` → 409 `{ code: 'CONFIRMATION_REQUIRED', warnings }`.
|
||
- Bewegungsbuch: `quantity` in Gramm (Int, Vorzeichen); `amount = initialAmount + Σ quantity / 1000`, auf Gramm gerundet, nie < 0; Toleranz 0,5 g; Texte „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“, „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“, „Die Menge kann nicht unter 0 sinken.“
|
||
|
||
**Benachrichtigungen**
|
||
- Jede Zeile hat einen Empfänger (`userId` Pflicht). Geschrieben wird nur an aktive Nutzer mit aktiver Rolle, die **jedes** Recht aus `requiredPermissions` halten; gespeicherte `requiredPermissions` = Empfänger-Rechte ∪ Inhaltsrechte.
|
||
- `NotificationCategory`: `TASK_DUE`, `DIGEST`, `SUGGESTION`, `WITHDRAWAL`, `SEASON`, `SYSTEM`.
|
||
- `dedupeKey = "<präfix>:<userId>"`; Präfixe: `task-due:<taskId>:<Tag>`, `digest:<Tag>`, `suggestion:<suggestionId>`, `withdrawal-conflict:<harvestId>:<colonyId>`, `withdrawal-ended:<treatmentId>`, `season-segment:<apiaryId>:<segment>:<Tag>`, `job-failed:<job>:<Tag>`, `telegram-token-invalid:<Tag>`.
|
||
- `link` = relativer App-Pfad: beginnt mit `/`, nicht mit `//` oder `/\`, kein Schema, ≤ 300 Zeichen; sonst Programmfehler (`Error`).
|
||
- Erinnerungen: `NotificationPreference` Standard `taskDueEnabled true`, `digestEnabled true`, `digestTime '06:30'` (05:00–12:00, sonst 400 „Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.“), Ruhezeit `21:00`–`06:30` (nur paarweise leerbar), `telegramEnabled false`, `telegramCategories [TASK_DUE, DIGEST]`, `telegramDetail true`. Fehlt die Zeile, gelten die Standards.
|
||
|
||
**Telegram**
|
||
- `TELEGRAM_BOT_TOKEN` nur im Worker (Secret `apicore-telegram`, `optional: true`), Format `^\d{5,}:[A-Za-z0-9_-]{30,}$`, in Produktion kein Platzhalter (`change-me…`); sonst startet der Worker nicht. `TELEGRAM_BOT_USERNAME` (ConfigMap) in api und Worker. Ohne Token laufen beide Telegram-Schleifen nicht. **API-Seite:** Der API-Pod hat nie das Token; „Telegram ist eingerichtet“ heißt dort `telegramBotAvailable()` = `TELEGRAM_BOT_USERNAME` nicht leer (Link-Token 409, `NotificationSettingsView.telegram.configured`, `TelegramStatusView.configured`). Ob der Empfang tatsächlich läuft, zeigt nur `TelegramPollState` (Task 37).
|
||
- Verbinden: Step-up (Passwort, bei MFA TOTP), Drossel 10/min, Token 32 Zufallsbytes Base64url (43 Zeichen), gespeichert nur SHA-256 (hex), 10 Minuten, einmalig, neues Token entwertet ältere. Nicht `@Idempotent()` (Antwort enthält ein Geheimnis, Annahme 11).
|
||
- Versand `telegram-send`: Takt 5 000 ms, Weckkanal `notification_delivery`, Batch 25, je Batch höchstens eine Zustellung je Nutzer bzw. Chat, ≤ 25 Nachrichten/s, je Chat ≥ 1 100 ms Abstand, Timeout 10 s, Klartext ohne `parse_mode`, Steuerzeichen entfernt, ≤ 600 Zeichen, Link-Vorschau aus. 429 → `retry_after`, kein Fehlversuch; 403/400 „chat not found“ → Verbindung lösen, `SKIPPED`; 401 → 15 min warten (kein Fehlversuch), Meldung an `system:manage` einmal je Tag; 5xx/Timeout/Netz → `retryDelayMs`, nach 8 Versuchen `FAILED`. Veraltet: `TASK_DUE`/`DIGEST` nur am selben Berliner Tag, sonst ≤ 24 h.
|
||
- `availableAt` = spätester Wert aus jetzt, Kategorie-Zeit (`DIGEST` = Morgenzeit, `TASK_DUE` = Morgenzeit + 1 min) und Ende der Ruhezeit, falls jetzt darin liegt; Systemantworten des Bots umgehen die Ruhezeit.
|
||
- Empfang `telegram-updates`: `getUpdates` mit `timeout=25`, `allowed_updates=["message"]`, Abruf-Timeout 35 s; Lease 60 s nur mit DB-Uhr (SQL aus Spec 9a.7); ohne Lease oder nach 409 30 s warten; nur private Chats; höchstens 3 Antworten je Chat in 10 Minuten; `updateOffset = update_id + 1` in derselben Transaktion wie die Wirkung.
|
||
- Texte exakt: `LINKED` „Verbunden mit ApiCore. Hier kommen deine Erinnerungen. /stop beendet das.“, `LINK_INVALID` „Der Link ist ungültig oder abgelaufen. Bitte in ApiCore neu verbinden.“, `STOPPED` „Abgemeldet. Du bekommst keine Nachrichten mehr.“, `HELP` „Dieser Bot schickt Erinnerungen aus ApiCore. Verbinden geht nur in ApiCore unter Profil > Benachrichtigungen. /stop beendet.“
|
||
|
||
**Wetter (T4b)**
|
||
- `WEATHER_HISTORY_SOURCE` (Standard `open-meteo`), `OPEN_METEO_FORECAST_URL` (`https://api.open-meteo.com/v1/forecast`), `OPEN_METEO_ARCHIVE_URL` (`https://archive-api.open-meteo.com/v1/archive`), `OPEN_METEO_API_KEY` (leer, Secret). Nur `https:`, nur aus der Konfiguration. Schlüssel und URL nie in Logs oder `lastError`.
|
||
- Schleife `phenology-weather`: 3 600 000 ms, kein Weckkanal; Koordinaten auf 0,01° gerundet, gleiche Punkte teilen eine Anfrage; letzte 90 Tage über Forecast mit `past_days=92`, ältere Lücken über Archiv; Timeout 8 s; Antwort ≤ 1 000 000 Byte; Werte außerhalb −50…50 °C → `null`. `cleanup` löscht `ApiaryWeatherDay` vor dem 1. Januar von (Jahr − 4).
|
||
|
||
**Rechte:** Gruppe „Pflanzenentwicklung“, Ressource `phenology`: `read` „Ansehen“, `create` „Melden“, `update` „Bearbeiten“. Migration vergibt `read` an Nicht-System-Rollen mit `colonies:read`, `create`/`update` an Nicht-System-Rollen mit `inspections:create` (Muster `winter-checks`, Annahme 16). Jede neue Route trägt `@RequirePermission` oder `@AnyAuthenticatedUser` (Profil-Routen); `test/authorization-coverage.e2e-spec.ts` erfasst alle Controller automatisch.
|
||
|
||
**Projektregeln**
|
||
- Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-`tx` als erster Parameter, optionaler `tx` als letzter.
|
||
- **Read-modify-write nur unter Zeilensperre:** Jede Änderung, die auf dem bisherigen Zustand aufbaut (Statuswechsel, Differenzbuchung, Zusammenführen mit gespeicherten Werten, Audit mit `beforeData`), liest `before` **innerhalb** der Transaktion nach `lockRowForUpdate(tx, table, id)` aus `src/prisma/row-lock.ts` – nie aus einem Lesen vor der Transaktion. Neue Tabellen kommen in die feste Bezeichner-Map `LOCKABLE_TABLES` (nie ein Tabellenname aus Eingaben); T4a-Tabellen trägt Task 1 ein, T4b-Tabellen Task 23, T4c-Tabellen Task 34. Sperren mehrere Zeilen, dann in fester Reihenfolge `Colony` → `Suggestion` → `Task` → `Queen` → `AutomationRule` → übrige Tabellen in Map-Reihenfolge, innerhalb einer Tabelle Schlüssel aufsteigend (die Map steht in genau dieser Reihenfolge). Compare-and-Set bleibt zusätzlich, wo es im Plan steht. Reine Ein-Anweisungs-Updates ohne gelesenen Vorzustand (`updateMany … where status = …`, Lease-SQL, Claims) brauchen keine zusätzliche Sperre.
|
||
- **Sperren bei Vorschlägen:** `SuggestionsService.proposeIn` sperrt zuerst die Volkszeile (`lockRowForUpdate(tx, 'Colony', colonyId)`, wenn `colonyId` gesetzt; `syncColonyIn` und der Handler `suggestions.colony-ended` halten sie dann schon) und danach die Regelzeile (`lockRuleIn`). Das serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, sodass die Absichts-Prüfung über Regeln hinweg keine Dublette durchlässt; die Reihenfolge entspricht der Map (`Colony` vor `AutomationRule`). Kein Pfad sperrt erst `AutomationRule` und dann `Colony`; `accept`, `dismiss`, `reopen` und `apply-cleanup` sperren `Suggestion` → `Task` → `Queen` und nie `Colony`.
|
||
- Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20); jede neue oder geänderte Update-DTO steht in `src/common/validation/update-dto-null-handling.spec.ts`.
|
||
- Handler schreiben nur über `tx`, keine Netzaufrufe; Handler-IDs `<consumer>.<name>`; jedes Modul mit Handlern wird von `AppModule` **und** `WorkerModule` importiert. Payloads und `evidence` tragen nur IDs, Enum-Werte, Zahlen, Tage.
|
||
- Beobachtungen werden nie still vorbelegt; vorbelegt wird nur, was keine Beobachtung ist (Glasgröße, MHD aus dem Artikel). Warnungen sperren nie (Annahme 10), außer der Bestätigung beim Wassergehalt.
|
||
- UI deutsch, kurze Sätze, ein Hauptknopf je Ansicht, Zustand nie nur über Farbe (Icon + Wort), Touch-Ziele ≥ 44 px, Sheet-Zeilen 56 px (`min-h-14`), `role="status"`/`aria-live="polite"` für Warnungen und Live-Werte, WCAG 2.2 AA. Code-Bezeichner, Log- und Fehlertexte (außer Nutzertexten) englisch.
|
||
- TDD: zuerst der fehlschlagende Test. Testnamen englisch.
|
||
- **Agents committen nicht.** Jeder Task endet mit „Verifikation“ und einer Dateiliste an den Orchestrator; der Orchestrator committet zentral.
|
||
- Backend-Checks: `npm run typecheck && npx eslint "{src,test}/**/*.ts" && npm test -- --ci`; einzelne e2e-Datei `npx jest --config ./test/jest-e2e.json test/<datei>.e2e-spec.ts --runInBand`.
|
||
- Frontend: vor Next.js-Code `node_modules/next/dist/docs/` lesen (`AGENTS.md`); Checks `npm run lint && npm run typecheck && npx vitest run <pfade>`. Diagramme: vorher den `dataviz`-Skill laden.
|
||
- `ARCHITECTURE.md` und `TASKS.md` trägt der Orchestrator ein. Jeder Task liefert seinen Doku-Text im Bericht („Docs“). Neue Annahmen bekommen die **nächste freie Nummer** zum Zeitpunkt des Eintrags.
|
||
|
||
---
|
||
|
||
## Review Focus
|
||
|
||
1. **Rechte-Leck über Vorschläge, Schritte, Übersicht und Meldungen.** Wer kein `varroa:read` hat, darf weder „12,4 Milben pro Tag“ noch den Vorschlag, noch dessen Zählung, noch eine Telegram-Nachricht dazu sehen.
|
||
- Task 5 e2e `a user without varroa:read sees neither the varroa suggestion nor its count`
|
||
- Task 15 e2e `next steps never carry a varroa reason without varroa:read`
|
||
- Task 3 e2e `a fan-out for colonies:read never reaches a user without it`
|
||
- Task 16 e2e `the digest of a user without varroa or inspection rights has no such line`
|
||
- Task 35 e2e `a revoked permission skips the delivery at send time`
|
||
2. **Doppelte Vorschläge oder Aufgaben** bei erneuter Zustellung, Doppeltipp, zwei Geräten, parallelen Handlern oder Kette plus Autopilot.
|
||
- Task 4 unit `proposeIn with an existing dedupe key creates nothing`
|
||
- Task 5 e2e `a second accept with another key answers 409 and leaves exactly one task`
|
||
- Task 7 e2e `a dismissed suggestion is not proposed again for the same inspection`
|
||
- Task 11 e2e `a split with QUEEN plus a later assignment yields one acceptance suggestion`
|
||
- Task 14 e2e `two colony syncs in parallel leave one INSPECT suggestion`
|
||
- Task 14 e2e `an expired INSPECT suggestion of the same anchor is proposed again on the next sync`
|
||
3. **Datenmigrationen und Bewegungsbuch verfälschen Bestände oder verlieren Inhalte** (Futter-Freitext, Rundmeldungen mit `read`, Altabfüllungen, parallele Abfüllungen).
|
||
- Task 2 e2e `free-text feed moves into notes and known words map to the enum`
|
||
- Task 3 e2e `broadcasts are copied per active user with their read flag`
|
||
- Task 23 e2e `legacy bottlings keep amount = initialAmount + movements`
|
||
- Task 29 e2e `two parallel bottlings over the rest: exactly one 400`
|
||
- Task 29 guard `honey batch amount is written only by the ledger`
|
||
4. **Telegram: Token-Leck, doppelte Zustellung, fremde Verbindung.**
|
||
- Task 34 unit `the token never appears in TelegramApiError or loggableError`
|
||
- Task 35 e2e `two send loops in parallel deliver each message exactly once`
|
||
- Task 36 e2e `link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID`
|
||
- Task 36 e2e `only the lease holder polls; a second worker waits`
|
||
- Task 36 unit `a new link shows an in-app notice with the Telegram first name`
|
||
5. **Zeit: Berliner Tag gegen UTC, Sommerzeit, Monatsende, Ruhezeit über Mitternacht.**
|
||
- Task 1 unit `berlinToday at 2026-03-28T23:30Z is 2026-03-29`
|
||
- Task 1 unit `berlinDateTime 2026-03-29 06:30 is 04:30Z on the day DST starts`
|
||
- Task 13 unit `the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey`
|
||
- Task 30 unit `best before 2027-01-31 plus 1 month is 2027-02-28`
|
||
- Task 35 unit `a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day`
|
||
|
||
---
|
||
|
||
## Abhängigkeiten und Parallelisierung
|
||
|
||
| Welle | Tasks (parallel, verschiedene Dateien) | wartet auf |
|
||
|---|---|---|
|
||
| 1 | 1 | Rückgrat, T3 |
|
||
| 2 | 2, 4, 13, 22 | 1 |
|
||
| 3 | 3, 5, 6, 10 | 3 → 2; 5 → 4; 6 → 1, 2; 10 → 2 |
|
||
| 4 | 7, 8, 11, 14, 17, 19 | 7 → 3, 4, 6; 8 → 2, 3; 11 → 4, 10; 14 → 4, 10, 13; 17 → 5; 19 → 6 |
|
||
| 5 | 9, 12, 15, 20 | 9 → 4, 8; 12 → 3, 6, 10; 15 → 5, 6, 14; 20 → 8, 10 |
|
||
| 6 | 16, 18, 21 | 16 → 3, 14, 15; 18 → 9, 15; 21 → 3, 12 |
|
||
| — | **Rollout T4a** | 1–22 |
|
||
| 7 | 23 | T4a |
|
||
| 8 | 24, 25, 28, 34 | 23 (34 → T4a) |
|
||
| 9 | 26, 27, 29, 35 | 26 → 25; 27 → 25; 29 → 28; 35 → 34 |
|
||
| 10 | 30, 31, 32, 36, 37 | 30 → 29; 31 → 26; 32 → 28, 29; 36, 37 → 35 |
|
||
| 11 | 33, 38 | 33 → 30; 38 → 36, 37 |
|
||
| — | **Rollout T4b + T4c** | 23–38 |
|
||
|
||
Geteilte Dateien, deshalb je Welle nur ein Task:
|
||
- `prisma/schema.prisma`, `src/backup/backup.service.ts` (+ Spec): 1 (W1), 2 (W2), 3 (W3), 23 (W7), 34 (W8)
|
||
- `src/prisma/row-lock.ts` (+ Spec): 1 (W1), 23 (W7), 34 (W8) – alle übrigen Tasks nutzen den Helfer nur
|
||
- `src/app.module.ts`, `src/worker/worker.module.ts`: 4 (W2), 14 (W4), 12 (W5), 16 (W6), 25 (W8), 35 (W9, nur Worker)
|
||
- `src/jobs/jobs.module.ts`, `src/jobs/daily-evaluation.job.ts`: 13 (W2), 14 (W4), 16 (W6), 25 (W8)
|
||
- `src/automation/automation.service.ts`: 1 (W1), 2 (W2), 3 (W3), 7 (W4)
|
||
- `src/calendar-sync/calendar-sync.service.ts`: 1 (W1), 3 (W3), 12 (W5); `calendar-sync.handlers.ts`: 10 (W3), 12 (W5)
|
||
- `src/colonies/colonies.service.ts`: 10 (W3), 8 (W4), 9 (W5)
|
||
- `src/domain-events/domain-event-types.ts`: 10 (W3), 9 (W5), 23 (W7)
|
||
- `src/common/validation/update-dto-null-handling.spec.ts`: 3 (W3), 8 (W4), 26 (W9), 30 (W10)
|
||
- `src/apiaries/apiary-field.service.ts` (+ Typen): 3 (W3), 8 (W4), 15 (W5)
|
||
- `src/system/job-run.service.ts`: 13 (W2), 3 (W3)
|
||
- `src/suggestions/suggestions.module.ts`: 4 (W2), 5 (W3), 11 (W4), 9 (W5); `suggestions.controller.ts`: 5 (W3), 9 (W5)
|
||
- `src/tasks/tasks.service.ts`: 4 (W2), 9 (W5); `src/queens/queens.service.ts`: 10 (W3), 9 (W5)
|
||
- `src/bottling/bottling.service.ts`: 12 (W5), 29 (W9), 30 (W10); `src/honey-batches/honey-batches.service.ts`: 28 (W8), 29 (W9), 30 (W10)
|
||
- `src/webhooks/webhook.handlers.ts`: 10 (W3), 28 (W8); `src/common/after-commit-usage.spec.ts`: 10 (W3), 12 (W5), 28 (W8); `src/withdrawal/withdrawal.handlers.ts`: 12 (W5), 28 (W8)
|
||
- `src/autopilot/*`: 14 (W4), 15 (W5), 27 (W9); `src/season/season.service.ts`: 13 (W2), 25 (W8)
|
||
- `src/notifications/notifications.service.ts`: 3 (W3), 35 (W9); `src/common/dates.ts`: 1 (W1), 30 (W10)
|
||
- `src/worker/worker-runtime.service.ts`: 24 (W8), 35 (W9), 36 (W10)
|
||
- `src/treatments/withdrawal-window.ts`, `src/treatments/treatments.service.ts`: nur 6 (W3); `src/jobs/daily-evaluation.rules.ts`: 13 (W2), 6 (W3) – Task 12 fasst sie nicht mehr an
|
||
- `src/suggestions/suggestions.service.ts`: 4 (W2), 7 (W4); `src/jobs/cleanup.job.ts`: 24 (W8), 35 (W9); `src/auth/permissions/permission-resolver.service.ts`: nur 35 (W9)
|
||
- `src/bottling/bottling.module.ts`: 12 (W5), 29 (W9); `src/harvests/harvests.module.ts`: nur 12 (W5); `.gitignore`: nur 2 (W2)
|
||
- `.env.example`, `deploy/k8s/**`: 24 (W8), 37 (W10)
|
||
- Frontend: `src/app/(app)/colonies/[id]/page.tsx`: 20 (W5), 18 (W6); `src/components/suggestions/suggestion-card.tsx`: 17 (W4), 18 (W6); `src/app/(app)/profile/page.tsx`: 21 (W6), 38 (W11); `src/app/(app)/apiaries/[id]/*`: 18 (W6), 31 (W10)
|
||
- `ARCHITECTURE.md`, `TASKS.md`: nur der Orchestrator.
|
||
|
||
---
|
||
|
||
## Tasks
|
||
|
||
## Lieferung T4a – Vorschläge, Ketten, Autopilot-Kern, Erinnerungen in der App
|
||
|
||
### Task 1: Schema I – Regel-Werte, Vorschlagsmodell, Tagesrechnung
|
||
|
||
**Files**
|
||
- Modify: `prisma/schema.prisma`
|
||
- Create: `prisma/migrations/20261014100000_t4_enum_values/migration.sql`, `prisma/migrations/20261014100100_t4_suggestions/migration.sql`
|
||
- Create: `src/common/dates.ts` (+ `.spec.ts`), `src/season/season-types.ts`
|
||
- Create: `src/automation/rule-config/` mit `rule-config.types.ts`, `parse-helpers.ts`, `default-rules.ts`, `simple.config.ts`, `varroa-threshold.config.ts`, `inspection-interval.config.ts`, `chain.config.ts`, `split-schedule.config.ts`, `water-content.config.ts`, `season-autopilot.config.ts`, `phenology.config.ts`, `rule-config.reader.ts`, `index.ts`, `rule-config.spec.ts`
|
||
- Modify: `src/automation/automation.service.ts` (`DEFAULT_RULES` aus `default-rules.ts`; `update` validiert und liest `before` unter Sperre), `src/automation/varroa-threshold.ts` (+ Spec), `src/calendar-sync/calendar-sync.service.ts` (`WITHDRAWAL` vorläufig aus), `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec; T4a-Tabellen, Schlüsselspalte je Eintrag)
|
||
- Test: `test/automation.e2e-spec.ts`, neu `test/t4-schema.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE §8 „Export/Import“: `suggestion` ist Teil des Bundles (Import vor `task`).
|
||
|
||
**Interfaces**
|
||
- Consumes: –
|
||
- Produces:
|
||
```prisma
|
||
// Migration 1: nur Enum-Werte
|
||
enum AutomationTriggerType { … INSPECTION_INTERVAL QUEEN_CELLS_SWARM_CHECK QUEEN_ACCEPTANCE_CHECK SPLIT_SCHEDULE COLONY_ENDED_CLEANUP WITHDRAWAL_ENDED HONEY_WATER_CONTENT SEASON_AUTOPILOT PHENOLOGY }
|
||
enum BatchMovementType { … BOTTLING }
|
||
enum CalendarSyncSourceType { … WITHDRAWAL }
|
||
// Migration 2: SuggestionStatus, SuggestionMode, SuggestionSource, SuggestionIntent und model Suggestion exakt wie Spec 4.1;
|
||
// Task.sourceSuggestionId String? (Relation onDelete: SetNull, @@index([sourceSuggestionId])); Gegenrelationen an User und Colony
|
||
```
|
||
```ts
|
||
// src/common/dates.ts
|
||
export function berlinToday(now?: Date): string; // YYYY-MM-DD in Europe/Berlin
|
||
export function berlinDayOf(instant: Date): string;
|
||
export function berlinDateTime(day: string, hhmm: string): Date; // Berliner Ortszeit -> Instant (Sommerzeit beachtet)
|
||
export function dayToDate(day: string): Date; // Mitternacht UTC
|
||
export function addDays(day: string, days: number): string;
|
||
export function daysBetween(from: string, to: string): number;
|
||
// src/season/season-types.ts (die Prisma-Enums folgen in Task 23 mit denselben Werten)
|
||
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;
|
||
export interface DailyMean { day: string; meanTempC: number | null } // Tagesmittel; Task 24 (Wetterquelle) und Task 25 (GTS) importieren es von hier
|
||
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;
|
||
}
|
||
// src/automation/rule-config
|
||
export interface RuleConfigIssue { field: string; message: string }
|
||
export interface RuleConfigParser<T> { readonly defaults: T; parse(json: unknown): T; validate(json: unknown): RuleConfigIssue[] }
|
||
export interface SimpleRuleConfig { notify: boolean; dueInDays?: number; minGrams?: Partial<Record<RawMaterialKind, number>> }
|
||
export interface NumberFormatConfig { format: string }
|
||
export interface VarroaThresholdConfig {
|
||
naturalFall: { seasonMonths: number[]; yellowFrom: number; redAbove: number; winterRedAbove: number };
|
||
infestationPercent: { summerMonths: number[]; summer: { yellowFrom: number; redAbove: number }; autumnMonths: number[]; autumn: { yellowFrom: number; redAbove: number } };
|
||
youngColonyFactor: number; youngColonyTypes: ColonyType[]; recheckAfterDays: number; notify: boolean;
|
||
}
|
||
export interface InspectionIntervalConfig { intervalDays: Record<Exclude<SeasonSegmentKey, 'WINTERRUHE'>, number> }
|
||
export interface ChainConfig { dueInDays: number; notify: boolean }
|
||
export interface PlanStepConfig { step: string; offsetDays: number; windowDays?: number }
|
||
export interface SplitScheduleConfig { RAISE_OWN: PlanStepConfig[]; QUEEN_CELL: PlanStepConfig[]; QUEEN: PlanStepConfig[]; notify: boolean }
|
||
export interface WaterContentConfig { warnAbovePercent: number; confirmAbovePercent: number; byHoneyType: Record<string, { warnAbovePercent?: number; confirmAbovePercent?: number }> }
|
||
export const AUTOPILOT_RULE_IDS = ['A1','A2','A3','A4','A5','A6','A7','A8','A9','A10'] as const;
|
||
export type AutopilotRuleId = (typeof AUTOPILOT_RULE_IDS)[number];
|
||
export interface SeasonAutopilotConfig { honeySuperMinFrames: number; varroaMeasureIntervalDays: number; winterVarroaWindow: { from: string; to: string };
|
||
queenMaxAgeYears: number; suggestionTtlDays: number; maxStepsPerColony: number; rules: Record<AutopilotRuleId, boolean> }
|
||
export interface PhenologyConfig { useGts: boolean; gtsThresholds: Partial<Record<PhenologyPhaseKey, number>>; calibrateFromReports: boolean;
|
||
calibratePhases: PhenologyPhaseKey[]; calendarFallback: Record<PhenologyPhaseKey, string>; swarmSeason: { from: PhenologyPhaseKey; until: PhenologyPhaseKey }; maxGapShare: number }
|
||
export interface RuleConfigMap { // ein Eintrag je AutomationTriggerType
|
||
VARROA_THRESHOLD: VarroaThresholdConfig; INSPECTION_INTERVAL: InspectionIntervalConfig; QUEEN_CELLS_SWARM_CHECK: ChainConfig; QUEEN_ACCEPTANCE_CHECK: ChainConfig;
|
||
SPLIT_SCHEDULE: SplitScheduleConfig; COLONY_ENDED_CLEANUP: Record<string, never>; WITHDRAWAL_ENDED: { notify: boolean }; HONEY_WATER_CONTENT: WaterContentConfig;
|
||
SEASON_AUTOPILOT: SeasonAutopilotConfig; PHENOLOGY: PhenologyConfig;
|
||
HONEY_BATCH_NUMBER_FORMAT: NumberFormatConfig; BOTTLING_LOT_NUMBER_FORMAT: NumberFormatConfig; RAW_MATERIAL_LOT_NUMBER_FORMAT: NumberFormatConfig; PRODUCTION_BATCH_NUMBER_FORMAT: NumberFormatConfig;
|
||
QUEENLESS_NO_EGGS: SimpleRuleConfig; FEED_STATUS_CRITICAL: SimpleRuleConfig; SWARM_MOOD_HIGH: SimpleRuleConfig; COLONY_HEALTH_CRITICAL: SimpleRuleConfig;
|
||
STOCK_ITEM_LOW: SimpleRuleConfig; RAW_MATERIAL_LOW: SimpleRuleConfig; TREATMENT_FOLLOW_UP: SimpleRuleConfig; FEEDING_FOLLOW_UP: SimpleRuleConfig;
|
||
}
|
||
export const RULE_CONFIG_PARSERS: { [K in AutomationTriggerType]: RuleConfigParser<RuleConfigMap[K]> };
|
||
export function ruleConfigOf<K extends AutomationTriggerType>(trigger: K, json: unknown): RuleConfigMap[K];
|
||
export function validateRuleConfig(trigger: AutomationTriggerType, json: unknown): RuleConfigIssue[];
|
||
export interface RuleDefaults { config: Record<string, unknown>; actionTemplate: string; active?: boolean }
|
||
export const DEFAULT_RULES: Record<AutomationTriggerType, RuleDefaults>; // neue Regeln mit actionTemplate ''
|
||
// rule-config.reader.ts
|
||
export interface ActiveRule<K extends AutomationTriggerType> { id: string | null; active: boolean; config: RuleConfigMap[K]; actionTemplate: string | null }
|
||
export function readRule<K extends AutomationTriggerType>(db: DbClient, trigger: K): Promise<ActiveRule<K>>; // keine Zeile -> Standards
|
||
export function lockRuleIn<K extends AutomationTriggerType>(tx: Prisma.TransactionClient, trigger: K): Promise<ActiveRule<K> & { id: string }>; // upsert aus DEFAULT_RULES, lockRowForUpdate(tx, 'AutomationRule', id), dann lesen
|
||
// src/prisma/row-lock.ts – Map-Einträge werden { table: Prisma.Sql; key: Prisma.Sql } (Standard-Schlüssel "id"); neu:
|
||
// Suggestion, Queen, AutomationRule ("id") und NotificationPreference (Schlüssel "userId"; Tabelle entsteht in Task 3).
|
||
// Map-Reihenfolge = Sperr-Reihenfolge: Colony, Suggestion, Task, Queen, AutomationRule, dann die bisherigen übrigen, NotificationPreference
|
||
export function lockRowForUpdate(tx: Prisma.TransactionClient, table: LockableTable, id: string): Promise<void>; // Signatur unverändert
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// dates.spec.ts
|
||
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('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', ...);
|
||
it('addDays crosses month ends', () => expect(addDays('2026-02-27', 2)).toBe('2026-03-01'));
|
||
it('daysBetween counts calendar days', () => expect(daysBetween('2026-05-01', '2026-05-09')).toBe(8));
|
||
// rule-config.spec.ts
|
||
it('every trigger has defaults and a parser', () => {
|
||
for (const t of Object.values(AutomationTriggerType)) { expect(DEFAULT_RULES[t]).toBeDefined(); expect(RULE_CONFIG_PARSERS[t]).toBeDefined(); }
|
||
});
|
||
it('every default config parses to itself and validates without issues', ...);
|
||
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('falls back per invalid key and keeps the valid ones', () =>
|
||
expect(ruleConfigOf('SEASON_AUTOPILOT', { queenMaxAgeYears: 'x', honeySuperMinFrames: 10 })).toMatchObject({ queenMaxAgeYears: 2, honeySuperMinFrames: 10 }));
|
||
it('rejects SCHWARMZEIT 10 days with the field name', () =>
|
||
expect(validateRuleConfig('INSPECTION_INTERVAL', { intervalDays: { SCHWARMZEIT: 10 } })).toEqual([{ field: 'intervalDays.SCHWARMZEIT', message: expect.stringContaining('7') }]));
|
||
it('rejects yellowFrom above redAbove', ...);
|
||
it('rejects a calendar fallback that runs backwards', ...); // KIRSCHE '03-01' vor SALWEIDE '03-20'
|
||
it('defaults notify to true only for QUEENLESS_NO_EGGS, VARROA_THRESHOLD and COLONY_HEALTH_CRITICAL', ...);
|
||
// varroa-threshold.spec.ts
|
||
it('reads naturalFall.redAbove from the band format', () => expect(varroaThresholdOf({ active: true, config: { naturalFall: { redAbove: 8 } } })).toBe(8));
|
||
// automation.e2e-spec.ts
|
||
it('PATCH a rule with an invalid known value answers 400 with the field', async () => {
|
||
const res = await patch(`/api/v1/automation/rules/${seasonAutopilotId}`, { config: { queenMaxAgeYears: 0 } }).expect(400);
|
||
expect(res.body.issues).toEqual([expect.objectContaining({ field: 'queenMaxAgeYears' })]);
|
||
});
|
||
it('lists the nine new rules with their defaults', ...);
|
||
// t4-schema.e2e-spec.ts
|
||
it('keeps at most one suggestion per dedupe key', ...); // createMany skipDuplicates zweimal -> count 1, dann 0
|
||
// row-lock.spec.ts
|
||
it('locks NotificationPreference by "userId" and every other table by "id"', ...); // SQL-Text prüfen, id als Parameter
|
||
it('rejects a table outside the map', ...);
|
||
// automation.service.spec.ts
|
||
it('update locks the rule row and reads before inside the transaction', ...);
|
||
// backup.service.spec.ts: bestehender Modell-Wächter scheitert für Suggestion; neu
|
||
it('exports and restores suggestions with their decision before tasks', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/common/dates.spec.ts src/automation src/backup src/prisma/row-lock.spec.ts` → FAIL (Module fehlen, Wächter listet `Suggestion`); e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- Migration 1 enthält nur `ALTER TYPE … ADD VALUE` (9 + 1 + 1). Migration 2: Enums, Tabelle `Suggestion` mit den drei Indizes aus Spec 4.1, `Task.sourceSuggestionId` mit FK und Index.
|
||
- `berlinToday`/`berlinDayOf` über `Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year, month, day })`; `berlinDateTime` bestimmt den Offset am Zieltag (zweimal anwenden, damit der Umstellungstag stimmt).
|
||
- Parser: `parse-helpers.ts` mit `intIn`, `numberIn`, `bool`, `monthDay`, `months`, `enumList`; `parse` fällt je Schlüssel auf den Standard zurück und loggt eine Warnung mit Trigger und Feld (ohne Wert); `validate` meldet nur vorhandene bekannte Schlüssel.
|
||
- `automation.service.ts`: `DEFAULT_RULES` importieren; `update` läuft in einer Transaktion: `lockRowForUpdate(tx, 'AutomationRule', id)`, `before` lesen (404 wie bisher), bei einem Objekt `validateRuleConfig(before.triggerType, dto.config)` (Issues → `BadRequestException({ message: 'Die Einstellung ist ungültig.', issues })`), schreiben, Audit mit `tx`.
|
||
- `row-lock.ts`: Einträge mit Schlüsselspalte, neue Tabellen laut Interfaces; bestehende Aufrufer unverändert.
|
||
- `varroaThresholdOf` liest `ruleConfigOf('VARROA_THRESHOLD', config).naturalFall.redAbove`; Signatur bleibt (Task 7 löscht die Datei).
|
||
- `calendar-sync.service.ts`: `WITHDRAWAL: false` im Schalter-Record und `case 'WITHDRAWAL': return null;` mit Kommentar „Schalter Task 3, Inhalt Task 12“.
|
||
- Backup: `suggestion` in Export und Import, Import vor `task`, `decidedByUserId` wie andere Nutzer-IDs aufgelöst.
|
||
- [ ] **Schritt 4:** gleiche Befehle → PASS; `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0.
|
||
- [ ] **Schritt 5: Verifikation**
|
||
- typecheck, lint, `npm test -- --ci`
|
||
- `npx jest --config ./test/jest-e2e.json test/automation.e2e-spec.ts test/t4-schema.e2e-spec.ts test/backup.e2e-spec.ts test/calendar-sync.e2e-spec.ts --runInBand` → PASS
|
||
- Docs-Text im Bericht; kein Commit
|
||
|
||
---
|
||
|
||
### Task 2: Schema II – Futter-Vokabular, Ableger-Herkunft, Varroa-Probe
|
||
|
||
**Files**
|
||
- Modify: `prisma/schema.prisma`
|
||
- Create: `prisma/migrations/20261014100200_t4_feed_vocabulary/migration.sql`, `…100300_t4_split_queen_source/migration.sql`, `…100400_t4_varroa_sample_size/migration.sql`
|
||
- Create: `src/common/feed-vocabulary.ts` (+ `.spec.ts`), `test/utils/migration-harness.ts`, `test/t4-migrations.e2e-spec.ts`
|
||
- Modify: `.gitignore` (`/.tmp-migrations/`), `src/inspections/dto/create-inspection.dto.ts` (`feedStatus` als `ColonyCondition`), `src/automation/automation.service.ts` (`feedStatus === 'CRITICAL'`), `src/import/import.service.ts` (+ Spec), `src/export/colony-record-card.ts` (Label), `src/export/export.service.ts` (Label im CSV), `src/backup/backup.service.ts` (+ Spec)
|
||
- Test: `test/inspections.e2e-spec.ts`
|
||
- Docs: neue Annahme „Futter-Vokabular“ (Abbildung, Freitext nach `notes`, Webhook `inspection.created` trägt `feedStatus` künftig als `GOOD`/`WEAK`/`CRITICAL` – auch für den Changelog).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1.
|
||
- Produces:
|
||
```prisma
|
||
// Migration 3: Inspection.feedStatus ColonyCondition? (Abbildung Spec 4.4)
|
||
// Migration 5: enum SplitQueenSource { RAISE_OWN QUEEN_CELL QUEEN }; ColonyLineage.queenSource SplitQueenSource?
|
||
// Migration 7a: VarroaMeasurement.sampleBees Int?
|
||
```
|
||
```ts
|
||
export const FEED_CONDITION_LABELS_DE: Record<ColonyCondition, string>;
|
||
export interface LegacyFeedMapping { feedStatus: ColonyCondition | null; notesSuffix: string | null } // 'Futter (alt): <Text>'
|
||
export function feedConditionFromLegacy(text: string | null | undefined): LegacyFeedMapping;
|
||
export function appendLegacyFeedNote(notes: string | null, suffix: string | null): string | null; // Trenner '\n'
|
||
// test/utils/migration-harness.ts – frisches Postgres-Schema, migriert bis vor eine Migration
|
||
export interface ScratchDatabase {
|
||
readonly schema: string;
|
||
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
||
migrateThrough(migrationName: string): Promise<void>;
|
||
drop(): Promise<void>;
|
||
}
|
||
export function createScratchDatabase(beforeMigration: string): Promise<ScratchDatabase>;
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// feed-vocabulary.spec.ts – Tabelle aus Spec 4.4
|
||
it.each([[' Ausreichend ', 'GOOD', null], ['niedrig', 'WEAK', null], ['KRITISCH', 'CRITICAL', null], ['', null, null], [null, null, null],
|
||
['halb voll', null, 'Futter (alt): halb voll']])('maps %p', (text, feedStatus, notesSuffix) =>
|
||
expect(feedConditionFromLegacy(text)).toEqual({ feedStatus, notesSuffix }));
|
||
// t4-migrations.e2e-spec.ts (Harness, Timeout 120 s)
|
||
it('free-text feed moves into notes and known words map to the enum', async () => {
|
||
const db = await createScratchDatabase('20261014100200_t4_feed_vocabulary');
|
||
// Volk + 3 Durchsichten: 'niedrig'/notes null, 'halb voll'/notes null, 'halb voll'/notes 'x'
|
||
await db.migrateThrough('20261014100200_t4_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' }]);
|
||
});
|
||
it('leaves no inspection whose old feed text was lost', ...); // Prüfabfrage Spec 4.12
|
||
// inspections.e2e-spec.ts
|
||
it('rejects a free-text feedStatus and accepts WEAK', ...); // 'niedrig' -> 400, 'WEAK' -> 201
|
||
// import.service.spec.ts
|
||
it('maps CSV feed text "niedrig" to WEAK and keeps unknown text in the notes', ...);
|
||
// backup.service.spec.ts
|
||
it('maps a legacy free-text feedStatus of an old bundle on import', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/common/feed-vocabulary.spec.ts src/import src/backup src/export` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- Migration 3 von Hand: neue Spalte `"feedCondition" "ColonyCondition"`, `UPDATE … SET "feedCondition" = CASE lower(btrim("feedStatus")) WHEN 'ausreichend' THEN 'GOOD' WHEN 'niedrig' THEN 'WEAK' WHEN 'kritisch' THEN 'CRITICAL' END`, Freitext anhängen (`concat_ws(E'\n', NULLIF(notes, ''), 'Futter (alt): ' || btrim("feedStatus"))`), Zahl umgezogener Freitexte per `RAISE NOTICE` in einem `DO`-Block, alte Spalte löschen, neue umbenennen.
|
||
- Harness: legt Schema `mig_<random>` an, kopiert die Migrationsordner vor dem Ziel samt `migration_lock.toml` in `.tmp-migrations/<random>/` unter `ApiCore-Backend/` (git-ignoriert, nicht im OS-Temp, damit `prisma.config.ts` das installierte `prisma`-Paket auflöst), schreibt dort eine `prisma.config.ts` (`schema` absolut, `migrations.path` = dieses Verzeichnis, `datasource.url` = Test-URL mit `?schema=mig_…`) und ruft `npx prisma migrate deploy --config .tmp-migrations/<random>/prisma.config.ts`; `query` über `pg` mit `SET search_path`; `migrateThrough` kopiert bis einschließlich Ziel und ruft erneut `deploy`; `drop` löscht Schema und das Verzeichnis unter `.tmp-migrations/`.
|
||
- Regel `FEED_STATUS_CRITICAL` prüft `inspection.feedStatus === 'CRITICAL'`.
|
||
- Stockkarte (PDF) und CSV-Export drucken `FEED_CONDITION_LABELS_DE`; CSV-Import und Backup-Import nutzen `feedConditionFromLegacy` + `appendLegacyFeedNote`.
|
||
- [ ] **Schritt 4:** → PASS; Drift-Check → 0.
|
||
- [ ] **Schritt 5: Verifikation**
|
||
- typecheck, lint, `npm test -- --ci`
|
||
- `npx jest --config ./test/jest-e2e.json test/t4-migrations.e2e-spec.ts test/inspections.e2e-spec.ts test/import.e2e-spec.ts test/export.e2e-spec.ts test/automation.e2e-spec.ts --runInBand` → PASS
|
||
- kein Commit
|
||
|
||
---
|
||
|
||
### Task 3: Schema III – Meldungen je Nutzer, Kalender-Schalter, besetzte Waben, Einstellungen
|
||
|
||
**Files**
|
||
- Modify: `prisma/schema.prisma`
|
||
- Create: `prisma/migrations/20261014100500_t4_notification_fanout/`, `…100600_t4_calendar_withdrawal/`, `…100700_t4_occupied_frames/`, `…100800_t4_notification_settings/` (je `migration.sql`)
|
||
- Create: `src/notifications/notification-link.ts`, `src/notifications/notification-preferences.service.ts` (+ Spec), `src/notifications/notification-settings.controller.ts`, `src/notifications/dto/update-notification-settings.dto.ts`, `src/notifications/no-broadcast-notifications.spec.ts`
|
||
- Modify: `src/notifications/notifications.service.ts` (+ Spec), `notifications.controller.ts`, `notifications.module.ts`, `src/automation/automation.service.ts`, `src/system/job-run.service.ts` (+ Spec), `src/apiaries/apiary-field.service.ts` (+ Spec), `src/calendar-sync/calendar-sync.service.ts`, `src/calendar-sync/calendar-sync-settings.service.ts` (+ Spec; `update` bildet die Felder von Hand ab, `syncWithdrawal` fehlt sonst), `src/calendar-sync/dto/update-calendar-sync-settings.dto.ts`, `src/common/validation/update-dto-null-handling.spec.ts`, `src/backup/backup.service.ts` (+ Spec)
|
||
- Test: `test/notifications.e2e-spec.ts`, `test/t4-migrations.e2e-spec.ts` (ergänzen), `test/calendar-sync.e2e-spec.ts`
|
||
- Docs: neue Annahme „Benachrichtigungen je Nutzer“ (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von Rückgrat-Spec §4, Begründung aus den Spec-Entscheidungen); §8: `NotificationPreference` im Backup.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1, 2.
|
||
- Produces:
|
||
```prisma
|
||
// Migration 8: Rundmeldungen je aktivem Nutzer kopieren (gleiches read, createdAt), Originale löschen, userId NOT NULL, dedupeKey String? @unique
|
||
// Migration 9: CalendarSyncSettings.syncWithdrawal Boolean @default(true)
|
||
// Migration 10: Colony.occupiedFrames Int? + CHECK ("occupiedFrames" BETWEEN 0 AND 40); keine Datenänderung an strength
|
||
// Migration 11a: enum NotificationCategory; Notification.category (Altzeilen: CASE WHEN "relatedType" = 'JobRun' OR "relatedType" IS NULL THEN 'SYSTEM' ELSE 'SUGGESTION' END – Fehlermeldungen der Jobs tragen relatedType 'JobRun'; danach NOT NULL),
|
||
// link String?, requiredPermissions String[] @default([]); model NotificationPreference exakt wie Spec 4.15
|
||
```
|
||
```ts
|
||
export interface CreateNotificationInput {
|
||
userId: string; priority: NotificationPriority; category: NotificationCategory; message: string;
|
||
relatedType?: string | null; relatedId?: string | null; link?: string | null;
|
||
requiredPermissions?: readonly PermissionKey[]; dedupeKey?: string | null;
|
||
}
|
||
export type FanOutNotificationInput = Omit<CreateNotificationInput, 'userId' | 'dedupeKey'> & { dedupeKeyPrefix?: string };
|
||
NotificationsService.record(input: CreateNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification | null>; // null: Dublette oder Empfänger ohne Recht
|
||
NotificationsService.recordForUsers(userIds: readonly string[], input: FanOutNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification[]>;
|
||
NotificationsService.recordForPermission(permission: PermissionKey | readonly PermissionKey[], input: FanOutNotificationInput, tx?: Prisma.TransactionClient): Promise<Notification[]>;
|
||
NotificationsService.findOwn(userId: string, options?: { unreadOnly?: boolean }): Promise<NotificationView[]>; // höchstens 100, neueste zuerst
|
||
NotificationsService.markRead(id: string, userId: string): Promise<NotificationView>; // fremd oder unbekannt -> 404
|
||
NotificationsService.markAllRead(userId: string): Promise<{ updated: number }>;
|
||
export interface NotificationView { id: string; priority: NotificationPriority; category: NotificationCategory; message: string;
|
||
relatedType: string | null; relatedId: string | null; link: string | null; read: boolean; createdAt: Date }
|
||
export function assertAppLink(link: string): void;
|
||
export function notificationDedupeKey(prefix: string, userId: string): string; // `${prefix}:${userId}`
|
||
export interface NotificationSettingsView { taskDueEnabled: boolean; digestEnabled: boolean; digestTime: string; quietFrom: string | null;
|
||
quietUntil: string | null; telegramCategories: NotificationCategory[]; telegramDetail: boolean } // Task 36 ergänzt `telegram`
|
||
NotificationPreferencesService.getOrCreate(userId: string, db?: DbClient): Promise<NotificationPreference>;
|
||
NotificationPreferencesService.update(userId: string, dto: UpdateNotificationSettingsDto): Promise<NotificationPreference>;
|
||
// GET/PATCH /profile/notification-settings @AnyAuthenticatedUser (nie API-Schlüssel); POST /notifications/read-all (Route vor :id)
|
||
// JobRunService: Fehlermeldung über recordForPermission(JOB_FAILURE_PERMISSION[job], { category: 'SYSTEM', dedupeKeyPrefix: `job-failed:${job}:${tag}` }); relatedType bleibt 'JobRun' (relatedId = JobRun-ID)
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// notifications.service.spec.ts
|
||
it('record without userId does not compile', () => {
|
||
// @ts-expect-error userId fehlt
|
||
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
|
||
});
|
||
it('recordForPermission writes one row per active user holding every permission, with a key per recipient', ...); // 'p:u1', 'p:u2'
|
||
it('recordForUsers skips users missing a required permission', ...);
|
||
it('stores the union of recipient and content permissions', ...);
|
||
it('a repeated dedupe key writes nothing', ...); // createManyAndReturn skipDuplicates -> []
|
||
it('rejects a link with a scheme, // or a backslash', () => expect(() => assertAppLink('//evil.example')).toThrow());
|
||
// notification-preferences.service.spec.ts
|
||
it('creates the defaults on first read', ...);
|
||
it('rejects 04:30 as digest time with the German message', ...); // 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.'
|
||
it('clears quiet hours only as a pair', ...);
|
||
it('update locks the preference row by userId and merges with the stored values read inside the transaction', ...);
|
||
// no-broadcast-notifications.spec.ts (Wächter)
|
||
it('Notification rows are written only by NotificationsService and the backup restore', () =>
|
||
expect(filesMatching(/\bnotification\.(create|createMany|createManyAndReturn|upsert)\(/)).toEqual(['src/backup/backup.service.ts', 'src/notifications/notifications.service.ts']));
|
||
it('every Notification write in those two files carries a userId (spec 14.3)', ...); // Wächter prüft im Treffer-Ausschnitt `userId`; die Wiederherstellung schreibt nie ohne
|
||
// notifications.e2e-spec.ts
|
||
it('reading is per user: marking one copy read leaves the other unread', ...);
|
||
it('a fan-out for colonies:read never reaches a user without it', ...);
|
||
it('read-all marks only own notifications', ...);
|
||
it('reading a foreign notification answers 404', ...);
|
||
it('an API key gets 403 on /profile/notification-settings', ...);
|
||
it('PATCH notification-settings stores digest time, quiet hours and Telegram categories', ...);
|
||
// t4-migrations.e2e-spec.ts
|
||
it('broadcasts are copied per active user with their read flag', ...); // 2 aktive, 1 inaktiver Nutzer
|
||
it('legacy job-failure rows (relatedType JobRun) and rows without relatedType get SYSTEM, all others SUGGESTION, before NOT NULL', ...);
|
||
it('occupied frames migration leaves strength untouched', ...);
|
||
// calendar-sync.e2e-spec.ts
|
||
it('syncWithdrawal is on by default and can be switched off', ...); // PATCH speichert das Feld (calendar-sync-settings.service bildet von Hand ab)
|
||
// backup.service.spec.ts
|
||
it('restore keeps the userId of a notification and skips a row whose user does not exist in the target', ...);
|
||
it('an old bundle notification without userId is copied per active user: category SYSTEM for relatedType JobRun or none, SUGGESTION otherwise', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/notifications src/system src/apiaries src/common/validation src/backup` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- Empfänger in einer Abfrage: `user.findMany({ where: { id?: { in }, isActive: true, role: { status: 'ACTIVE', AND: keys.map((key) => ({ permissions: { some: { permission: { key } } } })) } } })`; danach `notification.createManyAndReturn({ data, skipDuplicates: true })` (Task 35 braucht die Zeilen).
|
||
- `assertAppLink`: `/^\/(?![\/\\])[A-Za-z0-9\-._~\/?=&%]{0,299}$/`.
|
||
- Controller: `GET /notifications` → `findOwn`, `POST /notifications/read-all` → `markAllRead`, `POST /notifications/:id/read` → `markRead` (eine Anweisung `updateMany({ where: { id, userId } })`, `count 0` → 404; kein vorheriges Lesen).
|
||
- `NotificationPreferencesService.update` in einer Transaktion: `getOrCreate(userId, tx)` (Upsert), `lockRowForUpdate(tx, 'NotificationPreference', userId)`, gespeicherte Werte lesen, mit dem DTO zusammenführen, Paar-Regel der Ruhezeit prüfen, schreiben.
|
||
- `automation.service.ts`: die bisherige Rundmeldung wird `recordForPermission('tasks:read', { category: 'SUGGESTION', … }, tx)` (Task 7 ersetzt den Pfad).
|
||
- `apiary-field.service.ts`: Meldungszählung nur `userId` des Aufrufers (Task 15 stellt auf Vorschläge um).
|
||
- `calendar-sync.service.ts`: `WITHDRAWAL: settings.syncWithdrawal`; DTO-Feld `syncWithdrawal` mit `@IsOptionalNotNull() @IsBoolean()`. `calendar-sync-settings.service.ts`: `update` bildet die Felder von Hand ab (`data: { enabled: dto.enabled, … syncQueenAssignment: dto.syncQueenAssignment }`), dort `syncWithdrawal: dto.syncWithdrawal` ergänzen; sonst bliebe der Schalter wirkungslos.
|
||
- `UpdateNotificationSettingsDto`: `taskDueEnabled`, `digestEnabled`, `telegramDetail` (`@IsOptionalNotNull() @IsBoolean()`); `digestTime` (`@IsOptionalNotNull()`, `HH:MM`, 05:00–12:00); `quietFrom`/`quietUntil` (`HH:MM` oder `null`; nach dem Zusammenführen beide gesetzt oder beide `null`, sonst 400 „Bitte Beginn und Ende der Ruhezeit zusammen leeren.“); `telegramCategories` (`@IsOptionalNotNull() @IsArray() @ArrayUnique() @ArrayMaxSize(6) @IsEnum(NotificationCategory, { each: true })`). `telegramEnabled` ist nicht änderbar (setzen Verbinden/Trennen, Task 36).
|
||
- Backup: `notificationPreference` gesichert; Import alter Bundles: `Notification` ohne `userId` je aktivem Nutzer kopieren (wie Migration 8), ohne `category` wie Migration 11a (`relatedType 'JobRun'` oder ohne `relatedType` → `SYSTEM`, sonst `SUGGESTION`). Die Wiederherstellung (`tx.notification.upsert` in `backup.service.ts`) bleibt als einziger Schreibpfad außerhalb des `NotificationsService`, schreibt `userId` aber immer: aufgelöst über `resolveOptionalUserId`; ist der Nutzer im Zielsystem unbekannt, wird die Zeile übersprungen und gezählt (`userId` ist nach Migration 8 Pflicht, nie `null` schreiben).
|
||
- Migration 10: Kopfkommentar mit Prüfabfrage `SELECT count(*) FROM "Inspection" WHERE "colonyStrength" > 40` (vor dem Deploy, Treffer mit dem Inhaber klären, nicht kürzen).
|
||
- [ ] **Schritt 4:** → PASS; Drift-Check → 0.
|
||
- [ ] **Schritt 5: Verifikation**
|
||
- typecheck, lint, `npm test -- --ci`
|
||
- `npx jest --config ./test/jest-e2e.json test/notifications.e2e-spec.ts test/t4-migrations.e2e-spec.ts test/calendar-sync.e2e-spec.ts test/jobs.e2e-spec.ts test/apiaries.e2e-spec.ts test/backup.e2e-spec.ts test/authorization-coverage.e2e-spec.ts --runInBand` → PASS
|
||
- kein Commit
|
||
|
||
---
|
||
|
||
### Task 4: Vorschlags-Kern
|
||
|
||
**Files**
|
||
- Create: `src/suggestions/suggestion.types.ts`, `src/suggestions/suggestions.service.ts` (+ Spec), `src/suggestions/suggestion-texts.ts` (+ `suggestion-texts.spec.ts`), `src/suggestions/texts/rule-texts.ts`, `texts/chain-texts.ts`, `texts/autopilot-texts.ts`, `texts/cleanup-texts.ts` (je leeres Register), `src/suggestions/evidence-lookup.ts` (+ Spec), `src/suggestions/suggestions.module.ts`
|
||
- Modify: `src/tasks/tasks.service.ts` (`NewTaskInput.sourceSuggestionId`), `src/app.module.ts`, `src/worker/worker.module.ts`
|
||
- Docs: neue Annahme „Vorschläge und Anker“ (ein Modell, Arten `PLAN`/`DO`, Anker, verworfene Anker kommen nicht wieder, 30 Tage wieder öffnen, Texte beim Lesen gerendert).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (`Suggestion`, `lockRuleIn`, `PlanStepConfig`, `dates.ts`).
|
||
- Produces:
|
||
```ts
|
||
export type EvidenceValue = string | number | boolean | null | readonly string[];
|
||
export type SuggestionEvidence = Readonly<Record<string, EvidenceValue>>; // nur IDs, Zahlen, YYYY-MM-DD; Plan-Basis in `baseDate`
|
||
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;
|
||
notify?: boolean; // Standard false; Task 7 verdrahtet die Meldung (Regel- und Kettenvorschläge übergeben config.notify, der Autopilot nie);
|
||
}
|
||
export const INTENT_OF_LEGACY_TRIGGER: Readonly<Partial<Record<AutomationTriggerType, SuggestionIntent>>>;
|
||
export function suggestionDedupeKey(ruleKey: AutomationTriggerType, relatedId: string | null, anchor: string): string;
|
||
export interface RenderedSuggestionText { title: string; reasons: string[] }
|
||
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 }
|
||
export const LOOKUP_EVIDENCE_KEYS = ['colonyId', 'parentColonyId', 'queenId', 'treatmentId', 'apiaryId', 'stockItemId'] as const;
|
||
export function buildEvidenceLookup(db: DbClient, evidences: readonly SuggestionEvidence[]): Promise<EvidenceLookup>; // eine Abfrage je Modell
|
||
export interface SuggestionTextTemplate { ruleKey: AutomationTriggerType; example: SuggestionEvidence; render(evidence: SuggestionEvidence, lookup: EvidenceLookup, today: string): RenderedSuggestionText }
|
||
export type SuggestionTextRegister = Readonly<Record<string, SuggestionTextTemplate>>;
|
||
export const PLAN_STEP_TITLES: Readonly<Record<string, string>>; // Register aus texts/chain-texts.ts
|
||
export const INTENT_TITLES: Readonly<Record<SuggestionIntent, string>>;
|
||
export function renderSuggestion(s: Pick<Suggestion, 'reasonCode' | 'evidence' | 'intent'>, lookup: EvidenceLookup, today: string, titleOverride?: string | null): RenderedSuggestionText;
|
||
export function formatDayDe(day: string, withYear?: boolean): string; // '05.05.' bzw. '05.05.2026'
|
||
export function formatNumberDe(value: number, maxFractionDigits?: number): string; // '12,4'
|
||
export interface AcceptSuggestionInput { 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';
|
||
@Injectable() class SuggestionsService {
|
||
proposeIn(tx: Prisma.TransactionClient, input: ProposeInput): Promise<Suggestion | null>;
|
||
openIntentsIn(db: DbClient, target: { colonyId: string | null; relatedType: TaskRelatedType; relatedId: string | null }): Promise<Set<SuggestionIntent>>;
|
||
refreshIn(tx: Prisma.TransactionClient, id: string, patch: { evidence: SuggestionEvidence; dueDate: string | null; priority: TaskPriority; expiresAt: string | null }): Promise<void>;
|
||
resolveIn(tx: Prisma.TransactionClient, ids: readonly string[], note: SuggestionResolveNote): Promise<number>; // OPEN -> RESOLVED und dedupeKey = NULL in einem Statement
|
||
obsoleteForColonyIn(tx: Prisma.TransactionClient, colonyId: string): Promise<number>;
|
||
expireDue(today: string): Promise<number>; // OPEN -> EXPIRED und dedupeKey = NULL in einem Statement
|
||
accept(id: string, input: AcceptSuggestionInput, actingUserId: string): Promise<AcceptSuggestionResult>;
|
||
bulkAccept(ids: readonly string[], actingUserId: string): Promise<AcceptSuggestionResult[]>;
|
||
dismiss(id: string, note: string | null, actingUserId: string): Promise<Suggestion>;
|
||
bulkDismiss(ids: readonly string[], actingUserId: string): Promise<number>;
|
||
reopen(id: string, actingUserId: string): Promise<Suggestion>;
|
||
}
|
||
// NewTaskInput += sourceSuggestionId?: string | null
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// suggestions.service.spec.ts (Prisma-tx gemockt)
|
||
it('proposeIn locks the colony row, then the rule row, and writes one suggestion with an audit entry', ...);
|
||
it('proposeIn with an existing dedupe key creates nothing', async () => {
|
||
tx.suggestion.createManyAndReturn.mockResolvedValue([]);
|
||
await expect(service.proposeIn(tx, input)).resolves.toBeNull();
|
||
expect(audit.record).not.toHaveBeenCalled();
|
||
});
|
||
it('proposeIn skips when an open task of the same intent exists (via sourceSuggestion or legacy trigger)', ...);
|
||
it('proposeIn skips when an open suggestion of the same intent exists for the colony', ...);
|
||
it('accept creates one task per plan step through createInTx with sourceSuggestionId', async () => {
|
||
// RAISE_OWN, evidence.baseDate '2026-05-01'
|
||
expect(tasks.createInTx.mock.calls.map((c) => c[1].dueDate?.toISOString().slice(0, 10))).toEqual(['2026-05-10', '2026-05-22', '2026-05-29']);
|
||
expect(tasks.createInTx.mock.calls[1][1].description).toContain('bis 25.05.');
|
||
});
|
||
it('accept with a new date shifts every plan step by the same distance', ...);
|
||
it('accept of a DO suggestion without a date answers 400 "Bitte ein Datum wählen."', ...);
|
||
it('accept answers 409 with the current status when the CAS finds no OPEN row', ...);
|
||
it('accept locks the suggestion row and reads it inside the transaction', ...);
|
||
it('reopen within 30 days restores OPEN; after 30 days it answers 409', ...); // CAS mit "decidedAt" > now() - interval '30 days'
|
||
it('bulkAccept is all or nothing', ...);
|
||
it('expireDue moves OPEN past expiresAt to EXPIRED in one statement', ...);
|
||
it('expireDue and resolveIn set dedupeKey to NULL with the status change; DISMISSED and ACCEPTED keep it', ...);
|
||
it('an EXPIRED or RESOLVED suggestion of the same anchor can be proposed again; a DISMISSED one cannot', ...);
|
||
it('accept of a PLAN suggestion without plan and without a date creates a task due today (Berlin)', ...);
|
||
// suggestion-texts.spec.ts
|
||
it('every registered template renders its example in at most 2 reasons of at most 90 characters', ...);
|
||
it('no reason code is registered twice', ...);
|
||
it('formats numbers and days in German', () => { expect(formatNumberDe(12.4)).toBe('12,4'); expect(formatDayDe('2026-05-05')).toBe('05.05.'); });
|
||
it('falls back to INTENT_TITLES for an unknown reason code', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/suggestions src/tasks` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- `proposeIn`: `assertTransactionClient`; bei gesetzter `colonyId` zuerst `lockRowForUpdate(tx, 'Colony', colonyId)` (serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, damit die Absichts-Prüfung über Regeln hinweg hält), dann `lockRuleIn(tx, ruleKey)`; `openIntentsIn` prüft offene Vorschläge (gleiche `colonyId`, sonst gleiche `relatedType` + `relatedId`) und offene Aufgaben (`OPEN`/`IN_PROGRESS`; Absicht nach Global Constraints); dann `suggestion.createManyAndReturn({ data: [row], skipDuplicates: true })` und Audit `Suggestion CREATE` mit `tx`.
|
||
- `accept`, `dismiss`, `reopen` und die Bulk-Varianten je in einer Transaktion: `lockRowForUpdate(tx, 'Suggestion', id)` (Bulk: IDs aufsteigend), `before` lesen, Status prüfen (sonst 409 mit aktuellem Status), CAS-Update, Audit mit `beforeData`/`afterData`.
|
||
- `accept`: CAS `OPEN → ACCEPTED` (`decidedAt = now()`, `decidedByUserId`), dann Aufgaben über `tasks.createInTx` – Titel aus `renderSuggestion` (mit `actionTemplate` der Regel), Beschreibung = Gründe, `priority`, `relatedType`/`relatedId`, `sourceSuggestionId`, `sourceAutomationTrigger = ruleKey`, `sourceInspectionId = evidence.inspectionId` falls vorhanden. PLAN mit `plan`: je Schritt `baseDate + offsetDays`, Titel `PLAN_STEP_TITLES[step]`, bei `windowDays` Zusatz „bis {TT.MM.}“. Mit `input.dueDate` wird die Basis so verschoben, dass der erste Schritt auf diesem Tag liegt. Ein PLAN ohne `plan` und ohne `dueDate` (Regelvorschlag ohne `dueInDays`, Spec 1) legt die Aufgabe auf heute (`berlinToday()`).
|
||
- `resolveIn` und `expireDue` setzen `"dedupeKey" = NULL` im selben Statement wie den Statuswechsel; `accept`, `dismiss`, `reopen` und `obsoleteForColonyIn` lassen den Schlüssel stehen.
|
||
- `SuggestionsModule` importiert `TasksModule`, `AuditModule`, `NotificationsModule` (Task 7 schreibt darüber die Meldung zu neuen Vorschlägen), exportiert `SuggestionsService`; in `AppModule` und `WorkerModule` importiert.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation**
|
||
- typecheck, lint, `npm test -- --ci`
|
||
- `npx jest --config ./test/jest-e2e.json test/tasks.e2e-spec.ts test/worker-runtime.e2e-spec.ts --runInBand` → PASS
|
||
- kein Commit
|
||
|
||
---
|
||
|
||
### Task 5: Vorschläge-API
|
||
|
||
**Files**
|
||
- Create: `src/suggestions/suggestions.controller.ts`, `src/suggestions/suggestion-views.service.ts` (+ Spec), `src/suggestions/dto/list-suggestions.query.ts`, `dto/accept-suggestion.dto.ts`, `dto/dismiss-suggestion.dto.ts`, `dto/suggestion-ids.dto.ts`
|
||
- Modify: `src/suggestions/suggestions.module.ts`
|
||
- Test: neu `test/suggestions.e2e-spec.ts`, `test/utils/suggestions.ts` (`proposeForTest(app, input)` in einer Transaktion)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 4.
|
||
- Produces:
|
||
```ts
|
||
export interface SuggestionView { id: string; ruleKey: AutomationTriggerType; source: SuggestionSource; mode: SuggestionMode; intent: SuggestionIntent;
|
||
status: SuggestionStatus; priority: TaskPriority; relatedType: TaskRelatedType; relatedId: string | null; colonyId: string | null;
|
||
colonyCode: string | null; apiaryId: string | null; apiaryName: string | null; title: string; reasons: string[];
|
||
dueDate: string | null; expiresAt: string | null; plan: PlanStepConfig[] | null; decidedAt: string | null; decisionNote: string | null; createdAt: string;
|
||
cleanup: { taskIds: string[]; queenId: string | null } | null } // nur bei CLEANUP
|
||
export interface SuggestionSummary { open: number; byPriority: Record<TaskPriority, number> }
|
||
SuggestionViewsService.list(userId: string, query: ListSuggestionsQuery): Promise<SuggestionView[]>; // Standard OPEN; DISMISSED nur decidedAt >= now() - 30 Tage
|
||
SuggestionViewsService.summary(userId: string): Promise<SuggestionSummary>;
|
||
SuggestionViewsService.visibleWhere(permissions: ReadonlySet<string>): Prisma.SuggestionWhereInput; // requiredPermission null oder gehalten
|
||
```
|
||
- Routen (Rechte Spec 10.1, statische Routen vor `:id`): `GET /suggestions?status=&colonyId=&apiaryId=` und `GET /suggestions/summary` (`tasks:read`); `POST /suggestions/:id/accept`, `POST /suggestions/bulk-accept` (`tasks:create`, `@Idempotent()`); `POST /suggestions/:id/dismiss`, `POST /suggestions/bulk-dismiss`, `POST /suggestions/:id/reopen` (`tasks:update`, `@Idempotent()`).
|
||
- DTOs: `AcceptSuggestionDto { dueDate?: string | null (@IsOptional @IsDateString); assignedUserId?: string | null (@IsOptional @IsString @MinLength(1) @MaxLength(64)) }` – `accept` prüft in der Transaktion, dass der Zuständige existiert und aktiv ist (`user.isActive`, aktive Rolle), sonst 400 „Der Zuständige existiert nicht oder ist nicht aktiv.“; `DismissSuggestionDto { note?: string (@MaxLength(500)) }`, `SuggestionIdsDto { ids: string[] (@ArrayMinSize(1) @ArrayMaxSize(50) @ArrayUnique() @IsString({ each: true })) }`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// suggestion-views.service.spec.ts
|
||
it('filters by requiredPermission in the query, not after loading', ...);
|
||
it('renders title and reasons with one lookup for all rows', ...);
|
||
// test/suggestions.e2e-spec.ts
|
||
it('lists open suggestions with rendered title and reasons', ...);
|
||
it('a user without varroa:read sees neither the varroa suggestion nor its count', async () => {
|
||
await proposeForTest(app, { ...base, intent: 'VARROA_TREAT', requiredPermission: 'varroa:read' });
|
||
expect((await getAs(staffWithoutVarroa, '/api/v1/suggestions')).body).toEqual([]);
|
||
expect((await getAs(staffWithoutVarroa, '/api/v1/suggestions/summary')).body.open).toBe(0);
|
||
});
|
||
it('accept creates exactly one task with sourceSuggestionId and a calendar delivery', ...); // drainDomainEvents -> CalendarSyncOutboxEntry TASK
|
||
it('a second accept with another key answers 409 and leaves exactly one task', ...);
|
||
it('the same Idempotency-Key replays the first accept', ...);
|
||
it('bulk-accept with one non-OPEN id answers 409 and creates no task', ...);
|
||
it('bulk endpoints reject 0 or 51 ids', ...);
|
||
it('accept with an unknown or inactive assignedUserId answers 400 and creates no task', ...);
|
||
it('dismiss then reopen restores OPEN; the dismissed one is listed for 30 days', ...);
|
||
it('403 without tasks:create on accept and without tasks:update on dismiss', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/suggestions` und `test/suggestions.e2e-spec.ts` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie in Interfaces; Rechte über `PermissionResolverService.getPermissions`; `apiaryId`-Filter über `colony.apiaryId`; Sortierung Fälligkeit (ohne Datum zuletzt), Priorität, `createdAt`, `id`.
|
||
- [ ] **Schritt 4:** → PASS, inklusive `test/authorization-coverage.e2e-spec.ts`.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 6: Varroa-Ampel nach Jahreszeit (Backend)
|
||
|
||
**Files**
|
||
- Create: `src/treatments/withdrawal-window.ts` (+ Spec; der **eine** reine Behandlungsfenster-Helfer für Wartezeit, Varroa-Behandlungseffekt und Tagesregeln, aus Task 12 hierher verlegt), `src/treatments/treatment-windows.ts` (+ Spec; lädt die Fenster eines Volks), `src/automation/varroa-bands.ts` (+ `varroa-bands.spec.ts`), `src/varroa/varroa-bands.service.ts` (+ Spec), `src/varroa/dto/varroa-thresholds.query.ts`
|
||
- Delete: `src/varroa/varroa-threshold.service.ts` (Route `GET /varroa-measurements/threshold` entfällt)
|
||
- Modify: `src/varroa/varroa.controller.ts`, `varroa.module.ts`, `src/varroa/varroa.service.ts` (+ Spec; Prozent aus der Probe), `src/varroa/dto/create-varroa-measurement.dto.ts` (`sampleBees`), `src/varroa/colony-varroa-measurements.controller.ts` (`rating`), `src/treatments/treatments.service.ts` (+ Spec; `getWithdrawalStatus` über den Helfer), `src/jobs/daily-evaluation.rules.ts` (+ Spec; `withdrawalEndDay` über den Helfer)
|
||
- Test: `test/varroa.e2e-spec.ts`, neu `test/varroa-seasonal.e2e-spec.ts`
|
||
- Docs: bestehende T3-Annahme „Ampeln“ ändern: Varroa-Bänder je Methode, Monat und Volkstyp, Behandlungseffekt, Quellen LWG; Grenzwert 10,0 ist jetzt gelb.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (`VarroaThresholdConfig`, `readRule`, `dates.ts`), Task 2 (`sampleBees`).
|
||
- Produces:
|
||
```ts
|
||
export type VarroaLightKey = 'green' | 'yellow' | 'red';
|
||
export type VarroaBandReason = 'NATURAL_FALL_SEASON' | 'NATURAL_FALL_WINTER' | 'INFESTATION_SUMMER' | 'INFESTATION_AUTUMN';
|
||
export interface VarroaBand { method: VarroaMethod; unit: 'MITES_PER_DAY' | 'PERCENT'; yellowFrom: number | null; redAbove: number; reason: VarroaBandReason; youngColony: boolean }
|
||
export interface VarroaBands { day: string; bands: Partial<Record<VarroaMethod, VarroaBand>>; postTreatmentUntil: string | null }
|
||
// TreatmentWindowLite, treatmentWindowOf, WithdrawalWindow, withdrawalWindowOf, isInWindow: src/treatments/withdrawal-window.ts (unten); varroa-bands.ts importiert sie
|
||
export function bandsFor(day: string, colonyType: ColonyType | null, config: VarroaThresholdConfig, treatments: readonly TreatmentWindowLite[]): VarroaBands;
|
||
export function classify(value: number | null, band: VarroaBand | null | undefined): VarroaLightKey | null;
|
||
export interface VarroaRating { light: VarroaLightKey | 'POST_TREATMENT' | null; reasonCode: VarroaBandReason | 'POST_TREATMENT' | null; band: VarroaBand | null; postTreatmentUntil: string | null }
|
||
export function rateMeasurement(m: Pick<VarroaMeasurement, 'date' | 'method' | 'calculatedValue'>, colonyType: ColonyType | null, config: VarroaThresholdConfig, treatments: readonly TreatmentWindowLite[]): VarroaRating;
|
||
VarroaBandsService.thresholds(query: { colonyId?: string; date?: string }): Promise<VarroaBands & { active: boolean }>; // Regel aus -> bands {}, active false
|
||
// src/treatments/withdrawal-window.ts (rein, ohne Datenbank)
|
||
export interface TreatmentWindowLite { startDay: string; endDay: string } // Behandlungsbeginn (startDate, falls vor date, sonst date), endDate ?? date
|
||
export function treatmentWindowOf(t: Pick<Treatment, 'date' | 'startDate' | 'endDate'>): TreatmentWindowLite;
|
||
export interface WithdrawalWindow { treatmentId: string; colonyId: string; product: string; fromDay: string; untilDay: string } // untilDay exklusiv = erntefähig ab
|
||
export function withdrawalWindowOf(t: Pick<Treatment, 'id' | 'colonyId' | 'product' | 'date' | 'startDate' | 'endDate' | 'withdrawalPeriodDays'>): WithdrawalWindow | null;
|
||
export function isInWindow(window: WithdrawalWindow, day: string): boolean; // fromDay <= day < untilDay
|
||
// src/treatments/treatment-windows.ts (Funktion, kein Service-Export; Task 7 und VarroaBandsService nutzen sie, Task 7 braucht kein VarroaModule)
|
||
export function treatmentWindowsIn(db: DbClient, colonyId: string): Promise<TreatmentWindowLite[]>;
|
||
// GET /varroa-measurements/thresholds?colonyId=&date= @RequirePermission('varroa:read', 'varroa:create'); unbekanntes Volk 404; ungültiges Datum 400
|
||
// GET /colonies/:id/varroa-measurements -> je Messung zusätzlich rating: VarroaRating
|
||
// CreateVarroaMeasurementDto.sampleBees?: number (@IsOptional @IsInt @Min(1) @Max(2000)); das Bulk-DTO erbt es
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// varroa-bands.spec.ts – Tabelle aus Spec 14.1
|
||
it.each([
|
||
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.0, 'yellow'],
|
||
['2026-08-10', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 10.1, 'red'],
|
||
['2026-11-20', 'PRODUCTION_COLONY', 'NATURAL_MITE_FALL', 0.6, 'red'],
|
||
['2026-06-10', 'YOUNG_COLONY', 'NATURAL_MITE_FALL', 5.1, 'red'],
|
||
['2026-07-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 5.1, 'red'],
|
||
['2026-04-10', 'PRODUCTION_COLONY', 'POWDERED_SUGAR', 9, null],
|
||
['2026-07-10', 'PRODUCTION_COLONY', 'DRONE_BROOD_CONTROL', 50, null],
|
||
])('%s %s %s %p -> %p', (day, type, method, value, light) => expect(classify(value, bandsFor(day, type, DEFAULTS, []).bands[method])).toBe(light));
|
||
it('a measurement 5 days after treatment end has no light and names the recheck day', () =>
|
||
expect(rateMeasurement({ date: d('2026-08-07'), method: 'NATURAL_MITE_FALL', calculatedValue: 12 }, 'PRODUCTION_COLONY', DEFAULTS, [{ startDay: '2026-07-20', endDay: '2026-08-02' }]))
|
||
.toEqual({ light: 'POST_TREATMENT', reasonCode: 'POST_TREATMENT', band: null, postTreatmentUntil: '2026-08-16' }));
|
||
// withdrawal-window.spec.ts (aus Task 12 hierher verlegt)
|
||
it('runs from the treatment start to (endDate ?? date) + days, exclusive', () =>
|
||
expect(withdrawalWindowOf({ id: 't', colonyId: 'c', product: 'Oxuvar', date: d('2026-09-28'), startDate: null, endDate: d('2026-10-01'), withdrawalPeriodDays: 14 }))
|
||
.toEqual({ treatmentId: 't', colonyId: 'c', product: 'Oxuvar', fromDay: '2026-09-28', untilDay: '2026-10-15' }));
|
||
it('getWithdrawalStatus and withdrawalEndDay deliver the same window', ...);
|
||
it('no window without withdrawalPeriodDays', ...);
|
||
it('treatmentWindowOf starts at startDate when it is before date and ends at endDate ?? date', ...);
|
||
// treatment-windows.spec.ts
|
||
it('maps every treatment of the colony to a window and ignores other colonies', ...);
|
||
// varroa.service.spec.ts
|
||
it('update with sampleBees null clears the sample and keeps a manually entered value', ...);
|
||
it('stores mites per 100 bees for powdered sugar with a sample size', ...); // 10 Milben, 500 Bienen -> 2
|
||
// test/varroa-seasonal.e2e-spec.ts
|
||
it('thresholds for a young colony are halved', ...);
|
||
it('thresholds return no band for powdered sugar in April', ...);
|
||
it('the old /threshold route is gone (404)', ...);
|
||
it('colony measurements carry a rating with band and light', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/automation/varroa-bands.spec.ts src/varroa src/treatments src/jobs` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- `bandsFor`: Monat aus `day`; natürlicher Milbenfall in `seasonMonths` → `{ yellowFrom, redAbove }`, sonst `{ yellowFrom: null, redAbove: winterRedAbove }`; Jungvolk: alle Werte des Milbenfalls × `youngColonyFactor`; Prozent-Bänder nur in `summerMonths`/`autumnMonths`. `postTreatmentUntil` = `endDay + recheckAfterDays` der jüngsten Behandlung mit `startDay ≤ day < endDay + recheckAfterDays`.
|
||
- `calculatedValue` bei `POWDERED_SUGAR`/`WASHING_METHOD` mit `sampleBees` über den vorhandenen Ableitungs-Helfer (gleiche Rundung; ein manueller Wert gewinnt wie bisher). `update` leitet aus dem Vorzustand ab: `lockRowForUpdate(tx, 'VarroaMeasurement', id)`, `before` in der Transaktion lesen.
|
||
- `withdrawal-window.ts`: `TreatmentsService.getWithdrawalStatus` und `withdrawalEndDay` (`daily-evaluation.rules.ts`) rechnen über `withdrawalWindowOf`; das Verhalten des Bestands (Ende exklusiv) bleibt, der Vergleichstest steht oben. Task 7, 12 und 15 importieren den Helfer, es gibt kein zweites Fenster-Rechnen. Null-Regel für `sampleBees`: Eintrag in `update-dto-null-handling.spec.ts` macht Task 8 (dieselbe Datei, Welle 4).
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/varroa.e2e-spec.ts`, `test/varroa-seasonal.e2e-spec.ts`, `test/treatments.e2e-spec.ts`, `test/daily-evaluation.e2e-spec.ts`, `test/authorization-coverage.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 7: Regeln schlagen vor statt Aufgaben anzulegen
|
||
|
||
**Files**
|
||
- Modify: `src/automation/automation.service.ts` (+ Spec), `src/automation/automation.handlers.ts` (+ Spec), `src/automation/automation.module.ts` (importiert `SuggestionsModule`), `src/suggestions/texts/rule-texts.ts`, `src/suggestions/suggestions.service.ts` (+ Spec; `notify` in `proposeIn`)
|
||
- Delete: `src/automation/varroa-threshold.ts` (+ Spec)
|
||
- Test: `test/suggestions.e2e-spec.ts` (Regel-Fälle ergänzen), `test/varroa-seasonal.e2e-spec.ts` (Regel-Fälle), `test/automation.e2e-spec.ts`, `test/post-commit-side-effects.e2e-spec.ts`, `test/follow-up-controls.e2e-spec.ts`, `test/raw-materials.e2e-spec.ts`, `test/storage.e2e-spec.ts`
|
||
- Docs: Annahme 12 neu fassen: Regeln schreiben Vorschläge (Übernehmen/Verwerfen); bestehende Automatik-Aufgaben bleiben Aufgaben („Aus Vorschlag“); Meldung nur bei `config.notify`; Anker je Regel (Tabelle unten).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1, 3, 4, 6 (`rateMeasurement`, `treatmentWindowsIn` aus `src/treatments/treatment-windows.ts` – eine Funktion, kein Export des `VarroaBandsService`, kein Import von `VarroaModule`); Rückgrat Task 10 (Handler und `…In`-Methoden).
|
||
- Produces:
|
||
```ts
|
||
export interface RuleSuggestionTarget { relatedType: TaskRelatedType; relatedId: string; colonyId: string | null; intent: SuggestionIntent;
|
||
reasonCode: string; evidence: SuggestionEvidence; anchor: string; requiredPermission: PermissionKey; dueDate?: string | null; sourceEventId?: string | null }
|
||
AutomationService.proposeForRuleIn(tx: Prisma.TransactionClient, trigger: AutomationTriggerType, target: RuleSuggestionTarget): Promise<Suggestion | null>; // ersetzt triggerActionIn
|
||
```
|
||
|
||
| Regel | Auslöser | intent | reasonCode | Anker | evidence |
|
||
|---|---|---|---|---|---|
|
||
| `QUEENLESS_NO_EGGS` | Durchsicht `queenSeen = false`, `eggsPresent = false` | `QUEEN_CHECK` | `QUEENLESS_NO_EGGS` | Durchsicht-ID | `colonyId, inspectionId, inspectedOn` |
|
||
| `FEED_STATUS_CRITICAL` | Durchsicht `feedStatus = CRITICAL` | `FEED` | `FEED_STATUS_CRITICAL` | Durchsicht-ID | `colonyId, inspectionId, inspectedOn` |
|
||
| `VARROA_THRESHOLD` | Messung, Band rot, kein Behandlungseffekt | `VARROA_TREAT` | `VARROA_RED` | Mess-ID | `colonyId, measurementId, measuredOn, value, redAbove, unit` |
|
||
| `SWARM_MOOD_HIGH` | Schwarmkontrolle `HIGH` (Altpfad) | `SWARM_CHECK` | `SWARM_MOOD_HIGH` | Kontroll-ID | `colonyId, checkId, checkedOn` |
|
||
| `COLONY_HEALTH_CRITICAL` | Übergang nach `CRITICAL` | `HEALTH_CHECK` | `COLONY_HEALTH_CRITICAL` | Ereignis-ID | `colonyId, changedOn` |
|
||
| `STOCK_ITEM_LOW` | Lagerartikel unter Minimum (Altpfad) | `STOCK_REORDER` | `STOCK_ITEM_LOW` | ID der jüngsten `IN`-Bewegung, sonst `none` | `stockItemId, quantity, minThreshold` |
|
||
| `RAW_MATERIAL_LOW` | Rohstoff unter `minGrams` | `STOCK_REORDER` | `RAW_MATERIAL_LOW` | ID der jüngsten Charge der Sorte | `kind, remainingGrams, minGrams` |
|
||
| `TREATMENT_FOLLOW_UP` | Behandlung erfasst | `FOLLOW_UP` | `TREATMENT_FOLLOW_UP` | Behandlungs-ID | `colonyId, treatmentId, endedOn` |
|
||
| `FEEDING_FOLLOW_UP` | Fütterung erfasst | `FOLLOW_UP` | `FEEDING_FOLLOW_UP` | Fütterungs-ID | `colonyId, feedingId, fedOn` |
|
||
|
||
- Alle `PLAN`, Priorität `HIGH` wie die bisherigen Automatik-Aufgaben, `requiredPermission` nach Global Constraints; `dueDate` aus `config.dueInDays` ab dem fachlichen Datum, sonst `null`.
|
||
- Die Meldung (Spec 9.4) sitzt in `SuggestionsService.proposeIn` (nicht in `AutomationService`), damit sie für alle Quellen gilt, auch für Kettenvorschläge (Task 11): `ProposeInput.notify` (Regeln und Ketten übergeben `config.notify`, der Autopilot nie), `proposeForRuleIn` reicht `notify: rule.config.notify` durch. Bei neu eingefügter Zeile und `notify`: Titel über `renderSuggestion` mit `buildEvidenceLookup(tx, [evidence])`, dann `recordForPermission(['tasks:read', requiredPermission], { priority: priority === 'URGENT' ? 'KRITISCH' : 'WARNUNG', category: 'SUGGESTION', message: '<Code>: <Titel>', link: colonyId ? '/colonies/<id>' : '/suggestions', relatedType: 'Suggestion', relatedId: id, dedupeKeyPrefix: 'suggestion:<id>' }, tx)`. Audit `AutomationRule CREATE` mit `suggestionId` wie bisher mit `taskId`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// automation.service.spec.ts / automation.handlers.spec.ts
|
||
it('proposeForRuleIn writes a suggestion under the rule lock and an audit entry with suggestionId', ...);
|
||
it('writes a WARNUNG only when config.notify is true', ...);
|
||
it('the varroa rule proposes nothing inside the post-treatment window', ...);
|
||
it('the stock rule anchors on the newest IN movement', ...);
|
||
it('rule handlers write nothing for an ended colony', ...); // Spec 5.3
|
||
// suggestions.service.spec.ts
|
||
it('proposeIn with notify and a new row writes one SUGGESTION notification to tasks:read plus the required permission; notify false or a skipped row writes none', ...);
|
||
// test/suggestions.e2e-spec.ts
|
||
it('an inspection without queen and eggs leaves one OPEN suggestion and no task', ...);
|
||
it('a dismissed suggestion is not proposed again for the same inspection', ...); // Delivery zurücksetzen, erneut drainen -> nichts
|
||
it('a later inspection with the same finding proposes again', ...);
|
||
it('accepting a rule suggestion without dueInDays creates a task due today', ...);
|
||
it('a chain suggestion proposed with notify true notifies like a rule suggestion', ...); // proposeForTest mit source CHAIN
|
||
it('notify writes a WARNUNG only to holders of tasks:read and inspections:read', ...);
|
||
// test/varroa-seasonal.e2e-spec.ts
|
||
it('12 mites/day in August proposes a treatment check', ...);
|
||
it('12 mites/day five days after treatment end proposes nothing', ...);
|
||
it('10.0 mites/day in August proposes nothing (yellow)', ...);
|
||
// test/post-commit-side-effects.e2e-spec.ts (bisheriger Aufgaben-Fall)
|
||
it('creates exactly one automation suggestion when the handler fails once and is retried', ...);
|
||
// follow-up-controls / raw-materials / storage: Erwartungen von Aufgabe auf Vorschlag umgestellt
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/automation` und die genannten e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- Alle Regel-Pfade (Handler und die Altpfade Schwarmkontrolle und Lagerartikel) rufen `proposeForRuleIn`; `tasks.createInTx` und die Rundmeldung entfallen in der Automatik.
|
||
- Varroa: Regel aktiv → `rateMeasurement(…)` mit den Fenstern aus `treatmentWindowsIn(tx, colonyId)` (Task 6); nur `light === 'red'` schlägt vor. `varroa-threshold.ts` entfällt.
|
||
- Beendetes Volk (Spec 5.3): Jeder Regel-Handler lädt das Volk über `tx` und kehrt zurück, wenn es nicht lebt (`isLivingColonyStatus`); die Altpfade (Schwarmkontrolle, Lagerartikel) prüfen dasselbe, soweit sie ein Volk haben.
|
||
- `dueDate` kommt aus `config.dueInDays`; fehlt `dueInDays`, bleibt es `null`, und `accept` legt die Aufgabe auf heute (Task 4, Spec 1).
|
||
- `rule-texts.ts`: je reasonCode eine Vorlage mit Beispielbeleg, z. B. `VARROA_RED` → Titel „Behandlung prüfen“, Grund „12,4 Milben pro Tag am 10.05. Grenze: 10 (LWG).“
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; alle genannten e2e-Dateien → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 8: Durchsicht → Volkskarte und besetzte Waben
|
||
|
||
**Files**
|
||
- Create: `src/colonies/colony-takeover.ts` (+ `.spec.ts`)
|
||
- Modify: `src/colonies/colonies.service.ts` (+ Spec; `updateIn` als Kern von `update`), `src/colonies/dto/create-colony.dto.ts` (`occupiedFrames`, `strength` veraltet), `src/inspections/inspections.service.ts` (+ Spec), `src/inspections/dto/create-inspection.dto.ts` (`applyToColony`, `colonyStrength` ≤ 40), `src/inspections/inspections.controller.ts`, `src/inspections/inspections.module.ts` (importiert `ColoniesModule`, `PermissionsModule`), `src/export/export.service.ts`, `src/import/import.service.ts` (+ Spec), `src/apiaries/apiary-field.service.ts` + `apiary-field.types.ts` (`occupiedFrames` in Zeile und Paket), `src/common/validation/update-dto-null-handling.spec.ts`
|
||
- Test: neu `test/inspection-takeover.e2e-spec.ts`, neu `test/colony-strength.e2e-spec.ts`
|
||
- Docs: neue Annahme „Volksstärke in besetzten Waben“ (0–40, Altwert `strength` 1–10 bleibt, keine Umrechnung, Anzeige); Annahme zur Übernahme Durchsicht → Volkskarte (im selben Request, Server nimmt die Werte aus der Durchsicht, Überspring-Gründe, Brutbild → Brutstatus = Annahme).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 2 (`ColonyCondition`-Futter), Task 3 (`Colony.occupiedFrames`); Rückgrat Task 11, 13 (Ereignisse in `create`/`update`).
|
||
- Produces:
|
||
```ts
|
||
export const COLONY_TAKEOVER_FIELDS = ['occupiedFrames', 'broodStatus', 'feedStatus'] as const;
|
||
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
|
||
export type TakeoverSkipReason = 'NO_VALUE' | 'NO_PERMISSION' | 'NEWER_INSPECTION' | 'OUT_OF_RANGE';
|
||
export interface ColonyTakeover { applied: ColonyTakeoverField[]; skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[] }
|
||
export function broodConditionFromPattern(pattern: number | null): ColonyCondition | null; // 5 EXCELLENT … 1 CRITICAL
|
||
export function planColonyTakeover(inspection: Pick<Inspection, 'colonyStrength' | 'broodPattern' | 'feedStatus'>, requested: readonly ColonyTakeoverField[],
|
||
ctx: { canUpdateColony: boolean; hasNewerInspection: boolean }): { patch: Partial<Pick<Colony, 'occupiedFrames' | 'broodStatus' | 'feedStatus'>>; takeover: ColonyTakeover };
|
||
ColoniesService.updateIn(tx: Prisma.TransactionClient, id: string, dto: UpdateColonyDto, actingUserId: string): Promise<{ before: Colony; after: Colony }>; // Audit, ColonyStatusChanged/ColonyEnded
|
||
export type InspectionWithTakeover = Inspection & { colonyTakeover: ColonyTakeover };
|
||
InspectionsService.create(dto: CreateInspectionDto, actingUserId: string): Promise<InspectionWithTakeover>;
|
||
// CreateInspectionDto.applyToColony?: ColonyTakeoverField[] (@IsOptional @IsArray @ArrayUnique @ArrayMaxSize(3) @IsIn(COLONY_TAKEOVER_FIELDS, { each: true }))
|
||
// colonyStrength und occupiedFrames: @IsInt @Min(0) @Max(40, { message: 'Höchstens 40 besetzte Waben.' })
|
||
// CreateColonyDto.strength: @ApiPropertyOptional({ deprecated: true, description: 'Volksstärke (alt, Skala 1–10)' }); UpdateColonyDto: occupiedFrames leerbar
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// colony-takeover.spec.ts
|
||
it.each([[5, 'EXCELLENT'], [4, 'GOOD'], [3, 'AVERAGE'], [2, 'WEAK'], [1, 'CRITICAL'], [null, null]])('brood pattern %p -> %p', ...);
|
||
it('takes over only requested fields that have a value', ...); // feedStatus null -> skipped NO_VALUE
|
||
it('skips everything with NO_PERMISSION when the caller may not update colonies', ...);
|
||
it('skips everything with NEWER_INSPECTION when a later inspection exists', ...);
|
||
it('skips OUT_OF_RANGE for a value outside the colony card range', ...); // Altdaten colonyStrength 45
|
||
// colonies.service.spec.ts
|
||
it('writing occupiedFrames leaves strength untouched', ...);
|
||
it('updateIn locks the colony row before reading before', ...);
|
||
// test/inspection-takeover.e2e-spec.ts
|
||
it('takes over feed and brood in the same request and writes an audit entry Colony UPDATE', ...);
|
||
it('skips with NEWER_INSPECTION when a newer inspection of the colony exists', ...);
|
||
it('answers 201 and skips with NO_PERMISSION without colonies:update', ...);
|
||
it('a correction (PATCH) takes nothing over', ...);
|
||
it('applyToColony with 4 entries or duplicates answers 400', ...);
|
||
// test/colony-strength.e2e-spec.ts
|
||
it('PATCH occupiedFrames keeps the legacy strength', ...);
|
||
it('a takeover from an inspection writes only occupiedFrames', ...);
|
||
it('colonyStrength 41 answers 400 "Höchstens 40 besetzte Waben."', ...);
|
||
it('CSV export and import carry strength and occupiedFrames', ...);
|
||
// update-dto-null-handling.spec.ts
|
||
it('contract: occupiedFrames and the varroa sampleBees (Task 6) accept null and clear the value', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/colonies src/inspections src/import src/apiaries src/common/validation` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- `ColoniesService.update` wird `prisma.$transaction((tx) => this.updateIn(tx, …))`; `updateIn` beginnt mit `lockRowForUpdate(tx, 'Colony', id)` und liest `before` erst danach (404 wie bisher), dann die bisherige Logik samt Standort-Historie, Audit und Ereignissen.
|
||
- `InspectionsService.create` in **einer** Transaktion: Durchsicht, Audit, `InspectionRecorded`; bei `applyToColony` dann `hasNewerInspection` (`date >` diese und `id ≠`), `canUpdateColony` über `PermissionResolverService.getPermissions(actingUserId)`, `planColonyTakeover`, bei nicht leerem `patch` `colonies.updateIn(tx, colonyId, patch, actingUserId)`. Werte nur aus der gespeicherten Durchsicht, nie aus dem Request. Ohne `applyToColony` → `colonyTakeover: { applied: [], skipped: [] }`.
|
||
- `occupiedFrames` in `ColonyOverviewRow` und `OfflinePackV1.colonies`; CSV-Export/-Import der Völker mit beiden Spalten.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/inspection-takeover.e2e-spec.ts`, `test/colony-strength.e2e-spec.ts`, `test/inspections.e2e-spec.ts`, `test/colonies.e2e-spec.ts`, `test/apiaries.e2e-spec.ts`, `test/export.e2e-spec.ts`, `test/import.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 9: Volk beendet – Königin freigeben und aufräumen
|
||
|
||
**Files**
|
||
- Create: `src/colonies/colony-lifecycle.ts` (+ `.spec.ts`), `src/colonies/colony-end-releases-queen.spec.ts` (Wächter), `src/suggestions/suggestions.handlers.ts` (+ Spec), `src/suggestions/cleanup.service.ts` (+ Spec), `src/suggestions/dto/apply-cleanup.dto.ts`
|
||
- Modify: `src/colonies/colonies.service.ts` (+ Spec), `src/colonies/colony-events.ts` (+ `colony-events.spec.ts`; `colonyTransitionEvents` bekommt den Kontext des Endes), `src/winter-checks/winter-checks.service.ts` (+ Spec; Payload-Erwartung), `src/domain-events/domain-event-types.ts` (`ColonyEnded`-Payload), `src/suggestions/suggestions.controller.ts`, `src/suggestions/suggestions.module.ts`, `src/suggestions/texts/cleanup-texts.ts`, `src/tasks/tasks.service.ts` (+ Spec; `cancelIn`, `completeIn`), `src/queens/queens.service.ts` (+ Spec; `updateStatusIn`)
|
||
- Test: neu `test/colony-ended.e2e-spec.ts`, `test/winter-checks.e2e-spec.ts`, `test/colonies.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE Annahme 13/16 ergänzen: Jeder Weg, auf dem ein Volk endet (Auflösen, Vereinigen, Bearbeiten auf `SOLD`/`DECEASED`, Auswinterungsverlust), beendet die Königin-Zuordnung in derselben Transaktion; Aufräum-Vorschlag.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 4, 5, 8 (`updateIn`).
|
||
- Produces:
|
||
```ts
|
||
export function releaseQueenIn(tx: Prisma.TransactionClient, colonyId: string, endDate: Date): Promise<string | null>; // offene QueenColonyHistory beenden, currentQueenId = null; liefert die Königin-ID
|
||
// DomainEventPayloads.ColonyEnded wird: { status: ColonyStatus; endedOn: string; releasedQueenId: string | null; via: 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK' }
|
||
TasksService.cancelIn(tx: Prisma.TransactionClient, id: string, actingUserId: string): Promise<Task>; // sperrt Task, + Audit, TaskUpdated
|
||
TasksService.completeIn(tx: Prisma.TransactionClient, id: string, actingUserId: string): Promise<Task>; // lockRowForUpdate(tx, 'Task', id), before lesen, markDoneIn (CAS); nur beim Übergang Audit Task UPDATE (beforeData/afterData) + TaskCompleted, schon DONE -> unverändert zurück. complete() ruft completeIn (kein zweiter Pfad), markDoneIn bleibt privat
|
||
export type ColonyEndVia = 'DISSOLVE' | 'MERGE' | 'EDIT' | 'WINTER_CHECK';
|
||
// colonyTransitionEvents(before, after, actorUserId, ended: { endedOn: string; releasedQueenId: string | null; via: ColonyEndVia } | null) – ColonyEnded trägt diese Felder; die Aufrufer rufen releaseQueenIn, bevor sie die Ereignisse bauen
|
||
QueensService.updateStatusIn(tx: Prisma.TransactionClient, id: string, status: QueenStatus, actingUserId: string): Promise<Queen>; // + Audit
|
||
export interface ApplyCleanupDto { tasks: { id: string; outcome: 'CANCEL' | 'DONE' | 'KEEP' }[]; queen?: { id: string; status: QueenStatus } | null }
|
||
export interface CleanupResult { cancelled: number; completed: number; kept: number; queenStatus: QueenStatus | null }
|
||
CleanupService.apply(suggestionId: string, dto: ApplyCleanupDto, actor: { id: string; permissions: ReadonlySet<string> }): Promise<CleanupResult>;
|
||
class SuggestionEventHandlers { @OnDomainEvent('ColonyEnded', 'suggestions.colony-ended') colonyEnded(event, tx): Promise<void> }
|
||
// POST /suggestions/:id/apply-cleanup @RequirePermission('tasks:update') @Idempotent(); zusätzlich tasks:complete für DONE, queens:update für die Königin (sonst 403)
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// colony-lifecycle.spec.ts
|
||
it('releaseQueenIn ends the open history with the end date and clears currentQueenId', ...);
|
||
it('releaseQueenIn returns null for a colony without a queen', ...);
|
||
// colonies.service.spec.ts / winter-checks.service.spec.ts (Payload-Erwartung `{ status, endedOn, releasedQueenId, via }` statt `{ status }`)
|
||
it('update to SOLD releases the queen with the day of the change and records ColonyEnded via EDIT', ...);
|
||
it('update to DECEASED releases the queen', ...);
|
||
it('dissolve and merge use releaseQueenIn', ...);
|
||
it('a winter loss with markColonyDeceased releases the queen with the check date', ...);
|
||
// colony-events.spec.ts
|
||
it('ColonyEnded carries status, endedOn, releasedQueenId and via', ...);
|
||
// tasks.service.spec.ts
|
||
it('completeIn locks the task, writes the audit entry and TaskCompleted once, and complete() uses it', ...);
|
||
// colony-end-releases-queen.spec.ts (Wächter)
|
||
it('every file that writes a terminal colony status calls releaseQueenIn', ...); // Muster: status: 'DISSOLVED'|'SOLD'|'DECEASED'|'MERGED' bzw. Status aus dto
|
||
// suggestions.handlers.spec.ts
|
||
it('colony-ended turns open suggestions OBSOLETE and proposes CLEANUP when tasks are open', ...);
|
||
it('proposes CLEANUP when an ACTIVE queen was released, even without tasks', ...);
|
||
it('proposes nothing when nothing is left to tidy up', ...);
|
||
// test/colony-ended.e2e-spec.ts
|
||
it('a winter loss with markColonyDeceased ends the queen assignment in the same transaction', ...);
|
||
it('open suggestions become OBSOLETE after draining', ...);
|
||
it('apply-cleanup cancels 2 tasks and completes 1, then the suggestion is RESOLVED', ...);
|
||
it('apply-cleanup DONE writes the audit entry and TaskCompleted like a normal completion (calendar entry removed after draining)', ...);
|
||
it('403 without tasks:complete when one task is DONE', ...);
|
||
it('400 for a task id that is not in the evidence', ...); // 'Nur Aufgaben und Königin aus dem Vorschlag.'
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/colonies src/winter-checks src/suggestions src/tasks src/queens` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- `dissolve`, `merge`, `updateIn` (Status aus dem DTO in `TERMINAL_COLONY_STATUSES`, Enddatum = heute) und `markDeceasedIfRequested` (Enddatum = Datum der Auswinterung) rufen `releaseQueenIn`; der Status der Königin ändert sich dabei nicht. Danach `colonyTransitionEvents(before, after, actor, { endedOn, releasedQueenId, via })` mit `via` `DISSOLVE`, `MERGE`, `EDIT` bzw. `WINTER_CHECK`; Handler und Tests lesen `endedOn` aus der Payload, nie aus der Uhr. `dissolve`, `merge` (alle Völker, `id` aufsteigend) und `markDeceasedIfRequested` sperren die Volkszeilen mit `lockRowForUpdate(tx, 'Colony', id)` und lesen den Zustand danach; `releaseQueenIn` liest `currentQueenId` erst unter dieser Sperre.
|
||
- `cancelIn` und `completeIn` sperren `Task`, `updateStatusIn` sperrt `Queen`, `CleanupService.apply` sperrt `Suggestion`, dann die Aufgaben (`id` aufsteigend), dann die Königin (Reihenfolge laut Global Constraints); `before` jeweils erst danach lesen.
|
||
- Handler: Volk über `tx`; `suggestions.obsoleteForColonyIn`; offene Aufgaben `relatedType = COLONY`; `releasedQueenId` mit Status `ACTIVE`; dann `proposeIn` mit `ruleKey COLONY_ENDED_CLEANUP`, `CLEANUP`, `DO`, `NORMAL`, `requiredPermission 'colonies:read'`, `anchor 'ended-<endedOn>'`, `evidence { colonyId, status, endedOn, via, openTaskIds, queenId }`. Regel aus → nur `OBSOLETE`.
|
||
- Text `COLONY_ENDED`: „B3 ist eingegangen (Auswinterung 12.03.). 2 Aufgaben sind noch offen.“ (Wort und Herkunft je `status`/`via`).
|
||
- `CleanupService.apply` in einer Transaktion: CAS prüft `OPEN` und `intent = CLEANUP`; IDs nur aus `evidence`; `CANCEL` → `cancelIn`, `DONE` → `completeIn` (Audit und `TaskCompleted` wie bei einer normalen Erledigung; das private `markDoneIn` überspränge beides), `KEEP` → nichts; Königin → `updateStatusIn`; danach `resolveIn(…, 'Aufgeräumt')`.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/colony-ended.e2e-spec.ts`, `test/winter-checks.e2e-spec.ts`, `test/colonies.e2e-spec.ts`, `test/queens.e2e-spec.ts`, `test/tasks.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 10: Ereignisse für Ableger und Königin-Zuweisung
|
||
|
||
**Files**
|
||
- Modify: `src/domain-events/domain-event-types.ts` (`ColonySplit`, `QueenAssigned`), `src/colonies/colonies.service.ts` (+ Spec; `split`), `src/colonies/dto/split-colony.dto.ts`, `src/queens/queens.service.ts` (+ Spec; `assignColony`), `src/queens/queens.module.ts`, `src/calendar-sync/calendar-sync.handlers.ts` (+ Spec), `src/webhooks/webhook.handlers.ts` (+ Spec), `src/common/after-commit-usage.spec.ts`
|
||
- Test: `test/queens.e2e-spec.ts`, `test/colonies.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE §10 „Ereignisse“: `ColonySplit`, `QueenAssigned`; Kalender `QUEEN_ASSIGNMENT` und Webhook `queen.replaced` aus Handlern; Annahme 11: Altpfad-Liste ohne Kalender.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 2 (`SplitQueenSource`).
|
||
- Produces:
|
||
```ts
|
||
// DomainEventPayloads +=
|
||
ColonySplit: { parentColonyId: string; lineageId: string; queenSource: SplitQueenSource | null; splitOn: string }; // Aggregat Colony (Ableger)
|
||
QueenAssigned: { colonyId: string; historyId: string; assignedOn: string; previousColonyId: string | null }; // Aggregat Queen
|
||
// SplitColonyDto += date?: string (@IsOptional @IsDateString, nicht nach heute -> 400 'Das Datum liegt in der Zukunft.'), queenSource?: SplitQueenSource (@IsOptional @IsEnum)
|
||
@OnDomainEvent('QueenAssigned', 'calendar-sync.queen-assignment') // enqueueIn(tx, 'QUEEN_ASSIGNMENT', historyId)
|
||
// DOMAIN_EVENT_WEBHOOKS += QueenAssigned: 'queen.replaced' (data = Königin, über tx geladen)
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// colonies.service.spec.ts
|
||
it('split records ColonySplit with queenSource and the given date, audit inside the transaction', ...);
|
||
it('split with a future date answers 400', ...);
|
||
// queens.service.spec.ts
|
||
it('assignColony records QueenAssigned with previousColonyId and calls neither webhooks.dispatch nor calendarSync', ...);
|
||
// calendar-sync.handlers.spec.ts / webhook.handlers.spec.ts
|
||
it('QueenAssigned enqueues QUEEN_ASSIGNMENT with the history id', ...);
|
||
it('QueenAssigned forwards queen.replaced with the queen', ...);
|
||
// after-commit-usage.spec.ts
|
||
expect(filesCalling('calendarSync.enqueue(')).toEqual([]);
|
||
expect(filesCalling('webhooks.dispatch(')).toEqual(['src/colonies/colonies.service.ts', 'src/honey-batches/honey-batches.service.ts', 'src/queens/queens.service.ts', 'src/storage/storage.service.ts']); // queens nur noch queen.created
|
||
// test/queens.e2e-spec.ts
|
||
it('assigning a queen leaves a calendar entry and a queen.replaced delivery after draining', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/colonies src/queens src/calendar-sync src/webhooks src/common/after-commit-usage.spec.ts` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- `split`: in der Transaktion `lockRowForUpdate(tx, 'Colony', parentId)`, Muttervolk danach lesen und „lebt“ prüfen; Lineage mit `date` (Standard heute) und `queenSource`; Audit und `ColonySplit` in der Transaktion.
|
||
- `assignColony`: in der Transaktion zuerst `lockRowForUpdate(tx, 'Colony', …)` für Zielvolk und bisheriges Volk der Königin (`id` aufsteigend), dann `lockRowForUpdate(tx, 'Queen', id)`; Königin und Völker danach lesen und die Prüfungen (verfügbar, lebt, nicht schon zugewiesen) dort ausführen; Audit und `QueenAssigned` in der Transaktion, `assignedOn = utcDay(startDate)`; `webhooks.dispatch('queen.replaced')` und `calendarSync.enqueue` entfallen; `queen.created` bleibt Altpfad.
|
||
- `CalendarSyncService.enqueue` (Altpfad) hat danach keine Aufrufer mehr; Task 12 entfernt ihn (gleiche Welle wie Task 3 wäre ein Dateikonflikt).
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/queens.e2e-spec.ts`, `test/colonies.e2e-spec.ts`, `test/calendar-sync.e2e-spec.ts`, `test/webhooks.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 11: Biotechnik-Ketten
|
||
|
||
**Files**
|
||
- Create: `src/suggestions/chains/chains.handlers.ts` (+ Spec), `src/suggestions/chains/chain-planner.ts` (+ Spec)
|
||
- Modify: `src/suggestions/suggestions.module.ts`, `src/suggestions/texts/chain-texts.ts`
|
||
- Test: neu `test/chains.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE §10 Handler-Tabelle: `chains.queen-cells`, `chains.queen-acceptance`, `chains.split-schedule`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (`ChainConfig`, `SplitScheduleConfig`), Task 4, Task 10 (Ereignisse).
|
||
- Produces:
|
||
```ts
|
||
export interface ChainCandidate { ruleKey: 'QUEEN_CELLS_SWARM_CHECK' | 'QUEEN_ACCEPTANCE_CHECK' | 'SPLIT_SCHEDULE'; intent: 'SWARM_CHECK' | 'QUEEN_ACCEPTANCE' | 'SPLIT_PLAN';
|
||
reasonCode: string; anchor: string; baseDate: string; dueDate: string; plan: PlanStepConfig[] | null; requiredPermission: PermissionKey; evidence: SuggestionEvidence }
|
||
export function queenCellsCandidate(inspection: Pick<Inspection, 'id' | 'colonyId' | 'date' | 'queenCellsPresent'>, config: ChainConfig): ChainCandidate | null;
|
||
export function queenAcceptanceCandidate(payload: DomainEventPayloads['QueenAssigned'], queenId: string, config: ChainConfig): ChainCandidate;
|
||
export function splitScheduleCandidate(colonyId: string, payload: DomainEventPayloads['ColonySplit'], config: SplitScheduleConfig): ChainCandidate | null; // queenSource null -> null
|
||
class ChainEventHandlers {
|
||
@OnDomainEvent('InspectionRecorded', 'chains.queen-cells') queenCells(event, tx): Promise<void>;
|
||
@OnDomainEvent('QueenAssigned', 'chains.queen-acceptance') queenAcceptance(event, tx): Promise<void>;
|
||
@OnDomainEvent('ColonySplit', 'chains.split-schedule') splitSchedule(event, tx): Promise<void>;
|
||
}
|
||
// PLAN_STEP_TITLES: RAISE_CELLS_CHECK 'Nachschaffungszellen prüfen', MATING_CHECK 'Begattung prüfen', EGGS_CHECK 'Stifte prüfen', ACCEPTANCE_CHECK 'Annahme der Königin prüfen'
|
||
```
|
||
- Absichten: Weiselzellen → `SWARM_CHECK` (`inspections:read`, Anker Durchsicht-ID); Zuweisung → `QUEEN_ACCEPTANCE` (`queens:read`, Anker `historyId`); Ableger `RAISE_OWN`/`QUEEN_CELL` → `SPLIT_PLAN` (`colonies:read`, Anker `lineageId`), `QUEEN` → `QUEEN_ACCEPTANCE` (`queens:read`, Anker `lineageId`).
|
||
- Dazu (Lösung Spec-Lücke, siehe Self-Review) räumt `chains.queen-cells` bei jeder Durchsicht des lebenden Volks zuerst auf (`resolveIn(…, 'Mit Durchsicht erledigt')`) und schlägt erst danach vor, weil sonst die Absichts-Prüfung den neuen Vorschlag wegen des noch offenen alten blockiert:
|
||
- `SWARM_CHECK`-Kettenvorschläge des Volks: bei `queenCellsPresent = true` **alle** offenen (jedes Fälligkeitsdatum) – danach folgt der neue Vorschlag mit dem Anker dieser Durchsicht und Fälligkeit +7; sonst die mit `dueDate ≤ Durchsichtsdatum`.
|
||
- `QUEEN_ACCEPTANCE`-Kettenvorschläge des Volks mit `dueDate ≤ Durchsichtsdatum`: nur wenn `queenSeen` oder `eggsPresent` `true` ist (Annahme der Königin beobachtet); sonst bleiben sie offen.
|
||
- Meldung (Spec 9.4): Die Handler übergeben `notify: config.notify` an `proposeIn`; geschrieben wird die Meldung von `SuggestionsService` (Task 7), die Ketten bauen keine eigene.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// chain-planner.spec.ts
|
||
it('queen cells on 2026-05-05 propose a swarm check due 2026-05-12', ...);
|
||
it('RAISE_OWN yields one SPLIT_PLAN with offsets 9, 21 (window 3) and 28 from the split date', ...);
|
||
it('QUEEN_CELL yields one step at +21; QUEEN yields an acceptance check at +7', ...);
|
||
it('a split without queenSource yields nothing', ...);
|
||
// test/chains.e2e-spec.ts
|
||
it('queen cells -> one PLAN suggestion due +7', ...);
|
||
it('RAISE_OWN -> accept creates 3 tasks at +9, +21 and +28', ...);
|
||
it('a queen assignment -> acceptance check +7', ...);
|
||
it('a split with QUEEN plus a later assignment yields one acceptance suggestion', ...);
|
||
it('an inspection on the due day resolves the open swarm check', ...);
|
||
it('a second inspection with queen cells replaces the open swarm check by a new one due +7', ...);
|
||
it('an inspection with neither queen seen nor eggs leaves the open acceptance check open; with eggs it resolves it', ...);
|
||
// chain-planner.spec.ts / chains.handlers.spec.ts
|
||
it('the handlers pass notify from the chain config to proposeIn', ...);
|
||
it('nothing for an ended colony or an inactive rule', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/suggestions/chains` und `test/chains.e2e-spec.ts` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** – Handler laden über `tx`, prüfen „Volk lebt“ und `readRule(tx, …).active`, dann `suggestions.proposeIn(tx, { source: 'CHAIN', mode: 'PLAN', priority: 'NORMAL', relatedType: 'COLONY', relatedId: colonyId, colonyId, evidence: { …, baseDate } })`. Dubletten verhindert `proposeIn` über die Absicht. Reihenfolge je Durchsicht: erst auflösen, dann `proposeIn`; alle Aufrufe mit `notify: config.notify`.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/chains.e2e-spec.ts`, `test/worker-runtime.e2e-spec.ts` (Handler-IDs Worker = App) → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 12: Wartezeit-Kette
|
||
|
||
**Files**
|
||
- Create: `src/common/warnings/plausibility-warning.ts` (+ Spec), `src/withdrawal/withdrawal-check.service.ts` (+ Spec), `src/withdrawal/withdrawal.handlers.ts` (+ Spec), `src/withdrawal/withdrawal.module.ts`
|
||
- Modify: `src/harvests/harvests.service.ts` + `harvests.controller.ts` + `harvests.module.ts` (Antwort mit `warnings`, importiert `WithdrawalModule`), `src/bottling/bottling.service.ts` + `bottling.controller.ts` + `bottling.module.ts` (Antwort mit `warnings`, importiert `WithdrawalModule`), `src/calendar-sync/calendar-sync.service.ts` (+ Spec; Quelle `WITHDRAWAL`, ungenutzter Altpfad `enqueue` samt `runAfterCommit` entfällt), `src/common/after-commit-usage.spec.ts` (`runAfterCommit`-Liste ohne Kalender), `src/calendar-sync/calendar-sync.handlers.ts` (+ Spec), `src/calendar-sync/graph-client.service.ts` (+ Spec; `endDate`), `src/calendar/calendar.service.ts` (+ Spec; Typ `WITHDRAWAL`), `src/app.module.ts`, `src/worker/worker.module.ts`
|
||
- Test: neu `test/withdrawal-chain.e2e-spec.ts`, `test/calendar.e2e-spec.ts`, `test/harvests.e2e-spec.ts`, `test/bottling.e2e-spec.ts`
|
||
- Docs: Annahme 10 ergänzen (ein Fenster-Helfer, Prüfung bei Ernte und Abfüllung – Schleuderung folgt mit T4b –, Meldung einmal je Ernte und Volk, „wieder erntefähig“, Spanne im Kalender).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (`readRule`), Task 3 (Meldungen, `syncWithdrawal`), Task 6 (`withdrawalWindowOf`, `isInWindow`, `WithdrawalWindow` aus `src/treatments/withdrawal-window.ts`, seit dem Plan-Review dort), Task 10 (Handler-Datei).
|
||
- Produces:
|
||
```ts
|
||
// WithdrawalWindow, withdrawalWindowOf und isInWindow liefert Task 6 (src/treatments/withdrawal-window.ts, Welle 3); Task 12 importiert sie nur.
|
||
export type PlausibilityWarningCode = 'WITHDRAWAL_ACTIVE' | 'EXTRACTION_EXCEEDS_HARVEST' | 'HARVEST_NET_MISSING' | 'BATCHES_EXCEED_EXTRACTION'
|
||
| 'WATER_CONTENT_HIGH' | 'WATER_CONTENT_ABOVE_LEGAL' | 'WATER_CONTENT_MISSING' | 'JAR_SIZE_DIFFERS';
|
||
export type WarningSeverity = 'HINWEIS' | 'WARNUNG' | 'BESTAETIGEN';
|
||
export interface PlausibilityWarning { code: PlausibilityWarningCode; severity: WarningSeverity; message: string; refs: { type: string; id: string }[] }
|
||
export const WARNING_SEVERITY: Readonly<Record<PlausibilityWarningCode, WarningSeverity>>; // Werte aus Global Constraints
|
||
export type WithWarnings<T> = T & { warnings: PlausibilityWarning[] };
|
||
export function withWarnings<T extends object>(record: T, warnings: readonly PlausibilityWarning[]): WithWarnings<T>;
|
||
export interface WithdrawalConflict { harvestId: string; harvestedOn: string; colonyId: string; treatmentId: string; product: string; untilDay: string }
|
||
WithdrawalCheckService.conflictsForHarvests(db: DbClient, harvestIds: readonly string[]): Promise<WithdrawalConflict[]>;
|
||
WithdrawalCheckService.conflictsForHoneyBatch(db: DbClient, honeyBatchId: string): Promise<WithdrawalConflict[]>; // Charge -> Schleuderung -> Ernten
|
||
WithdrawalCheckService.warningsFor(db: DbClient, conflicts: readonly WithdrawalConflict[]): Promise<PlausibilityWarning[]>; // 'B3: Wartezeit nach Oxuvar endet am 20.08.2026.'
|
||
WithdrawalCheckService.notifyConflictsIn(tx: Prisma.TransactionClient, conflicts: readonly WithdrawalConflict[]): Promise<number>;
|
||
class WithdrawalEventHandlers {
|
||
@OnDomainEvent(['HarvestRecorded', 'HarvestCorrected'], 'withdrawal.harvest-check')
|
||
@OnDomainEvent(['BatchBottled', 'BottlingCorrected'], 'withdrawal.bottling-check') // removed: true -> nichts
|
||
@OnDomainEvent('WithdrawalEnded', 'notifications.withdrawal-ended')
|
||
}
|
||
@OnDomainEvent(['TreatmentRecorded', 'TreatmentCorrected'], 'calendar-sync.withdrawal') // enqueueIn(tx, 'WITHDRAWAL', treatmentId)
|
||
// GraphCalendarEventInput += endDate?: Date (ganztägig, exklusiv); CalendarEventType += 'WITHDRAWAL' mit endDate (letzter Tag einschließlich), nur mit treatments:read
|
||
// POST/PATCH /harvests und POST/PATCH /bottling: Antwort WithWarnings<…>, nie 4xx wegen Wartezeit
|
||
```
|
||
- Meldung „Konflikt“: `WARNUNG`, Kategorie `WITHDRAWAL`, Empfänger mit `harvests:read` und `treatments:read`, Text „Wartezeit: {Volk} am {TT.MM.} geerntet, Wartezeit nach {Mittel} endet am {TT.MM.JJJJ}.“, Link `/harvests/<id>`, Präfix `withdrawal-conflict:<harvestId>:<colonyId>`.
|
||
- Meldung „erntefähig“ (nur bei `WITHDRAWAL_ENDED.notify` und ohne weiteres laufendes Fenster des Volks): `INFO`, Kategorie `WITHDRAWAL`, Empfänger mit `harvests:create` und `treatments:read`, Text „{Volk} ist wieder erntefähig. Wartezeit nach {Mittel} endete am {TT.MM.JJJJ}.“, Link `/colonies/<id>`, Präfix `withdrawal-ended:<treatmentId>`.
|
||
- Kalender: Titel „Wartezeit {Volk} · {Mittel}“, `date = fromDay`, `endDate = untilDay`; ohne Wartezeit → `null` (Event wird gelöscht, Rückgrat Task 8).
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// withdrawal-window.spec.ts: seit dem Plan-Review in Task 6 (Fenster-Helfer ist dort, Welle 3)
|
||
// withdrawal-check.service.spec.ts
|
||
it('finds the treatment whose window includes the harvest date per colony', ...);
|
||
it('reports each (harvest, colony) conflict only once across stages', ...); // Präfix gleich -> zweite Meldung schreibt nichts
|
||
// test/withdrawal-chain.e2e-spec.ts
|
||
it('a harvest inside the window answers 201 with a warning and leaves one notification', ...);
|
||
it('a bottling of that harvest warns again but notifies no second time', ...);
|
||
it('WithdrawalEnded yields one INFO only when no other window is running', ...);
|
||
it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removing the days unregisters it', ...);
|
||
// test/calendar.e2e-spec.ts
|
||
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/treatments src/withdrawal src/common/warnings src/calendar-sync src/calendar src/jobs` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie in Interfaces; `WithdrawalModule` (importiert `NotificationsModule`, `PermissionsModule`) in `AppModule` und `WorkerModule`; `HarvestsModule` und `BottlingModule` importieren `WithdrawalModule` für die Antwort-Warnungen (Moduldateien stehen in der Dateiliste oben). Das Fenster kommt aus `withdrawalWindowOf` (Task 6); `getWithdrawalStatus` und `withdrawalEndDay` rechnen dort schon darüber.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/withdrawal-chain.e2e-spec.ts`, `test/calendar.e2e-spec.ts`, `test/calendar-sync.e2e-spec.ts`, `test/harvests.e2e-spec.ts`, `test/bottling.e2e-spec.ts`, `test/daily-evaluation.e2e-spec.ts`, `test/treatments.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 13: Saison nach Kalender, Fälligkeit der Durchsicht, Zeit-Ereignisse
|
||
|
||
**Files**
|
||
- Create: `src/season/season-calendar.ts` (+ Spec), `src/season/season-segment.ts` (+ Spec), `src/season/inspection-due.ts` (+ Spec), `src/season/varroa-due.ts` (+ Spec), `src/season/season.service.ts` (+ Spec), `src/season/season.module.ts`, `src/season/queen-age-threshold.spec.ts` (Wächter), `src/jobs/daily-evaluation-steps.ts` (+ Spec), `src/jobs/steps/time-events.step.ts` (+ Spec)
|
||
- Modify: `src/jobs/daily-evaluation.job.ts`, `src/jobs/daily-evaluation.rules.ts` (+ Spec; `INSPECTION_INTERVAL_DAYS`, `ACTIVE_SEASON_MONTHS`, `QUEEN_AGED_MIN_YEARS` entfallen), `src/jobs/jobs.module.ts`, `src/system/job-run.service.ts` (+ Spec; `JobPartialFailure`), `src/dashboard/dashboard.service.ts` (+ Spec; `OLD_QUEEN_AGE_YEARS` entfällt)
|
||
- Test: `test/daily-evaluation.e2e-spec.ts`
|
||
- Docs: neue Annahme „Saison aus Pflanzenentwicklung“ (in T4a nur Kalender-Rückfall, Daten für Schwaben = Annahme), neue Annahme „Alter der Königin“ (`queenMaxAgeYears = 2`, Begründung aus den Spec-Entscheidungen); ARCHITECTURE §10 „Zeitbasierte Ereignisse“ und „Jobs“: Schritte und Zähler.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (`SeasonState`, Configs, `readRule`, `dates.ts`).
|
||
- Produces:
|
||
```ts
|
||
export function calendarPhase(today: string, fallback: Record<PhenologyPhaseKey, string>): { phase: PhenologyPhaseKey | null; since: string | null }; // Phase des Vorjahres-WINTER vor SALWEIDE
|
||
export function segmentOf(phase: PhenologyPhaseKey | null, swarmSeason: PhenologyConfig['swarmSeason']): SeasonSegmentKey;
|
||
export function calendarSeasonState(apiaryId: string, today: string, config: PhenologyConfig): SeasonState; // phaseSource 'CALENDAR'
|
||
export interface InspectionDue { dueOn: string; firstOfYear: boolean; intervalDays: number }
|
||
export function inspectionDueOn(input: { createdOn: string; lastInspectedOn: string | null }, season: Pick<SeasonState, 'segment' | 'springSince'>, config: InspectionIntervalConfig): InspectionDue | null;
|
||
export interface VarroaDue { dueOn: string; kind: 'INTERVAL' | 'WINTER_WINDOW'; anchor: string }
|
||
export function varroaMeasurementDue(input: { createdOn: string; lastMeasuredOn: string | null; lastMeasurementId: string | null }, season: Pick<SeasonState, 'segment'>, config: SeasonAutopilotConfig, today: string): VarroaDue | null;
|
||
export function isQueenAged(hatchYear: number | null, today: string, queenMaxAgeYears: number): boolean;
|
||
SeasonService.statesFor(db: DbClient, apiaryIds: readonly string[], today: string): Promise<Map<string, SeasonState>>; // T4a: Kalender; Task 25: ApiaryPhenology
|
||
export const DAILY_EVALUATION_STEP_ORDER = ['phenology', 'time-events', 'autopilot', 'suggestions-expiry', 'digest'] as const;
|
||
export type DailyEvaluationStepName = (typeof DAILY_EVALUATION_STEP_ORDER)[number];
|
||
export interface DailyEvaluationStep { readonly name: DailyEvaluationStepName; run(today: string): Promise<JobResult> }
|
||
export function runDailyEvaluationSteps(steps: readonly DailyEvaluationStep[], today: string, logger: Logger): Promise<{ result: JobResult; failed: DailyEvaluationStepName[] }>;
|
||
export class JobPartialFailure extends Error { constructor(readonly result: JobResult, readonly failedSteps: readonly string[]) } // JobRunService speichert result bei FAILED
|
||
TimeEventsStep.run(today: string): Promise<JobResult>; // WithdrawalEnded, TaskDue, QueenAged, VarroaMeasurementDue, InspectionOverdue
|
||
```
|
||
- Regeln `time-events` (Nachholfenster 7 Tage, `dailyDedupeKey`, je Typ eine Transaktion wie bisher): `InspectionOverdue` am Tag nach `inspectionDueOn`, nie in `WINTERRUHE`; `VarroaMeasurementDue` am `dueOn` aus `varroaMeasurementDue`; `QueenAged` nicht mehr nur am `<Jahr>-01-01` (das Nachholfenster von 7 Tagen ließ eine später zugewiesene oder später gealterte Königin das ganze Jahr aus; Entscheidung zum Übertrag aus Rückgrat Task 18): an jedem Jobtag für jedes lebende Volk, dessen aktuelle Königin `isQueenAged(hatchYear, heute, queenMaxAgeYears)` ist, einmal je Königin und Jahr – Schlüssel `dailyDedupeKey('QueenAged', queenId, '<Jahr>-01-01')` (Jahresanker), Ereignis am Jobtag; das deckt Zuweisung im Jahr, Wechsel des Schlupfjahres und Jahreswechsel ab. Fälligkeiten im Winter (November–Februar) bleiben ohne Ereignis in `WINTERRUHE`; `inspectionDueOn` setzt sie auf den Beginn von `FRUEHJAHR` („Erste Durchsicht im Jahr“), `InspectionOverdue` folgt am Tag danach und A1 (Task 14) schlägt `INSPECTION_FIRST_OF_YEAR` vor; der Saisonbeginn selbst erscheint als Meldung `season-segment` (Task 26), eine feste Märzmeldung entfällt; `WithdrawalEnded` unverändert.
|
||
- A2-Winterfenster: Abschnitt `WINTERRUHE`, heute zwischen `11-15` und `12-20`, keine Messung seit `11-01` → `dueOn = <Jahr>-11-15`, `anchor = 'winter-<Jahr>'`; sonst außerhalb `WINTERRUHE` `(letzte Messung oder createdOn) + varroaMeasureIntervalDays`, `anchor` = Mess-ID oder `'none'`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// season-segment.spec.ts
|
||
it.each([[null, 'WINTERRUHE'], ['SALWEIDE', 'FRUEHJAHR'], ['KIRSCHE', 'SCHWARMZEIT'], ['APFEL', 'SCHWARMZEIT'], ['LINDE', 'SOMMER'],
|
||
['TRACHTENDE', 'SPAETSOMMER'], ['HERBST', 'HERBST'], ['WINTER', 'WINTERRUHE']])('%p -> %p', ...);
|
||
it('honours a configured swarm season start at APFEL', () => expect(segmentOf('KIRSCHE', { from: 'APFEL', until: 'LINDE' })).toBe('FRUEHJAHR'));
|
||
// season-calendar.spec.ts
|
||
it('2026-03-10 is WINTERRUHE since the WINTER date of 2025', ...);
|
||
it('2026-05-12 is SCHWARMZEIT since 2026-04-15 by calendar', ...);
|
||
// inspection-due.spec.ts
|
||
it('adds the interval of the current segment to the last inspection', ...); // SCHWARMZEIT 7
|
||
it('returns null in WINTERRUHE', ...);
|
||
it('counts from createdAt without any inspection', ...);
|
||
it('is due from the start of FRUEHJAHR when the last inspection was before it (first of the year)', ...);
|
||
// varroa-due.spec.ts
|
||
it('is due 28 days after the last measurement outside WINTERRUHE', ...);
|
||
it('is due on 11-15 in the winter window without a measurement since 11-01', ...);
|
||
// time-events.step.spec.ts
|
||
it('the job day at 04:30 Berlin equals the UTC day of dailyDedupeKey', ...);
|
||
it('emits InspectionOverdue on the day after inspectionDueOn and none in WINTERRUHE', ...);
|
||
it('QueenAged uses queenMaxAgeYears', ...);
|
||
it('QueenAged fires on the first job day a queen assigned in June is aged, and not a second time in the same year', ...);
|
||
it('QueenAged is not limited to January 1–8', ...);
|
||
it('a due date inside the winter yields InspectionOverdue the day after the start of FRUEHJAHR and none inside WINTERRUHE', ...);
|
||
// daily-evaluation-steps.spec.ts
|
||
it('a failing step leaves the others running and the run FAILED with all counters', ...);
|
||
// dashboard.service.spec.ts
|
||
it('OLD_QUEEN starts at queenMaxAgeYears (2)', ...);
|
||
// queen-age-threshold.spec.ts (Wächter)
|
||
it('there is no second queen age threshold in src', () => expect(offenders(/OLD_QUEEN_AGE_YEARS|QUEEN_AGED_MIN_YEARS/)).toEqual([]));
|
||
// test/daily-evaluation.e2e-spec.ts
|
||
it('InspectionOverdue follows the segment interval', async () => { await app.get(TimeEventsStep).run('2026-05-12'); … });
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/season src/jobs src/system src/dashboard` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- `DailyEvaluationJob.run()`: `today = berlinToday()`, Schritte in `DAILY_EVALUATION_STEP_ORDER` (Konstruktor injiziert die vorhandenen Schritte; Tasks 14, 16, 25 ergänzen ihre), `runDailyEvaluationSteps`; bei `failed.length > 0` → `throw new JobPartialFailure(result, failed)`; `JobRunService.run` speichert dann `result` und `error`.
|
||
- `SeasonService.statesFor` liest `PHENOLOGY` einmal und baut `calendarSeasonState` je Stand.
|
||
- Dashboard liest `queenMaxAgeYears` über `readRule(prisma, 'SEASON_AUTOPILOT')`.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/daily-evaluation.e2e-spec.ts`, `test/jobs.e2e-spec.ts`, `test/dashboard.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 14: Saison-Autopilot – Kern mit A1–A3
|
||
|
||
**Files**
|
||
- Create: `src/autopilot/autopilot.types.ts`, `src/autopilot/autopilot-rules.ts` (+ Spec), `src/autopilot/autopilot-input.ts` (+ Spec), `src/autopilot/autopilot.service.ts` (+ Spec), `src/autopilot/autopilot.handlers.ts` (+ Spec), `src/autopilot/autopilot.step.ts`, `src/autopilot/suggestions-expiry.step.ts`, `src/autopilot/autopilot.module.ts`
|
||
- Modify: `src/suggestions/texts/autopilot-texts.ts`, `src/app.module.ts`, `src/worker/worker.module.ts`, `src/jobs/jobs.module.ts`, `src/jobs/daily-evaluation.job.ts`
|
||
- Test: neu `test/autopilot.e2e-spec.ts`, `test/daily-evaluation.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE §10: Handler `autopilot.colony-sync`, Job-Schritte `autopilot` und `suggestions-expiry`, Abgleich (Spec 8.3).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 4, 10, 13.
|
||
- Produces:
|
||
```ts
|
||
export interface AutopilotInput {
|
||
colony: { id: string; code: string; status: ColonyStatus; colonyType: ColonyType; apiaryId: string; createdOn: string; feedStatus: ColonyCondition | null };
|
||
lastInspection: { id: string; date: string; colonyStrength: number | null; feedStatus: ColonyCondition | null; queenCellsPresent: boolean | null } | null;
|
||
lastVarroa: { id: string; date: string } | null;
|
||
lastTreatment: { id: string; startDay: string; endDay: string } | null;
|
||
varroaSinceLastTreatmentEnd: boolean;
|
||
feedingsSinceInspection: number; feedingsSinceLateSummer: number; treatmentsSinceLateSummer: number; // A4–A6 (T4b)
|
||
queen: { id: string; hatchYear: number | null } | null;
|
||
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
|
||
season: SeasonState;
|
||
}
|
||
export interface AutopilotConfigs { autopilot: ActiveRule<'SEASON_AUTOPILOT'>; interval: ActiveRule<'INSPECTION_INTERVAL'>; phenology: PhenologyConfig }
|
||
export interface AutopilotCandidate { rule: AutopilotRuleId; ruleKey: 'SEASON_AUTOPILOT' | 'INSPECTION_INTERVAL'; intent: SuggestionIntent; mode: SuggestionMode;
|
||
reasonCode: string; evidence: SuggestionEvidence; dueDate: string; priority: TaskPriority; anchor: string; requiredPermission: PermissionKey }
|
||
export type AutopilotRule = (input: AutopilotInput, configs: AutopilotConfigs, today: string) => AutopilotCandidate | null;
|
||
export const AUTOPILOT_RULES: Readonly<Partial<Record<AutopilotRuleId, AutopilotRule>>>; // A1–A3 hier, A4–A10 in Task 27
|
||
export function evaluateAutopilot(input: AutopilotInput, configs: AutopilotConfigs, today: string): AutopilotCandidate[];
|
||
export function loadAutopilotInputIn(tx: Prisma.TransactionClient, colonyId: string, season: SeasonState): Promise<AutopilotInput | null>; // feste Folge von Abfragen
|
||
export interface AutopilotSyncResult { opened: number; refreshed: number; resolved: number }
|
||
AutopilotService.syncColonyIn(tx: Prisma.TransactionClient, colonyId: string, today: string): Promise<AutopilotSyncResult>;
|
||
class AutopilotEventHandlers { @OnDomainEvent([...], 'autopilot.colony-sync') colonySync(event, tx): Promise<void> }
|
||
AutopilotStep.run(today): Promise<JobResult>; // { autopilotColonies, suggestionsOpened, suggestionsResolved }, je Volk eine Transaktion
|
||
SuggestionsExpiryStep.run(today): Promise<JobResult>; // { suggestionsExpired }
|
||
```
|
||
- `autopilot.colony-sync` hört auf: `InspectionRecorded`, `InspectionCorrected`, `VarroaMeasured`, `VarroaCorrected`, `TreatmentRecorded`, `TreatmentCorrected`, `FeedingRecorded`, `FeedingCorrected`, `HarvestRecorded`, `HarvestCorrected` (je Volk), `ColonyStatusChanged`, `ColonySplit` (Ableger und Muttervolk), `QueenAssigned` (Volk und `previousColonyId`), `TaskCompleted`, `TaskUpdated` (Aufgabe über `tx` laden, nur `relatedType = COLONY`), `WithdrawalEnded`, `QueenAged` (`colonyId`), `InspectionOverdue`, `VarroaMeasurementDue`. `today = berlinToday()`.
|
||
- Regeln: A1 `INSPECT`/`DO`/`INSPECTION_INTERVAL`, Bedingung `segment ≠ WINTERRUHE` und `dueOn ≤ heute + 2`, Fälligkeit `dueOn`, `HIGH` wenn `dueOn < heute`, Anker letzte Durchsicht-ID oder `none`, reasonCode `INSPECTION_DUE` bzw. `INSPECTION_FIRST_OF_YEAR`. A2 `VARROA_MEASURE`/`DO` aus `varroaMeasurementDue`, reasonCode `VARROA_MEASURE_DUE` bzw. `VARROA_WINTER_MEASURE`. A3 `VARROA_RECHECK`/`DO`, Behandlungsende vor ≥ `recheckAfterDays`, keine Messung seither, Fälligkeit `endDay + 14`, Anker Behandlungs-ID, reasonCode `VARROA_RECHECK_DUE`. Alle `expiresAt = dueDate + suggestionTtlDays`.
|
||
- Abgleich exakt Spec 8.3: Volkszeile über `lockRowForUpdate(tx, 'Colony', colonyId)`, Eingaben erst danach laden; beendet → Ende; `SEASON_AUTOPILOT` aus → alle offenen `AUTOPILOT` → `RESOLVED` („Autopilot aus“); Kandidat mit offenem Vorschlag gleichen Schlüssels → `refreshIn`; sonst offene Sache gleicher Absicht → verwerfen; sonst `proposeIn`; offene Autopilot-Vorschläge ohne Kandidat → `RESOLVED` („Bedingung entfällt“). `resolveIn` und `expireDue` geben den `dedupeKey` frei (Task 4), sodass dieselbe Bedingung nach Ablauf oder Erledigung neu vorgeschlagen werden darf; `DISMISSED` hält ihn. Regel- und Kettenvorschläge bleiben unberührt.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// autopilot-rules.spec.ts – je Regel „gegeben → erwartet“ mit Anker und reasonCode
|
||
it('A1: last inspection 8 days ago in SCHWARMZEIT -> INSPECT overdue, HIGH, anchor = inspection id', ...);
|
||
it('A1: due in 2 days -> NORMAL; due in 3 days -> nothing; WINTERRUHE -> nothing', ...);
|
||
it('A1: last inspection before spring start -> INSPECTION_FIRST_OF_YEAR', ...);
|
||
it('A2: last measurement 35 days ago -> VARROA_MEASURE due day 28', ...);
|
||
it('A2: winter window without a measurement since 11-01 -> anchor winter-2026', ...);
|
||
it('A3: treatment ended 14 days ago without a measurement since -> VARROA_RECHECK, anchor treatment id', ...);
|
||
it('a rule switched off in config.rules yields nothing', ...);
|
||
// autopilot.service.spec.ts
|
||
it('locks the colony row first and does nothing for an ended colony', ...);
|
||
it('refreshes an open suggestion with the same key instead of creating one', ...);
|
||
it('drops a candidate when an open task of the same intent exists', ...);
|
||
it('resolves open autopilot suggestions whose key vanished, never rule or chain suggestions', ...);
|
||
it('resolves every open autopilot suggestion when SEASON_AUTOPILOT is off', ...);
|
||
// test/autopilot.e2e-spec.ts (Abgleich mit festem Tag)
|
||
it('swarm season by calendar, last inspection 9 days ago -> step INSPECT overdue', ...); // syncColonyIn(tx, id, '2026-05-12')
|
||
it('after a new inspection the INSPECT suggestion is RESOLVED', ...);
|
||
it('two colony syncs in parallel leave one INSPECT suggestion', ...);
|
||
// test/daily-evaluation.e2e-spec.ts
|
||
it('running autopilot and expiry twice yields the same suggestion counts', ...);
|
||
it('an OPEN suggestion past expiresAt becomes EXPIRED', ...);
|
||
it('an expired INSPECT suggestion of the same anchor is proposed again on the next sync (dedupeKey was released)', ...);
|
||
it('a RESOLVED autopilot suggestion whose condition returns is proposed again', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/autopilot` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben; `AutopilotModule` importiert `SeasonModule`, `SuggestionsModule`; Texte in `autopilot-texts.ts` (z. B. `INSPECTION_DUE` → „Durchsicht“ / „Letzte Durchsicht vor 8 Tagen. In der Schwarmzeit alle 7 Tage.“).
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/autopilot.e2e-spec.ts`, `test/daily-evaluation.e2e-spec.ts`, `test/worker-runtime.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 15: Die nächsten 3 Schritte und „Heute“
|
||
|
||
**Files**
|
||
- Create: `src/autopilot/next-step-ranking.ts` (+ Spec), `src/autopilot/next-steps.service.ts` (+ Spec), `src/autopilot/colony-next-steps.controller.ts`, `src/autopilot/autopilot-today.controller.ts`
|
||
- Modify: `src/autopilot/autopilot.module.ts`, `src/apiaries/apiary-field.service.ts` (+ Spec), `src/apiaries/apiary-field.types.ts`, `src/apiaries/apiaries.module.ts` (importiert `AutopilotModule`)
|
||
- Test: neu `test/next-steps.e2e-spec.ts`, `test/apiaries.e2e-spec.ts`
|
||
- Docs: T3-Annahme „Ampeln“: Stand-Ampel gelb ab überschrittenem `inspectionDueOn`; Warnungen/Kritisch aus offenen Vorschlägen `HIGH`/`URGENT`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 4, 5 (`visibleWhere`), 6 (`withdrawalWindowOf`, seit dem Plan-Review dort), 13, 14.
|
||
- Produces (Spec 8.5 wörtlich, dazu):
|
||
```ts
|
||
export interface NextStep { key: string; kind: 'TASK' | 'SUGGESTION'; intent: SuggestionIntent | null; title: string; reasons: string[]; dueOn: string | null;
|
||
urgency: 'OVERDUE' | 'TODAY' | 'SOON' | 'LATER' | 'NONE';
|
||
action: { type: 'CAPTURE'; form: NextStepForm; href: string } | { type: 'ACCEPT'; suggestionId: string }
|
||
| { type: 'CLEANUP'; suggestionId: string } | { type: 'OPEN_TASK'; taskId: string; href: string };
|
||
dismissible: boolean }
|
||
export interface ColonyNextSteps { colonyId: string; segment: SeasonSegmentKey; phase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null;
|
||
inspectionDueOn: string | null; withdrawalUntil: string | null; steps: NextStep[] }
|
||
export interface StepSource { kind: 'TASK' | 'SUGGESTION'; id: string; intent: SuggestionIntent | null; mode: SuggestionMode | null; status: string;
|
||
priority: TaskPriority; dueOn: string | null; createdAt: Date; title: string; reasons: string[]; colonyId: string }
|
||
export const INTENT_ORDER: readonly SuggestionIntent[]; // Spec 8.4 Punkt 3; Aufgabe ohne Vorschlag = FOLLOW_UP
|
||
export type NextStepForm = 'inspection' | 'varroa' | 'feeding' | 'treatment' | 'winter-check';
|
||
export const FORM_OF_INTENT: Readonly<Partial<Record<SuggestionIntent, NextStepForm>>>;
|
||
// INSPECT, SWARM_CHECK, QUEEN_CHECK, QUEEN_ACCEPTANCE, HONEY_SUPER, HEALTH_CHECK -> inspection; VARROA_MEASURE, VARROA_RECHECK -> varroa;
|
||
// FEED -> feeding; VARROA_TREAT, SUMMER_TREATMENT -> treatment; WINTER_CHECK -> winter-check
|
||
export function rankSteps(items: readonly StepSource[], today: string): StepSource[];
|
||
export function mergeSameForm(ranked: readonly StepSource[]): StepSource[][]; // nur Gruppen mit mindestens einem DO-Vorschlag
|
||
export function toNextStep(group: readonly StepSource[], today: string): NextStep;
|
||
export interface AutopilotToday {
|
||
nextStep: (NextStep & { colonyId: string; colonyCode: string }) | null;
|
||
inspectionDue: { apiaryId: string; apiaryName: string; segment: SeasonSegmentKey; intervalDays: number | null; colonies: { id: string; code: string; dueOn: string; overdueDays: number }[] }[];
|
||
suggestionsOpen: number; seasons: { apiaryId: string; apiaryName: string; segment: SeasonSegmentKey; phase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null }[];
|
||
tasksVisible: boolean;
|
||
}
|
||
NextStepsService.forColony(colonyId: string, userId: string, today?: string): Promise<ColonyNextSteps>; // höchstens maxStepsPerColony (3)
|
||
NextStepsService.forColonies(colonyIds: readonly string[], userId: string, today?: string): Promise<Map<string, ColonyNextSteps>>; // feste Abfragen, kein N+1
|
||
NextStepsService.today(userId: string, today?: string): Promise<AutopilotToday>;
|
||
// GET /colonies/:id/next-steps (colonies:read), GET /autopilot/today (colonies:read; Aufgaben nur mit tasks:read)
|
||
// ColonyOverviewRow += inspectionDueOn: string | null; segment: SeasonSegmentKey; nextStepTitle: string | null; openWarnings/openCritical = offene sichtbare Vorschläge HIGH/URGENT
|
||
// OfflinePackV1 += nextSteps?: Record<string, NextStep[]> (version bleibt 1)
|
||
```
|
||
- Rang (Spec 8.4): Dringlichkeit (`IN_PROGRESS`, überfällig oder heute = 0; ≤ 3 Tage = 1; ≤ 14 = 2; später = 3; ohne Datum = 4, ohne Datum und `URGENT` = 0), Priorität, `INTENT_ORDER`, Fälligkeit, `createdAt`, `id`. Zusammengelegte Gruppe: dringlichster Rang, höchstens zwei Gründe, Aktion `CAPTURE`, Titel „Durchsicht mit Schwarmkontrolle“ (Formular-Titel + „mit“ + zweiter Titel).
|
||
- `href`: `/inspections/new?colonyId=<id>`, `/varroa/new?colonyId=<id>`, `/feedings/new?colonyId=<id>`, `/treatments/new?colonyId=<id>`, `/winter?colonyId=<id>&season=AUTUMN|SPRING`; Aufgabe `/tasks`.
|
||
- Sichtbarkeit: Vorschläge über `visibleWhere` **und nur mit `tasks:read`** (wie `GET /suggestions`; ohne `tasks:read` gibt es keine Vorschlags-Schritte, die Aktion `ACCEPT` zusätzlich nur mit `tasks:create`, sonst höchstens der `CAPTURE`-Link, falls die Absicht ein Formular hat), Aufgaben nur mit `tasks:read`; `withdrawalUntil` = jüngstes laufendes `untilDay` nur mit `treatments:read`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// next-step-ranking.spec.ts
|
||
it('IN_PROGRESS comes first, then overdue, then by priority and the intent table', ...);
|
||
it('a dateless URGENT step counts as urgent', ...);
|
||
it('INSPECT (DO) and SWARM_CHECK merge into one step with two reasons and CAPTURE', ...);
|
||
it('two PLAN steps of the same form do not merge', ...);
|
||
it('never returns more than 3 and is stable by id', ...);
|
||
// test/next-steps.e2e-spec.ts – Beispiel B3 am 2026-05-12 aus Spec 8.5
|
||
it('returns the three steps of the spec example in order', ...);
|
||
it('next steps never carry a varroa reason without varroa:read', ...);
|
||
it('a user without tasks:read gets no suggestion steps and no ACCEPT action', ...);
|
||
it('withdrawalUntil appears as a hint, not as a step', ...);
|
||
it('GET /autopilot/today lists due inspections per apiary and hides tasks without tasks:read', ...);
|
||
// test/apiaries.e2e-spec.ts
|
||
it('colony-overview carries inspectionDueOn, segment and nextStepTitle; warnings come from suggestions', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/autopilot src/apiaries` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben; Texte über `renderSuggestion` mit einem `EvidenceLookup` für alle Völker.
|
||
- [ ] **Schritt 4:** → PASS, inklusive `test/authorization-coverage.e2e-spec.ts`.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 16: Erinnerungen in der App – Aufgabe fällig und Morgenübersicht
|
||
|
||
**Files**
|
||
- Create: `src/reminders/task-due.handlers.ts` (+ Spec), `src/reminders/digest.service.ts` (+ Spec), `src/reminders/digest.step.ts`, `src/reminders/reminders.module.ts`
|
||
- Modify: `src/app.module.ts`, `src/worker/worker.module.ts`, `src/jobs/jobs.module.ts`, `src/jobs/daily-evaluation.job.ts`
|
||
- Test: neu `test/reminders.e2e-spec.ts`
|
||
- Docs: neue Annahme „Erinnerungen“ (je fällige Aufgabe und Morgenübersicht, Empfänger, Inhalt mit Stand 04:30, keine leere Übersicht, Morgenzeit 05:00–12:00); ARCHITECTURE §10: Handler `notifications.task-due`, Job-Schritt `digest`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 3, 13, 14, 15 (`NextStepsService.today`-Daten für Durchsicht und Saison).
|
||
- Produces:
|
||
```ts
|
||
class TaskDueHandlers { @OnDomainEvent('TaskDue', 'notifications.task-due') taskDue(event, tx): Promise<void> }
|
||
export interface DigestLine { text: string; permissions: PermissionKey[] }
|
||
export interface DigestData { today: string; tasks: { assignedUserId: string | null; overdue: boolean }[]; inspectionDue: AutopilotToday['inspectionDue'];
|
||
suggestions: { requiredPermission: string | null }[]; seasonChanges: { apiaryName: string; segment: SeasonSegmentKey; intervalDays: number | null }[] }
|
||
export function buildDigest(userId: string, permissions: ReadonlySet<string>, data: DigestData): { message: string; requiredPermissions: PermissionKey[] } | null;
|
||
DigestService.runFor(today: string): Promise<number>; // je aktivem Nutzer mit digestEnabled eine Transaktion
|
||
DigestStep.run(today: string): Promise<JobResult>; // { digests }
|
||
```
|
||
- `notifications.task-due`: Aufgabe über `tx`; nur `OPEN`/`IN_PROGRESS`; Empfänger = Zuständiger, sonst alle aktiven Nutzer mit `tasks:complete`; immer zusätzlich `tasks:read` und `taskDueEnabled` (fehlende Präferenz = an); `HINWEIS`, `TASK_DUE`, „Heute fällig: {Titel}“ (Titel ≤ 200 Zeichen), Link `/tasks`, `relatedType 'Task'`, `relatedId`, `requiredPermissions ['tasks:read']`, Präfix `task-due:<taskId>:<dueOn>`.
|
||
- Morgenübersicht: erste Zeile „Guten Morgen. Heute für dich:“, Zeilen nur mit Recht (Spec 9a.3): Aufgaben „{n} Aufgaben fällig, {k} davon überfällig.“ (Zuständige = Nutzer oder ohne Zuständigen bei `tasks:complete`; `tasks:read`), Durchsicht „Durchsicht fällig: {n} Völker am Stand {Name}.“ (`colonies:read` + `inspections:read`), Vorschläge „{n} Vorschläge warten.“ (`tasks:read`, gezählt nach `requiredPermission`), Saison „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“ (`apiaries:read`, nur bei `segmentSince = heute`). Keine Zeile → keine Übersicht. `INFO`, `DIGEST`, Link `/dashboard`, Präfix `digest:<Tag>`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// task-due.handlers.spec.ts
|
||
it('notifies the assignee only', ...);
|
||
it('without an assignee notifies holders of tasks:complete', ...);
|
||
it('writes nothing for a user with taskDueEnabled false', ...);
|
||
it('uses the key task-due:<taskId>:<day>:<userId>', ...);
|
||
// digest.service.spec.ts
|
||
it('includes a line only with its permission and lists every permission used', ...);
|
||
it('builds no digest without content', ...);
|
||
it('writes at most one digest per user and day', ...);
|
||
// test/reminders.e2e-spec.ts
|
||
it('TaskDue after draining yields exactly one notification per recipient, also after redelivery', ...);
|
||
it('running the digest step twice yields one digest per user', ...);
|
||
it('the digest of a user without varroa or inspection rights has no such line', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/reminders` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben; `DigestService` lädt die Daten einmal je Lauf und filtert je Nutzer. `RemindersModule` importiert `PermissionsModule` (für `PermissionResolverService`): `WorkerModule` hat keinen globalen Resolver, und `DigestService` braucht ihn beim Start; in `AppModule` ist `PermissionsModule` ohnehin global, in `JobsModule` liefert `JobPermissionsModule` einen eigenen Resolver (der zusätzliche Import ändert dort nichts am Verhalten; `PermissionCatalogSyncService.onModuleInit` ist idempotent, `skipDuplicates`). `test/worker-runtime.e2e-spec.ts` startet `WorkerModule` und löst damit die Abhängigkeit auf.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/reminders.e2e-spec.ts`, `test/daily-evaluation.e2e-spec.ts`, `test/worker-runtime.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 17: Frontend – Seite „Vorschläge“
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/suggestions/types.ts`, `api.ts`, `labels.ts` (+ `labels.spec.ts`), `grouping.ts` (+ `grouping.spec.ts`), `use-suggestion-summary.ts`
|
||
- Create: `src/components/suggestions/suggestion-card.tsx` (+ Spec), `suggestion-group.tsx` (+ Spec), `due-date-sheet.tsx`
|
||
- Create: `src/app/(app)/suggestions/page.tsx` (+ `page.spec.tsx`)
|
||
- Modify: `src/app/(app)/nav-config.ts` (+ Spec), `src/app/(app)/sidebar.tsx`, `src/app/(app)/more-sheet.tsx`, `src/components/task-status.tsx` (`TaskSourceBadge`), `src/lib/tasks/types.ts` (`sourceSuggestionId`)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 5 (Routen, `SuggestionView`, `SuggestionSummary`).
|
||
- Produces:
|
||
```ts
|
||
export type 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';
|
||
export interface Suggestion { /* Spiegel von SuggestionView */ }
|
||
export function fetchSuggestions(query?: { status?: 'OPEN' | 'DISMISSED'; colonyId?: string; apiaryId?: string }): Promise<Suggestion[]>; // Key ["suggestions", query]
|
||
export function fetchSuggestionSummary(): Promise<SuggestionSummary>; // Key ["suggestions", "summary"]
|
||
export function acceptSuggestion(id: string, input: { dueDate?: string | null }, options: CreateOptions): Promise<AcceptSuggestionResult>;
|
||
export function bulkAcceptSuggestions(ids: string[], options: CreateOptions): Promise<AcceptSuggestionResult[]>;
|
||
export function dismissSuggestion(id: string, note: string | null, options: CreateOptions): Promise<Suggestion>;
|
||
export function bulkDismissSuggestions(ids: string[], options: CreateOptions): Promise<{ dismissed: number }>;
|
||
export function reopenSuggestion(id: string, options: CreateOptions): Promise<Suggestion>;
|
||
export const INTENT_LABELS: Record<SuggestionIntent, string>;
|
||
export const INTENT_ICONS: Record<SuggestionIntent, LucideIcon>;
|
||
export interface SuggestionGroupModel { key: string; label: string; apiaryId: string | null;
|
||
rows: ({ kind: 'single'; suggestion: Suggestion } | { kind: 'combined'; intent: SuggestionIntent; suggestions: Suggestion[] })[] }
|
||
export function groupSuggestions(list: readonly Suggestion[]): SuggestionGroupModel[]; // je Stand, dann „Betrieb“; gleiche Absicht je Stand zusammengefasst
|
||
// NavItem += counter?: 'suggestions'
|
||
```
|
||
- Bedienung (Spec 11.3): Kopf „{n} Vorschläge“ + „Übernehmen plant eine Aufgabe. Verwerfen blendet den Vorschlag aus.“; `INSPECT`-Gruppe „Durchsicht fällig · 6 Völker“ mit „Runde starten“ (`startRound` mit den Völkern, Reihenfolge nach Code, dann Navigation zur ersten Durchsicht); PLAN-Gruppe „Honigraum prüfen · 3 Völker“ mit „Alle übernehmen“ (`bulk-accept`), Aufklappen als Button mit `aria-expanded`; Karte: Icon, Titel, Gründe, `DueChip`, Hauptknopf („Übernehmen“, „Erfassen“ → `CAPTURE`-Link, „Aufräumen“ → Task 18), Text-Knopf „Verwerfen“, Menü „Als Aufgabe planen“ (DO) bzw. „Termin ändern“ (PLAN) über `DueDateSheet`; Toasts „Aufgabe angelegt · Ansehen“ und „Verworfen · Rückgängig“ (8 s, Rückgängig = `reopen`); unten Link „Verworfene (30 Tage)“ mit „Wieder öffnen“; leer „Keine offenen Vorschläge.“
|
||
- Navigation: „Vorschläge“ in Gruppe „Arbeit“, Icon `Lightbulb`, Recht `tasks:read`, Zähler aus `summary.open`. `TaskSourceBadge`: „Aus Vorschlag“ (Titel „Aus einem übernommenen Vorschlag“) bei `sourceSuggestionId` oder `sourceAutomationTrigger`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
// labels.spec.ts
|
||
it('every intent has a label and an icon', () => { for (const i of INTENTS) { expect(INTENT_LABELS[i]).toBeTruthy(); expect(INTENT_ICONS[i]).toBeTruthy(); } });
|
||
// grouping.spec.ts
|
||
it('groups by apiary, then „Betrieb“, and combines the same intent per apiary', ...);
|
||
// page.spec.tsx
|
||
it('shows „3 Vorschläge“ and the explanation', ...);
|
||
it('„Alle übernehmen“ sends one bulk-accept with the group ids', ...);
|
||
it('„Verwerfen“ shows „Verworfen · Rückgängig“ and Rückgängig reopens', ...);
|
||
it('lists dismissed suggestions with „Wieder öffnen“', ...);
|
||
it('shows „Keine offenen Vorschläge.“ when empty', ...);
|
||
it('„Runde starten“ starts a round with the due colonies in code order', ...);
|
||
// suggestion-card.spec.tsx
|
||
it('the main button is named „Übernehmen“ for PLAN and „Erfassen“ for DO; touch targets are 44 px', ...);
|
||
// nav-config.spec.ts
|
||
it('„Vorschläge“ needs tasks:read and shows the open count', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/lib/suggestions src/components/suggestions "src/app/(app)/suggestions" "src/app/(app)/nav-config.spec.ts"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** – vorher `node_modules/next/dist/docs/` lesen; Bausteine `PageHeader`, `QueryState`, `EmptyState`, `Sheet`, `DueChip`, `ConfirmDialog`, Idempotency-Key je Aktion wie im Bestand.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src/lib/suggestions src/components/suggestions "src/app/(app)"`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 18: Frontend – „Als Nächstes“, Heute, Stand und Aufräumen
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/autopilot/types.ts`, `src/lib/autopilot/api.ts`, `src/components/autopilot/next-steps-card.tsx` (+ Spec), `src/components/autopilot/season-chip.tsx`, `src/components/today/inspection-due-card.tsx` (+ Spec), `src/components/suggestions/cleanup-sheet.tsx` (+ Spec)
|
||
- Modify: `src/app/(app)/dashboard/page.tsx`, `src/components/today/today-panel.tsx`, `src/app/(app)/colonies/[id]/page.tsx` (+ Spec), `src/app/(app)/apiaries/[id]/apiary-colonies-card.tsx` (+ Spec), `src/lib/apiaries/field-types.ts`, `src/lib/colonies/status-light.ts` (+ Spec), `src/components/suggestions/suggestion-card.tsx`, `src/app/(app)/winter/page.tsx` (+ Spec)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 15 (`ColonyNextSteps`, `AutopilotToday`, Übersichtsfelder), Task 9 (`apply-cleanup`, `SuggestionView.cleanup`), Task 17.
|
||
- Produces:
|
||
```ts
|
||
export function fetchColonyNextSteps(colonyId: string): Promise<ColonyNextSteps>; // Key ["colonies", colonyId, "next-steps"]
|
||
export function fetchAutopilotToday(): Promise<AutopilotToday>; // Key ["autopilot", "today"]
|
||
export function applyCleanup(suggestionId: string, input: ApplyCleanupInput, options: CreateOptions): Promise<CleanupResult>;
|
||
export const SEGMENT_LABELS: Record<SeasonSegment, string>; // WINTERRUHE 'Winterruhe', FRUEHJAHR 'Frühjahr', SCHWARMZEIT 'Schwarmzeit', SOMMER 'Sommer', SPAETSOMMER 'Spätsommer', HERBST 'Herbst'
|
||
export function seasonChipText(s: { segment: SeasonSegment; intervalDays: number | null }): string; // 'Schwarmzeit · alle 7 Tage'
|
||
export function colonyStatusLight(row: ColonyOverviewRow, options: { today: string; inspectionsVisible: boolean }): StatusLightResult;
|
||
// gelb ab überschrittenem row.inspectionDueOn („Durchsicht seit 3 Tagen fällig“); INSPECTION_STALE_DAYS entfällt
|
||
```
|
||
- `NextStepsCard` oben auf der Volksseite: höchstens 3 Schritte mit Titel, ≤ 2 Gründen, `DueChip`, Aktion (`CAPTURE` → Link mit `colonyId`, `ACCEPT` → Übernehmen, `CLEANUP` → `CleanupSheet`, `OPEN_TASK` → Link), Hinweis „Wartezeit bis {TT.MM.}“ aus `withdrawalUntil`.
|
||
- Heute: „Nächster Schritt“ aus `GET /autopilot/today` (offline wie bisher aus Aufgaben); Karte „Durchsicht fällig“ je Stand mit Völkerliste und „Runde starten“ (`startRound` nur mit den fälligen Völkern, nach Code); Chips „Vorschläge {n}“ und Saison je Stand.
|
||
- Stand: Zeilen „Durchsicht fällig in 2 Tagen“ bzw. „seit 3 Tagen“ und der Titel des nächsten Schritts.
|
||
- `CleanupSheet`: Aufgaben mit Vorauswahl „Abbrechen“, umschaltbar „Erledigt“/„Behalten“ (`ChoiceGroup`); Königin bei `DECEASED` „verstorben“, bei `SOLD` „verkauft“, sonst „unverändert“; ein Hauptknopf „Aufräumen“; ohne `tasks:complete` ist „Erledigt“ deaktiviert mit Hinweis.
|
||
- Überwinterung: `/winter?colonyId=&season=` öffnet den Erfassungsdialog für dieses Volk.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('NextStepsCard shows at most 3 steps with reasons and links CAPTURE to the form with colonyId', ...);
|
||
it('NextStepsCard shows the withdrawal hint, not a step', ...);
|
||
it('the dashboard shows the next step from /autopilot/today and falls back to tasks offline', ...);
|
||
it('„Durchsicht fällig“ starts a round only with the due colonies of that apiary', ...);
|
||
it('status light turns yellow once inspectionDueOn has passed', ...);
|
||
it('apiary rows say „Durchsicht fällig in 2 Tagen“ and „seit 3 Tagen“', ...);
|
||
it('CleanupSheet preselects „Abbrechen“ and the queen status by colony status and sends apply-cleanup', ...);
|
||
it('/winter?colonyId=c1&season=SPRING opens the dialog for that colony', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/components/autopilot src/components/today src/components/suggestions src/lib/colonies "src/app/(app)/colonies" "src/app/(app)/apiaries" "src/app/(app)/winter" "src/app/(app)/dashboard"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 19: Frontend – Varroa-Ampel mit Bändern und Probe
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/varroa/bands.ts` (+ Spec)
|
||
- Modify: `src/lib/varroa/rate.ts` (+ Spec; altes `varroaLight` und `VARROA_WARN_FRACTION` entfallen), `src/lib/varroa/api.ts` (`fetchVarroaThresholds`), `src/lib/varroa/use-varroa-threshold.ts` → `use-varroa-bands.ts`, `src/lib/varroa/types.ts` (`sampleBees`, `rating`), `src/components/varroa/varroa-rate-light.tsx`, `src/app/(app)/varroa/new/page.tsx` + `varroa-form-values.ts` (+ Spec), `src/app/(app)/colonies/[id]/colony-varroa.tsx` (+ Spec), `src/app/(app)/colonies/[id]/varroa-edit-dialog.tsx` (+ Spec)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 6.
|
||
- Produces:
|
||
```ts
|
||
export type VarroaLight = 'green' | 'yellow' | 'red';
|
||
export interface VarroaBand { method: VarroaMethod; unit: 'MITES_PER_DAY' | 'PERCENT'; yellowFrom: number | null; redAbove: number; reason: string; youngColony: boolean }
|
||
export interface VarroaBands { day: string; bands: Partial<Record<VarroaMethod, VarroaBand>>; postTreatmentUntil: string | null; active: boolean }
|
||
export function fetchVarroaThresholds(colonyId: string | null, date: string): Promise<VarroaBands>; // Key ["varroa", "thresholds", colonyId, date], staleTime 1 h
|
||
export function varroaLight(value: number | null, band: VarroaBand | null | undefined): VarroaLight | null; // > redAbove rot, >= yellowFrom gelb
|
||
export function infestationPercent(miteCount: number | null, sampleBees: number | null): number | null;
|
||
export const SAMPLE_BEES_CHOICE = 500; // Chip „500 Bienen (≈ 50 g)“, nichts vorgewählt
|
||
```
|
||
- Texte (live, `role="status"`): Icon + Wort „Gut“, „Achtung“, „Kritisch“ bzw. „Behandlungseffekt – Nachkontrolle ab {TT.MM.}“; z. B. „12,4 Milben/Tag – Kritisch (Grenze 10, LWG)“; ohne Band nur der Wert.
|
||
- Feld „Probe (Bienen)“ nur bei Puderzucker/Auswaschung; Trend: Bänder als Stufenlinie je Monat (vorher `dataviz`-Skill laden).
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
it('10,0/Tag in August is yellow, 10,1 red', ...);
|
||
it('powdered sugar in April has no light', ...);
|
||
it('shows „Behandlungseffekt – Nachkontrolle ab 16.08.“ inside the post-treatment window', ...);
|
||
it('the sample field appears only for powdered sugar and washing, the chip sets 500 without preselection', ...);
|
||
it('a stored sampleBees is sent back unchanged from the edit dialog', ...);
|
||
it('the chart draws the band as a monthly step line with an accessible summary', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/lib/varroa src/components/varroa "src/app/(app)/varroa" "src/app/(app)/colonies"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src/lib/varroa src/components/varroa "src/app/(app)"`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 20: Frontend – Volkskarte in der Durchsicht, Futter-Vokabular, besetzte Waben
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/colonies/condition.ts` (+ Spec), `src/components/colonies/colony-takeover-section.tsx` (+ Spec)
|
||
- Modify: `src/lib/inspections/types.ts` (`feedStatus: ColonyCondition | null`, `applyToColony`, `colonyTakeover`), `src/lib/colonies/types.ts` (`occupiedFrames`), `src/app/(app)/inspections/new/inspection-form-values.ts` (+ Spec), `src/app/(app)/inspections/new/page.tsx` (+ Spec), `src/lib/inspections/takeover.ts` (+ Spec), `src/app/(app)/colonies/[id]/inspection-edit-dialog.tsx` (+ Spec), `src/app/(app)/colonies/[id]/colony-edit-card.tsx` (+ Spec), `src/app/(app)/colonies/[id]/colony-actions.tsx` (+ Spec; Ableger-Dialog mit Datum und Herkunft der Königin), `src/lib/colonies/api.ts` (`splitColony`), `src/app/(app)/colonies/[id]/page.tsx`, `src/app/(app)/colonies/new/page.tsx` + `colony-form-values.ts` (+ Spec)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 2, 8, 10 (`SplitColonyDto.date/queenSource`).
|
||
- Produces:
|
||
```ts
|
||
export const FEED_CONDITION_LABELS: Record<ColonyCondition, string>; // wie Backend
|
||
export const INSPECTION_FEED_OPTIONS: readonly ['GOOD', 'WEAK', 'CRITICAL']; // plus „Nicht geprüft“
|
||
export function ratingToCondition(rating: number | null): ColonyCondition | null; // Brutbild 5..1
|
||
export type SplitQueenSource = 'RAISE_OWN' | 'QUEEN_CELL' | 'QUEEN'; // lib/colonies/types.ts: SplitColonyInput += date?: string; queenSource?: SplitQueenSource
|
||
export const SPLIT_QUEEN_SOURCE_LABELS: Record<SplitQueenSource, string>; // 'Eigene Zellen ziehen', 'Weiselzelle', 'Königin'
|
||
export interface ColonyTakeoverSectionProps { colony: Pick<Colony, 'occupiedFrames' | 'broodStatus' | 'feedStatus'>;
|
||
values: Pick<InspectionFormValues, 'colonyStrength' | 'broodPattern' | 'feedStatus'>; selected: ColonyTakeoverField[]; onChange: (fields: ColonyTakeoverField[]) => void; canUpdateColony: boolean }
|
||
// InspectionFormValues += applyToColony: ColonyTakeoverField[] (Standard: genau die in dieser Durchsicht ausgefüllten Felder)
|
||
```
|
||
- Abschnitt „Volkskarte“ direkt über „Speichern“: je Feld „Futter: Ausreichend → Niedrig“ mit Haken; ohne `colonies:update` fehlt er. Nach dem Speichern bei `NEWER_INSPECTION` Toast „Volkskarte nicht übernommen: Es gibt eine neuere Durchsicht.“
|
||
- Ableger-Dialog (Spec 11.2, in `colony-actions.tsx`): zusätzlich zu Code und Typ ein Datumsfeld „Datum des Ablegers“ (Standard heute, nicht in der Zukunft; die Servermeldung „Das Datum liegt in der Zukunft.“ erscheint am Feld) und eine `ChoiceGroup` „Woher kommt die Königin?“ mit „Eigene Zellen ziehen“ (`RAISE_OWN`), „Weiselzelle“ (`QUEEN_CELL`), „Königin“ (`QUEEN`) und „Weiß ich noch nicht“ (sendet kein `queenSource`, keine Kette); nichts ist vorgewählt (Herkunft ist eine Beobachtung). Ein Satz unter der Wahl nennt die Folge, z. B. „Es entsteht ein Plan: Zellen prüfen, Begattung, Stifte.“
|
||
- Bearbeiten-Dialog zeigt einen gespeicherten Futterwert außerhalb der drei trotzdem. `takeover.ts` verliert den Freitext-Sonderfall.
|
||
- Volk bearbeiten: „Besetzte Waben“ (`NumberStepper` 0–40); „Volksstärke (alt, Skala 1–10)“ nur, wenn gespeichert; Leeren sendet `null`. Neues Volk nur `occupiedFrames`. Volksseite „{n} besetzte Waben“, sonst „Volksstärke (alt): 7 von 10“.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('checks only fields filled in this inspection and shows „bisher → neu“', ...);
|
||
it('hides the section without colonies:update', ...);
|
||
it('sends feedStatus as WEAK and applyToColony with the checked fields', ...);
|
||
it('the edit dialog shows a stored AVERAGE feed value', ...);
|
||
it('„Besetzte Waben“ accepts 0–40 and clearing sends null', ...);
|
||
it('shows the legacy strength field only when a value is stored', ...);
|
||
// colony-actions.spec.tsx
|
||
it('the split dialog sends date and queenSource with nothing preselected', ...);
|
||
it('„Weiß ich noch nicht“ sends no queenSource', ...);
|
||
it('a future date shows the server message at the date field', ...);
|
||
it('the colony page shows „Volksstärke (alt): 7 von 10“ without occupiedFrames', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/lib/colonies src/lib/inspections src/components/colonies "src/app/(app)/inspections" "src/app/(app)/colonies"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src/lib src/components "src/app/(app)"`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 21: Frontend – Warnungen, Wartezeit im Kalender, Meldungen, Benachrichtigungs-Einstellungen
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/warnings/types.ts`, `src/lib/warnings/toast-warnings.ts`, `src/components/warnings/warning-list.tsx` (+ Spec), `src/lib/notification-settings/types.ts`, `api.ts`, `validation.ts` (+ Spec), `src/app/(app)/profile/notification-settings-card.tsx` (+ Spec)
|
||
- Modify: `src/lib/notifications/types.ts` + `api.ts` (`category`, `link`, `markAllNotificationsRead`), `src/components/notification-bell.tsx` (+ neue Spec), `src/lib/calendar/types.ts`, `src/app/(app)/calendar/page.tsx` (+ neue Spec), `src/app/(app)/bottling/new/page.tsx`, `src/app/(app)/harvests/new/page.tsx`, `src/app/(app)/profile/page.tsx`
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 3, 12.
|
||
- Produces:
|
||
```ts
|
||
export interface PlausibilityWarning { code: string; severity: 'HINWEIS' | 'WARNUNG' | 'BESTAETIGEN'; message: string; refs: { type: string; id: string }[] }
|
||
export interface WarningListProps { warnings: readonly PlausibilityWarning[]; title?: string } // role="status" aria-live="polite", Icon + Wort je Stufe
|
||
export function toastWarnings(warnings: readonly PlausibilityWarning[]): void; // nach dem Speichern: „Gespeichert · 1 Warnung“ + Texte
|
||
export function markAllNotificationsRead(): Promise<{ updated: number }>;
|
||
export function fetchNotificationSettings(): Promise<NotificationSettings>;
|
||
export function updateNotificationSettings(patch: Partial<NotificationSettings>): Promise<NotificationSettings>; // nur Geändertes
|
||
export function validateDigestTime(value: string): string | null; // 'Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.'
|
||
// CalendarEvent += endDate?: string (letzter Tag einschließlich), type 'WITHDRAWAL'
|
||
```
|
||
- Glocke: „Alle gelesen“; Meldung mit `link` öffnet das Ziel (Next `Link`), sonst wie bisher.
|
||
- Einstellungen-Karte (Profil > Benachrichtigungen): Schalter „Erinnerung je Aufgabe“, „Morgenübersicht“ mit Uhrzeit (Standard 06:30), „Ruhezeit“ von–bis (Standard 21:00–06:30, nur gemeinsam leerbar), Hauptknopf „Speichern“. Den Telegram-Bereich ergänzt Task 38.
|
||
- Kalender: Wartezeit als Spanne „Wartezeit B3 · Oxuvar“ über alle Tage bis `endDate`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('WarningList announces warnings with icon and word in a status region', ...);
|
||
it('after a bottling with WITHDRAWAL_ACTIVE the toast names the colony and the end day', ...);
|
||
it('„Alle gelesen“ calls read-all and a notification with link navigates there', ...);
|
||
it('rejects 04:30 with the German message and clears quiet hours only as a pair', ...);
|
||
it('saving sends only changed fields', ...);
|
||
it('the calendar renders a withdrawal span across its days', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/lib/warnings src/components/warnings src/lib/notification-settings src/components/notification-bell.spec.tsx "src/app/(app)/profile" "src/app/(app)/calendar"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 22: Frontend – Verwaltung > Automatisierung für die neuen Regeln
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/automation/rule-configs.ts` (+ Spec), `src/app/(app)/settings/automation/rule-forms/varroa-threshold-form.tsx`, `inspection-interval-form.tsx`, `chain-form.tsx`, `split-schedule-form.tsx`, `water-content-form.tsx`, `season-autopilot-form.tsx`, `phenology-form.tsx`, `notify-form.tsx` (je + Spec, wo Logik)
|
||
- Modify: `src/lib/automation/types.ts` (neue Trigger und Labels), `src/app/(app)/settings/automation/automation-rules-tab.tsx`
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (Config-Formate, 400 mit `issues`).
|
||
- Produces:
|
||
```ts
|
||
export const RULE_LABELS: Record<AutomationTriggerType, string>; // z. B. SEASON_AUTOPILOT 'Saison-Autopilot', PHENOLOGY 'Pflanzenentwicklung'
|
||
export interface ValueSource { label: string; kind: 'SOURCE' | 'ASSUMPTION' } // 'LWG Veitshöchheim', 'DWD', 'D.I.B.', 'HonigV Anlage 2', 'Annahme'
|
||
export const RULE_VALUE_SOURCES: Partial<Record<AutomationTriggerType, Record<string, ValueSource>>>; // Pfad -> Quelle
|
||
export function issuesByField(error: unknown): Record<string, string>; // ApiError.body.issues -> Feldfehler
|
||
```
|
||
- Je neue Regel ein Formular mit allen Werten aus Global Constraints; Quelle je Wert sichtbar (Text, kein Tooltip allein); A1–A10 einzeln schaltbar; Kalender-Rückfälle als `MM-DD`-Felder; `notify` als Schalter „Zusätzlich melden“; Hauptknopf „Speichern“.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('the varroa form saves bands and shows „LWG Veitshöchheim“ next to the values', ...);
|
||
it('a 400 issue appears under its field', ...);
|
||
it('autopilot rules A1–A10 switch individually', ...);
|
||
it('every trigger has a label', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/lib/automation "src/app/(app)/settings/automation"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src/lib/automation "src/app/(app)/settings"`; kein Commit.
|
||
|
||
---
|
||
|
||
### Abschluss T4a (Orchestrator)
|
||
|
||
- [ ] Backend: `npm run lint && npm run typecheck && npm test && npm run build`; Test-DB migrieren, dann `npm run test:e2e` komplett.
|
||
- [ ] Frontend: `npm run lint && npm run typecheck && npm test && npm run build`.
|
||
- [ ] Drift-Check `npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code` → 0.
|
||
- [ ] Wächter grün: `handler-contract.spec.ts`, Handler-IDs Worker = App, `no-broadcast-notifications.spec.ts`, `colony-end-releases-queen.spec.ts`, `queen-age-threshold.spec.ts`, `after-commit-usage.spec.ts`, Backup-Modell-Wächter, `update-dto-null-handling.spec.ts`, Text-Wächter, Intent-Labels.
|
||
- [ ] Docs aus den Berichten in `ARCHITECTURE.md` (Annahmen mit nächster freier Nummer; Annahme 12, T3-„Ampeln“, 10, 13/16 geändert; §8, §10) und `TASKS.md` (Abschnitt T4a) eintragen und auf Widersprüche lesen.
|
||
- [ ] Vor dem Deploy: App-Backup und `pg_dump`; Prüfabfrage `colonyStrength > 40` (Migration 10); nach dem Deploy die Prüfabfragen aus Spec 4.12 (Migrationen 3, 8). Jeder Cluster-Schritt nur mit Freigabe des Inhabers.
|
||
- [ ] Ein zentraler Commit, Nachricht endet mit `Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>`.
|
||
|
||
---
|
||
|
||
## Lieferung T4b – Pflanzenentwicklung, Saisonregeln, Honig-Kette, Qualität
|
||
|
||
### Task 23: Schema T4b – Pflanzenentwicklung, Bewegungsbuch, Haltbarkeit, Ereigniskatalog
|
||
|
||
**Files**
|
||
- Modify: `prisma/schema.prisma`
|
||
- Create: `prisma/migrations/20261015100000_t4_phenology/`, `…100100_t4_honey_ledger/`, `…100200_t4_product_shelf_life/` (je `migration.sql`)
|
||
- Modify: `src/auth/permissions/permission-catalog.ts` (Gruppe „Pflanzenentwicklung“), `src/domain-events/domain-event-types.ts` (T4b-Ereignisse), `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec; `Apiary`, `PhenologyReport`, `Extraction`, `HoneyBatch` am Ende der Map, Schlüssel `"id"`)
|
||
- Create: `src/season/season-enums.spec.ts` (Wächter), `test/honey-ledger-migration.e2e-spec.ts`
|
||
- Docs: neue Annahme „Bewegungsbuch der Honigcharge“ (Gramm, Invariante, Rückrechnung: frühere Handkorrekturen stecken in `initialAmount`); §8: `PhenologyReport` gesichert, `ApiaryPhenology` und `ApiaryWeatherDay` ausgenommen (Begründungen aus Spec 4.13).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 2 (`createScratchDatabase`), Task 1 (`season-types.ts`).
|
||
- Produces:
|
||
```prisma
|
||
// Migration 4: enums PhenologyPhase, SeasonSegment, PhaseSource; models PhenologyReport, ApiaryPhenology, ApiaryWeatherDay exakt wie Spec 4.2;
|
||
// INSERT Permission phenology:read/create/update (Gruppe 'Pflanzenentwicklung', sortOrder 860–862) und RolePermission für Nicht-System-Rollen
|
||
// (read bei colonies:read, create/update bei inspections:create) – Muster 20260928091348_winter_checks
|
||
// Migration 6: HoneyBatch.initialAmount Float?; BatchMovement.honeyBatchId String? (FK Restrict, Index), sourceBottlingBatchId String? (FK SetNull);
|
||
// CHECK neu num_nonnulls("bottlingBatchId","productionBatchId","rawMaterialLotId","honeyBatchId") = 1; Rückrechnung Spec 4.12
|
||
// Migration 7b: Product.shelfLifeMonths Int?
|
||
```
|
||
```ts
|
||
// DomainEventPayloads += (Spec 5.1)
|
||
ExtractionRecorded: { harvestIds: string[] }; ExtractionCorrected: { harvestIds: string[] };
|
||
HoneyBatchRecorded: { extractionId: string }; HoneyBatchCorrected: { extractionId: string };
|
||
PhenologyReported: { apiaryId: string | null; phase: PhenologyPhase; reportedOn: string };
|
||
PhenologyCorrected: { apiaryId: string | null; phase: PhenologyPhase; reportedOn: string };
|
||
SeasonPhaseChanged: { apiaryId: string; phase: PhenologyPhase | null; segment: SeasonSegment; previousPhase: PhenologyPhase | null; previousSegment: SeasonSegment | null; source: PhaseSource | null };
|
||
// AggregateType += 'Extraction' | 'HoneyBatch' | 'PhenologyReport' | 'Apiary'
|
||
```
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// season-enums.spec.ts
|
||
it('Prisma phenology enums equal the TS constants of T4a', () => {
|
||
expect(Object.values(PhenologyPhase)).toEqual([...PHENOLOGY_PHASES]);
|
||
expect(Object.values(SeasonSegment)).toEqual([...SEASON_SEGMENTS]);
|
||
expect(Object.values(PhaseSource)).toEqual([...PHASE_SOURCES]);
|
||
});
|
||
// test/honey-ledger-migration.e2e-spec.ts (Harness)
|
||
it('legacy bottlings keep amount = initialAmount + movements', async () => {
|
||
// Charge amount 20, Abfüllungen 7,5 und 2,5 kg; danach migrateThrough('20261015100100_t4_honey_ledger')
|
||
// initialAmount 30; zwei BOTTLING-Buchungen -7500 und -2500 g mit sourceBottlingBatchId und createdAt der Abfüllung
|
||
expect(await db.query(INVARIANT_SQL)).toEqual([]);
|
||
});
|
||
it('a batch without amount gets no initialAmount and no booking', ...);
|
||
// backup.service.spec.ts
|
||
it('backs up PhenologyReport and excludes ApiaryPhenology and ApiaryWeatherDay with reasons', ...);
|
||
it('an old bundle without initialAmount is recalculated like migration 6', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/season src/backup` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren**
|
||
- Rückrechnung in Migration 6 je Charge mit `amount IS NOT NULL`: `initialAmount = amount + Σ totalAmount` ihrer Abfüllungen; je Abfüllung `BOTTLING` mit `-round(totalAmount * 1000)`, `sourceBottlingBatchId`, `createdAt` der Abfüllung, Notiz „Übernahme aus Abfüllung (T4)“. Den Namen des alten CHECK aus `20261005100000_raw_materials_production` übernehmen.
|
||
- `INVARIANT_SQL` als exportierte Konstante in `test/utils/honey-ledger.ts`: Chargen mit `abs(amount − (initialAmount + coalesce(Σ quantity, 0) / 1000)) > 0.0005`.
|
||
- Katalog-Eintrag `phenology` in `permission-catalog.ts`; Backup-Import: Chargen ohne `initialAmount` nach derselben Regel.
|
||
- [ ] **Schritt 4:** → PASS; Drift-Check → 0.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/honey-ledger-migration.e2e-spec.ts`, `test/backup.e2e-spec.ts`, `test/roles.e2e-spec.ts`, `test/authorization-coverage.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 24: Wetterquelle und Worker-Schleife `phenology-weather`
|
||
|
||
**Files**
|
||
- Create: `src/weather/history/weather-history-source.ts`, `src/weather/history/weather-history.config.ts` (+ Spec), `src/weather/history/open-meteo-history.source.ts` (+ Spec), `src/weather/history/weather-history.service.ts` (+ Spec), `src/weather/history/weather-history.module.ts`, `test/weather-history.e2e-spec.ts`
|
||
- Modify: `src/worker/worker-runtime.service.ts` (+ Spec; Schleifen nach Name, Weckkanal optional), `src/worker/worker-runtime.module.ts`, `src/weather/weather.service.ts` (+ Spec; gleiche URL- und Schlüssel-Variablen), `src/jobs/cleanup.job.ts` (+ Spec), `.env.example`, `deploy/k8s/overlays/encry/configmap-backend.yaml`
|
||
- Docs: neue Annahme „Wetterquelle“ (Inhaber-Entscheidung freie Open-Meteo-API mit bewusst übernommenem Lizenzrisiko, Adapter, Wechsel per Einstellung bzw. neuem Adapter); ARCHITECTURE §10: Schleife `phenology-weather`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 23 (`ApiaryWeatherDay`), Task 1 (`DailyMean` aus `src/season/season-types.ts`).
|
||
- Produces:
|
||
```ts
|
||
export interface GeoPoint { lat: number; lon: number } // auf 0,01° gerundet
|
||
import type { DailyMean } from '../../season/season-types'; // Tagesmittel, definiert in Task 1 (nicht hier: Task 25 liegt in derselben Welle)
|
||
export interface WeatherHistorySource { readonly name: string; dailyMeans(point: GeoPoint, fromDay: string, toDay: string, signal?: AbortSignal): Promise<DailyMean[]> }
|
||
export const WEATHER_HISTORY_SOURCE = Symbol('WEATHER_HISTORY_SOURCE');
|
||
export interface WeatherHistoryConfig { source: 'open-meteo'; forecastUrl: URL; archiveUrl: URL; apiKey: string | null }
|
||
export function weatherHistoryConfig(env?: NodeJS.ProcessEnv): WeatherHistoryConfig; // nur https:, unbekannte Quelle -> Fehler beim Start
|
||
export function roundPoint(lat: number, lon: number): GeoPoint;
|
||
export const WEATHER_HISTORY_POLL_MS = 3_600_000; export const WEATHER_FETCH_TIMEOUT_MS = 8_000;
|
||
export const WEATHER_MAX_RESPONSE_BYTES = 1_000_000; export const FORECAST_PAST_DAYS = 92; export const RECENT_WINDOW_DAYS = 90;
|
||
export const WEATHER_RETENTION_YEARS = 4;
|
||
WeatherHistoryService.syncOnce(today: string, signal: AbortSignal): Promise<{ points: number; daysWritten: number; failedPoints: number }>;
|
||
// WorkerRuntime: interface WorkerLoop { name: string; intervalMs: number; channel?: WakeChannel; tick: () => Promise<unknown> }; Map nach name
|
||
```
|
||
- Je Takt: aktive Stände mit Koordinaten; fehlende Tage vom 1. Januar bis gestern (Berlin) = keine Zeile, oder `meanTempC` null in den letzten 90 Tagen; letzte 90 Tage über Forecast (`past_days=92`, `daily=temperature_2m_mean`, `timezone=Europe/Berlin`), ältere Lücken über Archiv (`start_date`, `end_date`); `apikey` nur, wenn gesetzt; Upsert je Stand und Tag mit `source` `'forecast'`/`'archive'`. Fehler je Punkt über `loggableError` ohne URL, nächster Takt versucht es neu; kein Ereignis, keine Meldung.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
it('selects open-meteo by WEATHER_HISTORY_SOURCE and refuses http: URLs', ...);
|
||
it('never puts the API key or the URL into an error message', ...);
|
||
it('maps values outside -50..50 °C to null', ...);
|
||
it('stops reading after 1 000 000 bytes and aborts after 8 s', ...);
|
||
it('apiaries on the same rounded point share one request', ...);
|
||
it('runs the loop without a wake channel and stops on shutdown', ...); // worker-runtime.service.spec
|
||
it('cleanup deletes weather days before January 1st four years back', ...);
|
||
// test/weather-history.e2e-spec.ts (fetch gemockt)
|
||
it('fetches missing days once, with rounded coordinates, and writes nothing on an error', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/weather src/worker src/jobs` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben; Antwort über `response.body` mit Byte-Zähler lesen; `AbortSignal.any([signal, AbortSignal.timeout(8000)])`.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/weather-history.e2e-spec.ts`, `test/weather.e2e-spec.ts`, `test/worker-runtime.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 25: Pflanzenentwicklung – GTS, Phase, Saisonzustand, Job-Schritt
|
||
|
||
**Files**
|
||
- Create: `src/phenology/gts.ts` (+ Spec), `src/phenology/phenology-phase.ts` (+ Spec), `src/phenology/phenology-calibration.ts` (+ Spec), `src/phenology/phenology-state.service.ts` (+ Spec), `src/phenology/phenology.step.ts`, `src/phenology/phenology.module.ts`
|
||
- Modify: `src/season/season.service.ts` (+ Spec; liest `ApiaryPhenology`), `src/app.module.ts`, `src/worker/worker.module.ts`, `src/jobs/jobs.module.ts`, `src/jobs/daily-evaluation.job.ts`
|
||
- Test: `test/daily-evaluation.e2e-spec.ts`
|
||
- Docs: Annahme „Saison aus Pflanzenentwicklung“ erweitern (GTS nach DWD, 200 °C = Salweide als Annahme, Kalibrierung als Median der Vorjahre, keine erfundenen Schwellen, Rückfall-Daten für Schwaben prüfen); ARCHITECTURE §10: Job-Schritt `phenology`, Ereignis `SeasonPhaseChanged`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 13 (`segmentOf`, `calendarPhase`, `SeasonService`), Task 1 (`DailyMean` aus `src/season/season-types.ts`; nicht aus Task 24, gleiche Welle), Task 23, Task 24 (nur zur Laufzeit: `ApiaryWeatherDay` gefüllt).
|
||
- Produces:
|
||
```ts
|
||
export const GTS_MONTH_FACTORS: Readonly<Record<number, number>>; // 1: 0.5, 2: 0.75, sonst 1
|
||
export interface GtsResult { gts: number; throughDay: string | null; expectedDays: number; missingDays: number; usable: boolean }
|
||
export function grasslandTemperatureSum(days: readonly DailyMean[], year: number, throughDay: string, maxGapShare: number): GtsResult;
|
||
export interface PhaseInputs { today: string; reports: { phase: PhenologyPhaseKey; date: string; revoked: boolean }[]; gts: GtsResult | null;
|
||
hasCoordinates: boolean; thresholds: Partial<Record<PhenologyPhaseKey, number>>; config: PhenologyConfig; previous: { phase: PhenologyPhaseKey | null } | null; allowBackward: boolean }
|
||
export interface EffectivePhase { reportedPhase: PhenologyPhaseKey | null; reportedOn: string | null; estimatedPhase: PhenologyPhaseKey | null;
|
||
effectivePhase: PhenologyPhaseKey | null; phaseSource: PhaseSourceKey | null; note: SeasonNote }
|
||
export function effectivePhaseOf(inputs: PhaseInputs): EffectivePhase;
|
||
export function calibrateThresholds(years: { firstReportDay: Partial<Record<PhenologyPhaseKey, string>>; gtsOn: (day: string) => number | null }[], phases: readonly PhenologyPhaseKey[]): Partial<Record<PhenologyPhaseKey, number>>; // Median, höchstens 3 Vorjahre
|
||
PhenologyStateService.recomputeIn(tx: Prisma.TransactionClient, apiaryId: string, today: string, options?: { allowBackward?: boolean }): Promise<{ changed: boolean; state: SeasonState }>;
|
||
PhenologyStateService.recomputeAll(today: string): Promise<{ apiaries: number; changes: number }>; // je Stand eine Transaktion
|
||
PhenologyStep.run(today: string): Promise<JobResult>; // { phenologyApiaries, seasonChanges }
|
||
```
|
||
- `recomputeIn`: zuerst `lockRowForUpdate(tx, 'Apiary', apiaryId)`, dann den bisherigen `ApiaryPhenology`-Zustand lesen (mehrere Stände: `id` aufsteigend); Meldungen des Jahres für den Stand und für alle Stände (`apiaryId null`), ohne zurückgezogene; GTS aus `ApiaryWeatherDay` bis gestern; GTS älter als 3 Tage → nicht benutzt (`WEATHER_STALE`); ohne Koordinaten `NO_COORDINATES`; Schwellen = `gtsThresholds` (`useGts`) plus Kalibrierung für `calibratePhases` (`calibrateFromReports`); Kalender nur für Phasen ohne GTS-Schwelle oder ohne Wetterdaten. Maximum der drei, nie rückwärts außer `allowBackward`. Upsert `ApiaryPhenology (apiaryId, year)`; bei geänderter Phase oder Abschnitt `SeasonPhaseChanged` mit `dailyDedupeKey('SeasonPhaseChanged', apiaryId, today)`; `segmentSince` = heute bei Wechsel.
|
||
- `SeasonService.statesFor`: Zeile des laufenden Jahres, wenn vorhanden, sonst Kalender (T4a-Verhalten).
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// gts.spec.ts
|
||
it('weights January 0.5, February 0.75 and March 1', ...);
|
||
it('negative daily means count 0', ...);
|
||
it('is unusable with more than 10 % missing days', ...);
|
||
it('reaches 200 °C on the expected day', ...);
|
||
// phenology-phase.spec.ts
|
||
it('takes the maximum of report, GTS and calendar', ...);
|
||
it('ignores revoked reports', ...);
|
||
it('a report for all apiaries counts for each apiary', ...);
|
||
it('without a previous-year report there is no KIRSCHE threshold, the calendar applies', ...);
|
||
it('never moves backwards unless a report was revoked', ...);
|
||
// phenology-calibration.spec.ts
|
||
it('uses the median GTS on the first report day of up to three previous years', ...);
|
||
// phenology-state.service.spec.ts
|
||
it('records SeasonPhaseChanged once per apiary and day on a change', ...);
|
||
// test/daily-evaluation.e2e-spec.ts
|
||
it('the phenology step writes ApiaryPhenology for every active apiary', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/phenology src/season` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben; `PhenologyModule` in `AppModule`, `WorkerModule`, `JobsModule`.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/daily-evaluation.e2e-spec.ts`, `test/autopilot.e2e-spec.ts`, `test/worker-runtime.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 26: Pflanzen-Meldungen – API, Neuberechnung, Saison-Meldung
|
||
|
||
**Files**
|
||
- Create: `src/phenology/phenology.service.ts` (+ Spec), `src/phenology/phenology.controller.ts`, `src/phenology/dto/create-phenology-report.dto.ts`, `dto/update-phenology-report.dto.ts`, `dto/list-phenology-reports.query.ts`, `src/phenology/phenology.handlers.ts` (+ Spec)
|
||
- Modify: `src/phenology/phenology.module.ts`, `src/common/validation/update-dto-null-handling.spec.ts`
|
||
- Test: neu `test/phenology.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE §10: Handler `phenology.recompute`, `notifications.season-segment`; Rechte-Gruppe „Pflanzenentwicklung“.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 3, 23, 25.
|
||
- Produces:
|
||
```ts
|
||
PhenologyService.create(dto: CreatePhenologyReportDto, userId: string): Promise<PhenologyReport>; // Audit + PhenologyReported in einer Transaktion
|
||
PhenologyService.update(id: string, dto: UpdatePhenologyReportDto, userId: string): Promise<PhenologyReport>; // PhenologyCorrected
|
||
PhenologyService.revoke(id: string, userId: string): Promise<PhenologyReport>; // revokedAt; schon zurückgezogen -> 409
|
||
PhenologyService.list(year: number): Promise<PhenologyReportView[]>; // alle Meldungen des Jahres, mit revokedAt
|
||
PhenologyService.state(): Promise<{ apiaryId: string; apiaryName: string; phase: PhenologyPhase | null; segment: SeasonSegment; phaseSource: PhaseSource | null;
|
||
reportedOn: string | null; gts: number | null; gtsThroughDay: string | null; note: SeasonNote }[]>; // nie Koordinaten
|
||
class PhenologyEventHandlers {
|
||
@OnDomainEvent(['PhenologyReported', 'PhenologyCorrected'], 'phenology.recompute') // apiaryId null -> alle aktiven Stände; Corrected -> allowBackward
|
||
@OnDomainEvent('SeasonPhaseChanged', 'notifications.season-segment') // nur bei Wechsel des Abschnitts
|
||
}
|
||
// Routen (Spec 10.1): GET /phenology/state (apiaries:read); GET /phenology/reports?year= (phenology:read);
|
||
// POST /phenology/reports (phenology:create, @Idempotent); PATCH /phenology/reports/:id, POST /phenology/reports/:id/revoke (phenology:update, revoke @Idempotent)
|
||
// CreatePhenologyReportDto { phase: PhenologyPhase; date: string (nicht nach berlinToday -> 400 'Das Datum liegt in der Zukunft.'); apiaryId?: string | null; notes?: string (≤ 500) }
|
||
// UpdatePhenologyReportDto: apiaryId null = alle Stände, notes null leert; phase/date @IsOptionalNotNull
|
||
```
|
||
- `update` und `revoke`: `lockRowForUpdate(tx, 'PhenologyReport', id)`, `before` danach lesen (404, bei `revoke` schon zurückgezogen → 409), schreiben, Audit mit `beforeData`, Ereignis.
|
||
- Saison-Meldung: `INFO`, `SEASON`, an `apiaries:read`, „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“ (ohne Satz 2, wenn der Abschnitt kein Intervall hat), Link `/apiaries/<id>`, Präfix `season-segment:<apiaryId>:<segment>:<Tag>`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// test/phenology.e2e-spec.ts
|
||
it('a report for all apiaries sets SALWEIDE on every apiary and records SeasonPhaseChanged once per apiary', ...);
|
||
it('revoking recomputes and the phase can go back', ...);
|
||
it('a retry with the same key yields one report', ...);
|
||
it('a date in the future answers 400 "Das Datum liegt in der Zukunft."', ...);
|
||
it('403 without phenology:create; /phenology/state never contains coordinates', ...);
|
||
it('a segment change notifies apiaries:read holders once', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/phenology src/common/validation` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS, inklusive `test/authorization-coverage.e2e-spec.ts`.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 27: Saison-Autopilot – Regeln A4–A10 und Abgleich je Stand
|
||
|
||
**Files**
|
||
- Modify: `src/autopilot/autopilot-rules.ts` (+ Spec), `src/autopilot/autopilot-input.ts` (+ Spec), `src/autopilot/autopilot.handlers.ts` (+ Spec), `src/autopilot/autopilot.service.ts` (`syncApiaryIn`), `src/suggestions/texts/autopilot-texts.ts`
|
||
- Test: `test/autopilot.e2e-spec.ts`
|
||
- Create: `src/suggestions/suggesting-triggers.spec.ts` (Wächter, Spec 14.3)
|
||
- Docs: ARCHITECTURE §10: Handler `autopilot.apiary-sync`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 14, 25 (`SeasonPhaseChanged`, `ApiaryPhenology`).
|
||
- Produces:
|
||
```ts
|
||
AutopilotService.syncApiaryIn(tx: Prisma.TransactionClient, apiaryId: string, today: string): Promise<AutopilotSyncResult>; // alle lebenden Völker des Stands
|
||
@OnDomainEvent('SeasonPhaseChanged', 'autopilot.apiary-sync')
|
||
// AUTOPILOT_RULES += A4..A10 (Spec 8.2)
|
||
```
|
||
|
||
| Regel | intent / Art | Bedingung | Anker | reasonCode | Recht |
|
||
|---|---|---|---|---|---|
|
||
| A4 | `SUMMER_TREATMENT` / PLAN, `HIGH` | `SPAETSOMMER`, keine Behandlung seit `segmentSince` | Jahr | `SUMMER_TREATMENT_DUE` („Tracht vorbei (gemeldet am 25.07.). Noch keine Behandlung seit 25.07.“) | `treatments:read` |
|
||
| A5 | `FEED` / DO | `SPAETSOMMER` oder `HERBST`, keine Fütterung seit Beginn `SPAETSOMMER` | Jahr | `AUTUMN_FEED_MISSING` („Einfüttern“) | `feedings:read` |
|
||
| A6 | `FEED` / DO | ≠ `WINTERRUHE`, Futter der letzten Durchsicht `WEAK`/`CRITICAL` oder Volkskarte `CRITICAL`, keine Fütterung seither | Durchsicht-ID | `FEED_LOW` | `inspections:read` |
|
||
| A7 | `HONEY_SUPER` / DO | Phase ≥ `KIRSCHE` und < `LINDE`, `colonyStrength` der letzten Durchsicht ≥ `honeySuperMinFrames` | Jahr | `HONEY_SUPER_CHECK` („Kirsche blüht (geschätzt). B3 besetzt 9 Waben.“) | `inspections:read` |
|
||
| A8 | `QUEEN_REPLACE` / PLAN | `SCHWARMZEIT` oder `SOMMER`, `isQueenAged` | `<queenId>-<Jahr>` | `QUEEN_OLD` („Königin Q-12 ist von 2024.“) | `queens:read` |
|
||
| A9 | `WINTER_CHECK` / DO | `HERBST`, keine Einwinterung für `winterYear = Jahr` | `<Jahr>-AUTUMN` | `WINTER_CHECK_AUTUMN_MISSING` („Einwinterung erfassen“) | `winter-checks:read` |
|
||
| A10 | `WINTER_CHECK` / DO | `FRUEHJAHR`, Einwinterung des Vorwinters da, Auswinterung fehlt | `<Jahr−1>-SPRING` | `WINTER_CHECK_SPRING_MISSING` („Auswinterung erfassen“) | `winter-checks:read` |
|
||
|
||
- Fälligkeit heute; `expiresAt = dueDate + suggestionTtlDays`; jede Regel einzeln über `config.rules` schaltbar; Phase und „gemeldet/geschätzt/Kalender“ im Grund aus `SeasonState`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// autopilot-rules.spec.ts – je Regel A4–A10 „gegeben -> erwartet“ mit Anker und reasonCode
|
||
it('A4: SPAETSOMMER without treatment since segment start -> SUMMER_TREATMENT, HIGH, anchor year', ...);
|
||
it('A5 and A6 never fire in WINTERRUHE; A6 anchors on the inspection', ...);
|
||
it('A7: KIRSCHE and 9 frames -> HONEY_SUPER; 7 frames -> nothing; LINDE -> nothing', ...);
|
||
it('A8: hatchYear 2024 in 2026 SCHWARMZEIT -> QUEEN_REPLACE', ...);
|
||
it('A9 and A10 follow the WinterCheck rows', ...);
|
||
// suggesting-triggers.spec.ts (Wächter)
|
||
it('every suggesting AutomationTriggerType has at least one registered reason text template', ...); // Liste der vorschlagenden Trigger im Wächter; ruleKey je Vorlage gegen die vier Register
|
||
it('every reasonCode passed to proposeIn in src is registered', ...); // Quelltext-Scan auf reasonCode gegen die Register
|
||
// test/autopilot.e2e-spec.ts
|
||
it('swarm season by report, last inspection 9 days ago -> step 1 INSPECT overdue', ...); // Meldung KIRSCHE, drain, next-steps
|
||
it('a SeasonPhaseChanged syncs every living colony of the apiary', ...);
|
||
it('never more than 3 steps per colony', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/autopilot` und die e2e-Datei → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben (Eingaben aus `loadAutopilotInputIn`, keine zusätzlichen Abfragen je Regel).
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/autopilot.e2e-spec.ts`, `test/next-steps.e2e-spec.ts`, `test/daily-evaluation.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 28: Honig-Kette – Ereignisse, Vorbelegung, Plausibilität, Wartezeit bei der Schleuderung
|
||
|
||
**Files**
|
||
- Create: `src/honey-batches/honey-plausibility.ts` (+ Spec), `src/extractions/dto/extraction-prefill.query.ts`, `src/honey-batches/dto/honey-batch-prefill.query.ts`
|
||
- Modify: `src/extractions/extractions.service.ts` (+ Spec), `src/extractions/extractions.controller.ts`, `src/extractions/extractions.module.ts`, `src/honey-batches/honey-batches.service.ts` (+ Spec), `src/honey-batches/honey-batches.controller.ts`, `src/honey-batches/honey-batches.module.ts`, `src/webhooks/webhook.handlers.ts` (+ Spec), `src/withdrawal/withdrawal.handlers.ts` (+ Spec), `src/withdrawal/withdrawal-check.service.ts` (`conflictsForExtraction`), `src/common/after-commit-usage.spec.ts`
|
||
- Test: neu `test/honey-chain.e2e-spec.ts`, `test/extractions.e2e-spec.ts`, `test/honey-batches.e2e-spec.ts`, `test/webhooks.e2e-spec.ts`
|
||
- Docs: ARCHITECTURE §10: Ereignisse `ExtractionRecorded/Corrected`, `HoneyBatchRecorded/Corrected`, Webhook `batch.created` aus Handler, Handler `withdrawal.extraction-check`; Annahme 11: Altpfad-Liste ohne Honigchargen.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 12 (`PlausibilityWarning`, `WithdrawalCheckService`), Task 23.
|
||
- Produces:
|
||
```ts
|
||
export function extractionWarnings(amountKg: number | null, harvests: readonly { id: string; netWeight: number | null }[]): PlausibilityWarning[]; // EXTRACTION_EXCEEDS_HARVEST, HARVEST_NET_MISSING
|
||
export function batchWarnings(extraction: { id: string; amount: number | null }, batches: readonly { id: string; initialAmount: number | null }[]): PlausibilityWarning[]; // BATCHES_EXCEED_EXTRACTION
|
||
export interface ExtractionPrefill { amountKg: number | null; honeyType: string | null; sources: { harvestId: string; date: string; netWeight: number | null }[]; warnings: PlausibilityWarning[] }
|
||
export interface HoneyBatchPrefill { remainingKg: number | null; honeyType: string | null; waterContent: number | null; warnings: PlausibilityWarning[] }
|
||
ExtractionsService.prefill(harvestIds: readonly string[]): Promise<ExtractionPrefill>; // GET /extractions/prefill?harvestIds=a,b (extractions:create; 1–50 IDs)
|
||
HoneyBatchesService.prefill(extractionId: string): Promise<HoneyBatchPrefill>; // GET /honey-batches/prefill?extractionId= (honey-batches:create)
|
||
WithdrawalCheckService.conflictsForExtraction(db: DbClient, extractionId: string): Promise<WithdrawalConflict[]>;
|
||
@OnDomainEvent(['ExtractionRecorded', 'ExtractionCorrected'], 'withdrawal.extraction-check')
|
||
// DOMAIN_EVENT_WEBHOOKS += HoneyBatchRecorded: 'batch.created' (data = Charge über tx)
|
||
// POST/PATCH /extractions und /honey-batches: Antwort WithWarnings<…>
|
||
```
|
||
- Vorbelegung (Spec 7.6): Schleudermenge = Σ `netWeight` (nur wenn alle bekannt), Sorte nur bei gleicher `honeyType`; Chargenmenge = Schleudermenge − Σ `initialAmount` vorhandener Chargen (`null` zählt nicht), Sorte und Wassergehalt der Schleuderung. Warnungen beim Anlegen und Ändern, nie eine Sperre.
|
||
- `create`/`update` beider Services in einer Transaktion mit Audit (mit `tx`) und Ereignis; `update` sperrt zuerst (`lockRowForUpdate(tx, 'Extraction' | 'HoneyBatch', id)`) und liest `before` erst danach; `webhooks.dispatch('batch.created')` entfällt; der Handler lädt die Charge über `tx` mit demselben `honeyBatchInclude` wie bisher, die Webhook-Payload `data` bleibt also unverändert.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// honey-plausibility.spec.ts
|
||
it('warns when the extraction exceeds the known net harvest', ...);
|
||
it('hints when a net weight is missing and does not compare then', ...);
|
||
it('warns when the initial amounts of the batches exceed the extraction', ...);
|
||
// test/honey-chain.e2e-spec.ts
|
||
it('prefill sums the net weights of the chosen harvests and keeps a shared honey type', ...);
|
||
it('extraction and batch answers carry both plausibility warnings, never 4xx', ...);
|
||
it('an extraction of a harvest inside a withdrawal window warns but notifies no second time', ...);
|
||
it('a new honey batch delivers batch.created through the handler', ...);
|
||
it('the batch.created payload has the same shape as before (honeyBatchInclude)', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/extractions src/honey-batches src/webhooks src/withdrawal src/common/after-commit-usage.spec.ts` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; alle genannten e2e-Dateien, `test/withdrawal-chain.e2e-spec.ts`, `test/authorization-coverage.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 29: Bewegungsbuch der Honigcharge
|
||
|
||
**Files**
|
||
- Create: `src/honey-batches/honey-batch-ledger.service.ts` (+ Spec), `src/honey-batches/dto/create-honey-batch-movement.dto.ts`, `src/honey-batches/honey-batch-amount-writes.spec.ts` (Wächter), `test/utils/honey-ledger.ts` (aus Task 23 erweitern)
|
||
- Modify: `src/bottling/bottling.service.ts` (+ Spec; `takeFromHoneyBatch`, `giveBackToHoneyBatch`, `normalizeAmount` entfallen), `src/bottling/bottling.module.ts`, `src/honey-batches/honey-batches.service.ts` (+ Spec), `src/honey-batches/honey-batches.controller.ts`, `src/honey-batches/honey-batches.module.ts`
|
||
- Test: neu `test/honey-ledger.e2e-spec.ts`, `test/bottling.e2e-spec.ts`, `test/data-integrity.e2e-spec.ts` („Bottling quantities“)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 23 (Spalten), Task 28 (Datei-Reihenfolge).
|
||
- Produces:
|
||
```ts
|
||
export interface LedgerBooking { honeyBatchId: string; deltaKg: number; type: 'BOTTLING' | 'CORRECTION' | 'WITHDRAWAL'; reason?: WithdrawalReason | null;
|
||
notes?: string | null; sourceBottlingBatchId?: string | null; actingUserId: string | null }
|
||
export interface LedgerResult { amountKg: number | null; movementId: string | null }
|
||
HoneyBatchLedgerService.bookIn(tx: Prisma.TransactionClient, booking: LedgerBooking): Promise<LedgerResult>;
|
||
HoneyBatchLedgerService.setInitialIn(tx: Prisma.TransactionClient, honeyBatchId: string, amountKg: number): Promise<void>; // null -> Wert, keine Buchung
|
||
HoneyBatchLedgerService.movements(honeyBatchId: string): Promise<HoneyBatchMovementView[]>;
|
||
export interface HoneyBatchMovementView { id: string; type: BatchMovementType; quantityGrams: number; reason: WithdrawalReason | null; notes: string | null;
|
||
sourceBottlingBatchId: string | null; createdAt: Date; userId: string | null }
|
||
// GET /honey-batches/:id/movements (honey-batches:read); POST /honey-batches/:id/movements (honey-batches:update, @Idempotent)
|
||
// CreateHoneyBatchMovementDto { type: 'WITHDRAWAL' | 'CORRECTION'; deltaKg: number (≠ 0, |x| ≤ 100000); reason?: WithdrawalReason (Pflicht bei WITHDRAWAL); notes?: string (≤ 500) }
|
||
```
|
||
- `bookIn` (Spec 7.7): `lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId)`, Charge danach lesen; `initialAmount null` → keine Buchung, `{ amountKg: null }`; negatives Delta nur bei Rest ≥ |Delta| − 0,0005 kg, sonst 400 „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“ (bei `CORRECTION`/`WITHDRAWAL` „Die Menge kann nicht unter 0 sinken.“); `BatchMovement` mit `quantity = round(deltaKg × 1000)`; `amount = max(0, round((initialAmount + Σ quantity / 1000) × 1000) / 1000)`.
|
||
- Abfüllung ändern/entfernen: zuerst `lockRowForUpdate(tx, 'BottlingBatch', id)`, Abfüllung danach lesen, dann `bookIn` (sperrt die Charge; bei Chargenwechsel beide Chargen `id` aufsteigend). `PATCH /honey-batches/:id` liest den aktuellen Rest erst unter der Chargensperre.
|
||
- Vorgänge (Tabelle Spec 7.7): Charge mit Menge anlegen → `initialAmount = amount`; Abfüllung anlegen `BOTTLING −`, ändern `BOTTLING` Differenz (Wechsel der Charge: zurück auf alte, ab von neuer), entfernen `BOTTLING +` mit Notiz „Abfüllung entfernt“; Status `BOTTLED` wie bisher; `PATCH amount` → `CORRECTION` (neu − aktuell); `amount: null` bei vorhandenen Buchungen → 400 „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“; `null → Wert` → `setInitialIn`. `create` und `update` in `honey-batches.service.ts` reichen `amount` nie per `{ ...dto }` an Prisma (`const { amount, ...rest } = dto`; Anlegen setzt `amount` und `initialAmount` gemeinsam über `setInitialIn`); der Wächter-Regex erfasst die Spread-Form nicht, deshalb die Unit-Tests oben.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// honey-batch-ledger.service.spec.ts
|
||
it('books in grams and rounds the amount to the gram', ...);
|
||
it('refuses a withdrawal larger than the rest with the German message', ...);
|
||
it('a correction books the difference new minus current', ...);
|
||
it('books nothing for a batch without initialAmount', ...);
|
||
// honey-batch-amount-writes.spec.ts (Wächter)
|
||
it('honey batch amount is written only by the ledger', () => expect(writersOf(/honeyBatch\.(update|updateMany|upsert)\([^)]*amount/s, /UPDATE "HoneyBatch"[^;]*"amount"/)).toEqual(['src/honey-batches/honey-batch-ledger.service.ts']));
|
||
// honey-batches.service.spec.ts (der Wächter-Regex sieht `data: { ...dto }` nicht)
|
||
it('update never passes amount to Prisma, even when the dto carries it', ...);
|
||
it('create writes amount and initialAmount together', ...);
|
||
// test/honey-ledger.e2e-spec.ts
|
||
it('keeps the invariant after create, bottle, correct and remove', ...); // INVARIANT_SQL -> []
|
||
it('two parallel bottlings over the rest: exactly one 400', ...);
|
||
it('PATCH amount null with movements answers 400', ...);
|
||
it('GET movements lists BOTTLING with its source bottling', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/honey-batches src/bottling` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/honey-ledger.e2e-spec.ts`, `test/bottling.e2e-spec.ts`, `test/data-integrity.e2e-spec.ts`, `test/honey-batches.e2e-spec.ts`, `test/shop-sync.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 30: Qualität vor der Abfüllung
|
||
|
||
**Files**
|
||
- Create: `src/bottling/water-content.ts` (+ Spec), `src/bottling/bottling-prefill.service.ts` (+ Spec), `src/bottling/dto/bottling-prefill.query.ts`
|
||
- Modify: `src/bottling/bottling.service.ts` (+ Spec), `src/bottling/bottling.controller.ts`, `src/bottling/dto/create-bottling-batch.dto.ts`, `src/honey-batches/honey-batches.service.ts` (Warnung bei Honigmessung), `src/products/dto/create-product.dto.ts`, `src/common/dates.ts` (+ Spec; `addMonthsClamped`), `src/common/validation/update-dto-null-handling.spec.ts`
|
||
- Test: neu `test/bottling-quality.e2e-spec.ts`, `test/products.e2e-spec.ts`
|
||
- Docs: neue Annahme „Wassergehalt“ (18 % Warnung nach D.I.B., über 20 % Bestätigung nach HonigV Anlage 2, Heidehonig nur per Config, jüngste Messung maßgeblich, MHD aus `shelfLifeMonths` ohne Standardwert).
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 1 (`WaterContentConfig`), Task 12 (Warnungen), Task 29.
|
||
- Produces:
|
||
```ts
|
||
export function latestWaterContent(batch: Pick<HoneyBatch, 'waterContent'>, measurements: readonly Pick<HoneyMeasurement, 'waterContent' | 'measuredAt'>[]): number | null;
|
||
export function waterContentWarnings(value: number | null, honeyType: string | null, config: WaterContentConfig, refs: { type: string; id: string }[]): PlausibilityWarning[];
|
||
// > confirm -> WATER_CONTENT_ABOVE_LEGAL (BESTAETIGEN); > warn -> WATER_CONTENT_HIGH; null -> WATER_CONTENT_MISSING; byHoneyType ohne Groß-/Kleinschreibung exakt
|
||
export function addMonthsClamped(day: string, months: number): string; // 2027-01-31 + 1 -> 2027-02-28
|
||
export interface BottlingPrefill { jarSize: number | null; jarSizeSource: 'PRODUCT' | null; bestBeforeDate: string | null; shelfLifeMonths: number | null;
|
||
waterContent: number | null; warnings: PlausibilityWarning[] }
|
||
BottlingPrefillService.prefill(query: { honeyBatchId: string; productId?: string; date?: string }): Promise<BottlingPrefill>; // GET /bottling/prefill (bottling:create)
|
||
// CreateBottlingBatchDto: jarSize?, bestBeforeDate? optional wenn productId gesetzt; acknowledgedWarnings?: 'WATER_CONTENT_ABOVE_LEGAL'[] (@ArrayMaxSize(1))
|
||
// CreateProductDto.shelfLifeMonths?: number (@IsOptional @IsInt @Min(1) @Max(60)); UpdateProductDto: leerbar
|
||
```
|
||
- `POST /bottling`: Glasgröße = gesendet, sonst `product.packagingSize`; MHD = gesendet, sonst `date + shelfLifeMonths`; ohne beides 400 „Glasgröße und MHD fehlen. Bitte einen Artikel wählen oder beides eintragen.“ bzw. ohne Haltbarkeit 400 „Ohne Haltbarkeit am Artikel bitte ein MHD eintragen.“; abweichendes Glas → `JAR_SIZE_DIFFERS`; Wassergehalt über der Bestätigungsgrenze ohne Bestätigung → 409 `{ code: 'CONFIRMATION_REQUIRED', warnings }` (der Idempotenz-Interceptor gibt den Schlüssel frei, die bestätigte Wiederholung darf ihn wieder nutzen). `PATCH /bottling` liefert nur Warnungen. Honigmessung anlegen → Antwort mit Warnungen, nie 409.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
it('best before 2027-01-31 plus 1 month is 2027-02-28', () => expect(addMonthsClamped('2027-01-31', 1)).toBe('2027-02-28'));
|
||
it('the newest measurement wins over the batch value', ...);
|
||
it('a configured heather honey confirms only above 23 %', ...);
|
||
// test/bottling-quality.e2e-spec.ts
|
||
it('19 % -> 201 with WATER_CONTENT_HIGH', ...);
|
||
it('20.5 % -> 409 CONFIRMATION_REQUIRED; confirmed retry with the same key -> 201', ...);
|
||
it('best before comes from shelfLifeMonths and a different jar size gives a hint', ...);
|
||
it('prefill returns jar size and best before from the product', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/bottling src/common/dates.spec.ts src/products src/common/validation` und die e2e-Dateien → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/bottling-quality.e2e-spec.ts`, `test/bottling.e2e-spec.ts`, `test/products.e2e-spec.ts`, `test/honey-batches.e2e-spec.ts`, `test/authorization-coverage.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 31: Frontend – Pflanzenentwicklung
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/phenology/types.ts`, `api.ts`, `labels.ts` (+ Spec), `src/components/phenology/phenology-report-sheet.tsx` (+ Spec)
|
||
- Modify: `src/lib/offline/db.ts` (+ Spec; `DB_VERSION = 4`, Store `pending-phenology-reports`), `src/lib/offline/sync.ts` (+ Spec), `src/components/autopilot/season-chip.tsx`, `src/app/(app)/apiaries/[id]/page.tsx`
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 26, Task 18 (`SeasonChip`).
|
||
- Produces:
|
||
```ts
|
||
export const PHASE_REPORT_LABELS: Record<PhenologyPhase, string>; // 'Salweide blüht', 'Kirsche blüht', 'Apfel blüht', 'Holunder blüht', 'Linde blüht', 'Tracht vorbei', 'Efeu blüht', 'Winterruhe'
|
||
export const PHASE_SOURCE_LABELS: Record<PhaseSource, string>; // 'gemeldet', 'geschätzt', 'Kalender'
|
||
export function createPhenologyReport(input: CreatePhenologyReportInput, options: CreateOptions): Promise<PhenologyReport>;
|
||
export function revokePhenologyReport(id: string, options: CreateOptions): Promise<PhenologyReport>;
|
||
export function fetchPhenologyState(): Promise<PhenologyState[]>; export function fetchPhenologyReports(year: number): Promise<PhenologyReport[]>;
|
||
export function queuePhenologyReport(input: CreatePhenologyReportInput, idempotencyKey: string, ownerUserId: string): Promise<void>;
|
||
```
|
||
- Stand-Seite: Kopf mit `SeasonChip` und Quelle („Schwarmzeit · alle 7 Tage · gemeldet am 18.04.“), Knopf „Pflanze blüht“ (nur mit `phenology:create`) öffnet `PhenologyReportSheet`: Zeilen `min-h-14` je Phase mit Name, „für alle Stände“ vorgewählt (umschaltbar auf diesen Stand), ein Tipp meldet für heute über `useCaptureMutation` (offline in die Warteschlange); darunter die Meldungen des Jahres mit „Zurückziehen“ (`phenology:update`).
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('one tap reports the phase for today for all apiaries', ...);
|
||
it('rows are 56 px and „für alle Stände“ is preselected', ...);
|
||
it('offline the report is queued and synced later', ...);
|
||
it('db upgrade 3 -> 4 keeps queued inspections', ...);
|
||
it('shows the season with its source on the apiary page', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/lib/phenology src/components/phenology src/lib/offline "src/app/(app)/apiaries"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 32: Frontend – Honig-Kette und Bewegungsbuch
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/lib/honey-batches/movements.ts`, `src/app/(app)/honey-batches/[id]/honey-batch-movements-card.tsx` (+ Spec), `src/app/(app)/honey-batches/[id]/correct-amount-dialog.tsx` (+ Spec)
|
||
- Modify: `src/components/form/previous-value-hint.tsx` (+ Spec; Prop `prefix`), `src/lib/extractions/api.ts` + `types.ts` (Vorbelegung, `warnings`), `src/lib/honey-batches/api.ts` + `types.ts`, `src/app/(app)/extractions/new/page.tsx` (+ Spec), `src/app/(app)/honey-batches/create-honey-batch-dialog.tsx` (+ neue Spec), `src/app/(app)/honey-batches/[id]/page.tsx`
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 28, 29, Task 21 (`WarningList`, `toastWarnings`).
|
||
- Produces:
|
||
```ts
|
||
// PreviousValueHintProps += prefix?: string // Standard 'Letztes Mal'; z. B. 'Aus 2 Ernten' -> „Aus 2 Ernten: 42,5 kg · übernehmen“
|
||
export function fetchExtractionPrefill(harvestIds: string[]): Promise<ExtractionPrefill>; // Key ["extractions", "prefill", ids]
|
||
export function fetchHoneyBatchPrefill(extractionId: string): Promise<HoneyBatchPrefill>;
|
||
export function fetchHoneyBatchMovements(id: string): Promise<HoneyBatchMovement[]>;
|
||
export function bookHoneyBatchMovement(id: string, input: { type: 'WITHDRAWAL' | 'CORRECTION'; deltaKg: number; reason?: WithdrawalReason; notes?: string }, options: CreateOptions): Promise<HoneyBatchMovement>;
|
||
```
|
||
- Mengen erscheinen nur als Hinweis mit „übernehmen“ (nie still im Feld); Warnliste über „Speichern“; Karte „Bewegungen“ nach dem Muster `components/stock/stock-card`; „Menge korrigieren“ bucht `CORRECTION`, „Entnahme“ bucht `WITHDRAWAL` mit Grund.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('the extraction form offers „Aus 2 Ernten: 42,5 kg · übernehmen“ and fills only on tap', ...);
|
||
it('shows the plausibility warnings above „Speichern“', ...);
|
||
it('the movements card lists bottlings and corrections; „Menge korrigieren“ books the difference', ...);
|
||
it('PreviousValueHint keeps „Letztes Mal“ as default prefix', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/components/form "src/app/(app)/extractions" "src/app/(app)/honey-batches"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 33: Frontend – Abfüllung mit Artikel-Werten und Bestätigung, Haltbarkeit am Artikel
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Modify: `src/lib/bottling/api.ts` + `types.ts` (`fetchBottlingPrefill`, `acknowledgedWarnings`), `src/app/(app)/bottling/new/page.tsx` + `bottling-form-values.ts` (+ Spec), `src/lib/products/types.ts`, Artikel-Formulare unter `src/app/(app)/products/` (+ Spec)
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 30, Task 21.
|
||
- Produces: `fetchBottlingPrefill(query): Promise<BottlingPrefill>`; Formularwerte `acknowledgedWarnings: 'WATER_CONTENT_ABOVE_LEGAL'[]`.
|
||
- Glasgröße und MHD direkt aus dem Artikel mit Herkunft „aus Artikel: 24 Monate“ (Stammdaten, keine Beobachtung), weiter änderbar; bei 409 `CONFIRMATION_REQUIRED` `ConfirmDialog` „Wassergehalt über 20 %. Trotzdem abfüllen?“ (Fokusfalle, Fokus zurück), Bestätigen sendet erneut mit gleichem Idempotency-Key und `acknowledgedWarnings`; Artikel-Formular: Feld „Haltbarkeit (Monate)“, leerbar. Spec 4.7: `packagingSize` gilt als Gramm wie `BottlingBatch.jarSize`; das Feld heißt im Artikel-Formular „Gewicht pro Glas (g)“ und prüft ganze Gramm (1–10000). Trug es bisher eine andere Beschriftung oder Einheit, wird sie hier korrigiert; vorhandene Werte werden nicht umgerechnet.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('fills jar size and best before from the product and names the source', ...);
|
||
it('a 409 opens the confirmation and the confirmed retry sends acknowledgedWarnings with the same key', ...);
|
||
it('the product form saves and clears shelfLifeMonths', ...);
|
||
it('the product form labels packagingSize as grams and rejects 0 and values above 10000', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run "src/app/(app)/bottling" "src/app/(app)/products"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npx vitest run src`; kein Commit.
|
||
|
||
---
|
||
|
||
## Lieferung T4c – Telegram
|
||
|
||
### Task 34: Schema T4c, Bot-Client und Startprüfung
|
||
|
||
**Files**
|
||
- Modify: `prisma/schema.prisma`
|
||
- Create: `prisma/migrations/20261015110000_t4_telegram/migration.sql`
|
||
- Create: `src/telegram/telegram-config.ts` (+ Spec), `src/telegram/telegram-api-error.ts`, `src/telegram/telegram-client.ts` (+ Spec), `src/telegram/telegram-api-error.spec.ts`, `src/telegram/telegram.module.ts`, `src/common/no-network-outside-loops.spec.ts` (Wächter)
|
||
- Modify: `src/common/outbox/outbox-claims.ts` (+ Spec), `src/common/worker-wakeup.ts`, `src/runtime/startup-checks.ts` (+ Spec), `src/domain-events/handler-contract.spec.ts`, `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec; `NotificationDelivery` am Ende der Map, Schlüssel `"id"`)
|
||
- Docs: §8: `TelegramLink`, `TelegramLinkToken`, `NotificationDelivery`, `TelegramPollState` ausgenommen (Begründungen Spec 4.13); §9 „Ausgehende Aufrufe“: Telegram nur aus dem Worker, Klartext, Token nie in Logs.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 3.
|
||
- Produces:
|
||
```prisma
|
||
// enums NotificationChannel, NotificationDeliveryStatus; models TelegramLink, TelegramLinkToken, NotificationDelivery, TelegramPollState exakt wie Spec 4.15;
|
||
// CHECK ((notificationId IS NOT NULL AND userId IS NOT NULL AND templateCode IS NULL AND chatId IS NULL) OR (notificationId IS NULL AND templateCode IS NOT NULL AND chatId IS NOT NULL));
|
||
// INSERT INTO "TelegramPollState" (id) VALUES ('singleton')
|
||
```
|
||
```ts
|
||
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery' | 'NotificationDelivery'; // successStatus DELIVERED, settledAt deliveredAt
|
||
export function deferClaim(db: DbClient, table: 'NotificationDelivery', id: string, claimedAt: Date, delayMs: number): Promise<boolean>; // PENDING, availableAt = now() + delay, attempts unverändert
|
||
export function settleSkipped(db: DbClient, table: 'NotificationDelivery', id: string, claimedAt: Date, reason: string): Promise<boolean>; // SKIPPED, lastError = reason
|
||
// WAKE_CHANNELS += notificationDeliveries: 'notification_delivery'
|
||
export const TELEGRAM_TOKEN_PATTERN = /^\d{5,}:[A-Za-z0-9_-]{30,}$/;
|
||
export interface TelegramConfig { token: string | null; botUsername: string | null }
|
||
export function telegramConfig(env?: NodeJS.ProcessEnv): TelegramConfig;
|
||
export function telegramBotAvailable(env?: NodeJS.ProcessEnv): boolean; // API und Worker: TELEGRAM_BOT_USERNAME nicht leer (getrimmt); liest nie das Token (der API-Pod hat es nicht)
|
||
export function assertTelegramToken(role: ApicoreRole, env?: NodeJS.ProcessEnv): void; // worker/all: Format; Produktion: kein 'change-me…'
|
||
export class TelegramApiError extends Error { constructor(readonly method: string, readonly statusCode: number | null, readonly description: string, readonly retryAfterSec: number | null) }
|
||
export interface TelegramUpdate { updateId: bigint; chatId: bigint; chatType: string; text: string | null; firstName: string | null }
|
||
export const TELEGRAM_SEND_TIMEOUT_MS = 10_000; export const TELEGRAM_POLL_TIMEOUT_SEC = 25; export const TELEGRAM_POLL_FETCH_TIMEOUT_MS = 35_000;
|
||
@Injectable() class TelegramClient {
|
||
isConfigured(): boolean; // nur Worker: Token vorhanden und gültig geformt
|
||
sendMessage(chatId: bigint, text: string, signal?: AbortSignal): Promise<void>; // ohne parse_mode, link_preview_options.is_disabled
|
||
getUpdates(offset: bigint, signal?: AbortSignal): Promise<TelegramUpdate[]>; // allowed_updates ["message"]
|
||
}
|
||
```
|
||
- URL `https://api.telegram.org/bot<token>/<method>` nur intern; jeder Fehler wird `TelegramApiError` ohne URL; Antwortkörper nur auf `ok`, `description`, `parameters.retry_after` gelesen; Größe begrenzt (256 kB).
|
||
- Wächter `no-network-outside-loops.spec.ts`: Wetter-Hosts nur in `src/weather/`, `api.telegram.org` nur in `src/telegram/`; `fetch(` nur in `src/weather/`, `src/telegram/` und den Bestandsdateien `src/calendar-sync/graph-client.service.ts`, `src/common/password/password-policy.ts`; nie in `*.handlers.ts` oder `src/jobs/`. `handler-contract.spec.ts` verbietet zusätzlich Importe von `telegram-client`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
it('the token never appears in TelegramApiError or loggableError', async () => {
|
||
fetchMock.mockRejectedValue(new TypeError(`connect failed https://api.telegram.org/bot${TOKEN}/sendMessage`));
|
||
const error = await client.sendMessage(1n, 'x').catch((e) => e);
|
||
expect(JSON.stringify({ ...error, message: error.message, log: loggableError(error) })).not.toContain(TOKEN);
|
||
});
|
||
it('sends plain text without parse_mode and with link preview disabled', ...);
|
||
it('telegramBotAvailable is true with the username set and the token unset (API pod) and false with an empty username', ...);
|
||
it('maps 429 with retry_after into TelegramApiError.retryAfterSec', ...);
|
||
it('the worker refuses a malformed or placeholder token in production', ...); // startup-checks.spec
|
||
it('settleSkipped and deferClaim are compare-and-set on id and claimedAt', ...); // outbox-claims.spec
|
||
it('network calls stay inside their loop directories', ...); // Wächter
|
||
// backup.service.spec.ts: die vier Modelle sind ausgenommen
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/telegram src/common src/runtime src/domain-events src/backup` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS; Drift-Check → 0.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/backup.e2e-spec.ts`, `test/platform-schema.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 35: Telegram-Zustellung anlegen und senden
|
||
|
||
**Files**
|
||
- Create: `src/notifications/delivery-time.ts` (+ Spec), `src/notifications/delivery-planner.ts` (+ Spec), `src/telegram/telegram-texts.ts` (+ Spec), `src/telegram/telegram-sender.ts` (+ Spec), `test/telegram.e2e-spec.ts`, `test/utils/telegram.ts` (Bot-API-Attrappe)
|
||
- Modify: `src/notifications/notifications.service.ts` (+ Spec; Planer nach jedem Einfügen), `src/notifications/notifications.module.ts`, `src/auth/permissions/permission-resolver.service.ts` (+ Spec; `getFreshPermissions`), `src/jobs/cleanup.job.ts` (+ Spec), `src/telegram/telegram.module.ts`, `src/worker/worker-runtime.service.ts` (+ Spec; Schleife `telegram-send`), `src/worker/worker-runtime.module.ts`, `src/worker/worker.module.ts`
|
||
- Docs: neue Annahme „Erinnerungen und Telegram“ (Teil Zustellung: Outbox, Ruhezeit, Kategorien, Prüfung beim Senden, Grenzen, Fehlerbilder); ARCHITECTURE §10: Schleife `telegram-send`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 3, 16 (Kategorien), Task 34.
|
||
- Produces:
|
||
```ts
|
||
export interface DeliveryPreferences { digestTime: string; quietFrom: string | null; quietUntil: string | null }
|
||
export function deliveryAvailableAt(now: Date, category: NotificationCategory, prefs: DeliveryPreferences): Date; // max(now, Kategorie-Zeit, Ende Ruhezeit)
|
||
export function isInQuietHours(now: Date, prefs: DeliveryPreferences): boolean; // über Mitternacht
|
||
NotificationDeliveryPlanner.planIn(db: DbClient, notifications: readonly Notification[]): Promise<number>; // TELEGRAM-Zeilen + notifyWorker('notification_delivery')
|
||
export type TelegramTemplateCode = 'LINKED' | 'LINK_INVALID' | 'STOPPED' | 'HELP';
|
||
export const TELEGRAM_TEMPLATES: Record<TelegramTemplateCode, string>; // Texte aus Global Constraints
|
||
export function renderTelegramText(n: Pick<Notification, 'message' | 'link'>, detail: boolean, frontendOrigin: string): string; // ≤ 600 Zeichen
|
||
export const TELEGRAM_SEND_POLL_MS = 5_000; export const TELEGRAM_SEND_BATCH = 25; export const TELEGRAM_MAX_PER_SECOND = 25;
|
||
export const TELEGRAM_CHAT_SPACING_MS = 1_100; export const TELEGRAM_TOKEN_INVALID_PAUSE_MS = 900_000;
|
||
PermissionResolverService.getFreshPermissions(userId: string): Promise<ReadonlySet<string>>; // liest immer frisch (wie isTokenValid, ohne den 30-s-Cache) und füllt den Cache; leer bei inaktivem Nutzer oder Rolle
|
||
@Injectable() class TelegramSender { runOnce(signal: AbortSignal): Promise<number>; drain(signal: AbortSignal, maxRounds?: number): Promise<number>; releaseStaleClaims(): Promise<number> }
|
||
```
|
||
- Planen (Spec 9a.4) in derselben Transaktion wie die Meldung: nur bei `telegramEnabled`, bestehendem `TelegramLink` und Kategorie in `telegramCategories`; `now` einmal per `SELECT now()`.
|
||
- Senden (Spec 9a.6): `claimBatch(…, 'NotificationDelivery', { limit: 25, orderBy: 'createdAt' })`; je Nutzer bzw. Chat nur die älteste, die übrigen `deferClaim(…, 1100)`; vor jedem Senden in einer SQL-Abfrage prüfen: Nutzer aktiv, `telegramEnabled`, Verbindung, Kategorie, Veraltung (`TASK_DUE`/`DIGEST` gleicher Berliner Tag über `to_char(… AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD')`, sonst `now() - "createdAt" <= interval '24 hours'`), bei `TASK_DUE` Aufgabe noch offen; Rechte über `PermissionResolverService.getFreshPermissions` (nicht `getPermissions`: der Worker bekommt kein `invalidateUser` der API-Pods, der 30-s-Cache ließe ein entzogenes Recht bis zu 30 s weiter senden) ⊇ `requiredPermissions`; sonst `settleSkipped` mit `user inactive` | `telegram disabled` | `not linked` | `category off` | `permission revoked` | `stale` | `task already done`. Systemantworten (`templateCode`) ohne diese Prüfungen.
|
||
- Antworten: 200 → `settleSuccess(…, 'DELIVERED')`; 429 → `deferClaim(retry_after × 1000)`; 403 oder 400 „chat not found“ → Verbindung nur löschen, wenn sie noch auf diesen Chat zeigt (`telegramLink.deleteMany({ where: { userId, chatId } })`; ein neuerer Link desselben Nutzers auf einen anderen Chat bleibt unberührt); nur bei `count 1` `telegramEnabled = false` (unter `lockRowForUpdate(tx, 'NotificationPreference', userId)` wie Task 36) und die Meldung an den Nutzer, bei `count 0` nichts davon; `settleSkipped('chat unavailable')`, Meldung `SYSTEM` an den Nutzer „Telegram-Zustellung beendet. Der Bot wurde blockiert oder der Chat gelöscht.“; 401 → `deferClaim(900000)`, Schleife pausiert 15 min, `recordForPermission('system:manage', { category: 'SYSTEM', priority: 'WARNUNG', message: 'Telegram: Bot-Token ungültig.', link: '/settings/system', dedupeKeyPrefix: 'telegram-token-invalid:<Tag>' })`; 5xx/Timeout/Netz → `settleFailure` (`loggableError`).
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// delivery-time.spec.ts
|
||
it('a delivery created at 22:00 inside quiet hours 21:00–06:30 waits until 06:30 the next day', ...);
|
||
it('TASK_DUE waits until one minute after the digest time, DIGEST until the digest time', ...);
|
||
it('without quiet hours a SUGGESTION is available now', ...);
|
||
// telegram-sender.spec.ts
|
||
it('checks permissions and the link at send time and skips with a reason', ...);
|
||
it('sends plain text of at most 600 characters with the app link', ...);
|
||
it('429 defers by retry_after without counting an attempt', ...);
|
||
it('403 removes the link, switches Telegram off and notifies the user in the app', ...);
|
||
it('a 403 for an old chat does not remove a newer link of the same user', ...);
|
||
it('the permission check reads fresh, not the 30 s cache', ...);
|
||
it('401 keeps the delivery pending, pauses 15 min and notifies system:manage once a day', ...);
|
||
it('a stale DIGEST from yesterday is SKIPPED', ...);
|
||
it('keeps 1.1 s between two messages to the same chat', ...);
|
||
// cleanup.job.spec.ts
|
||
it('cleanup deletes terminal NotificationDelivery rows after 30 days (FAILED after 90) and TelegramLinkToken rows expired for 30 days', ...);
|
||
// test/telegram.e2e-spec.ts (Bot API gemockt)
|
||
it('a due task yields one delivery after the morning time and none inside quiet hours', ...);
|
||
it('two send loops in parallel deliver each message exactly once', ...);
|
||
it('a revoked permission skips the delivery at send time', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/notifications src/telegram src/worker` und `test/telegram.e2e-spec.ts` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben; Schleifenzeile `telegram-send` (5 000 ms, `notification_delivery`) nur, wenn `TelegramClient.isConfigured()`. `TelegramModule` importiert `PermissionsModule` (`WorkerModule` hat keinen globalen Resolver; der Katalogabgleich `PermissionCatalogSyncService.onModuleInit` läuft dann auch im Worker mit, idempotent per `skipDuplicates`). `cleanup.job.ts` räumt wie bei `WebhookDelivery` auf: `DELETE FROM "NotificationDelivery"` für `DELIVERED`/`SKIPPED` älter als `WEBHOOK_DELIVERY_RETENTION_DAYS` und `FAILED` älter als `FAILED_RETENTION_DAYS`; `DELETE FROM "TelegramLinkToken"` mit `"expiresAt" < now() - interval '30 days'`.
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/telegram.e2e-spec.ts`, `test/notifications.e2e-spec.ts`, `test/reminders.e2e-spec.ts`, `test/jobs.e2e-spec.ts`, `test/worker-runtime.e2e-spec.ts` → PASS; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 36: Telegram verbinden, trennen und empfangen
|
||
|
||
**Files**
|
||
- Create: `src/telegram/telegram-link.service.ts` (+ Spec), `src/telegram/telegram-updates.service.ts` (+ Spec), `src/profile/telegram.controller.ts`, `src/profile/dto/telegram-link-token.dto.ts`
|
||
- Modify: `src/profile/profile.module.ts`, `src/notifications/notification-settings.controller.ts` + `notification-preferences.service.ts` (Telegram-Status in der Antwort), `src/telegram/telegram.module.ts`, `src/worker/worker-runtime.service.ts` (+ Spec; Schleife `telegram-updates`)
|
||
- Test: `test/telegram.e2e-spec.ts` (ergänzen)
|
||
- Docs: Annahme „Erinnerungen und Telegram“ ergänzen (Verbinden mit Step-up, Einmal-Token als Hash, Long Polling mit Lease, kein öffentlicher Eingang, Datenschutz-Hinweis, je Umgebung ein Bot); ARCHITECTURE §10: Schleife `telegram-updates`.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 3, 34, 35.
|
||
- Produces:
|
||
```ts
|
||
export const LINK_TOKEN_TTL_MS = 600_000; export const LINK_TOKEN_BYTES = 32;
|
||
export const HELP_REPLIES_PER_CHAT = 3; export const HELP_WINDOW_MS = 600_000; export const LEASE_SECONDS = 60; export const NO_LEASE_WAIT_MS = 30_000;
|
||
TelegramLinkService.createLinkToken(userId: string): Promise<{ deepLink: string; expiresAt: Date }>; // https://t.me/<bot>?start=<token>; 409 'Telegram ist nicht eingerichtet.' genau dann, wenn `telegramBotAvailable()` falsch ist (Benutzername leer); nie am Token prüfen, der API-Pod hat es nicht
|
||
TelegramLinkService.unlink(userId: string): Promise<void>; // Verbindung löschen, telegramEnabled false, STOPPED an den Chat
|
||
TelegramLinkService.linkFromStartIn(tx: Prisma.TransactionClient, token: string, chatId: bigint, firstName: string | null): Promise<'LINKED' | 'LINK_INVALID'>;
|
||
TelegramLinkService.stopIn(tx: Prisma.TransactionClient, chatId: bigint): Promise<void>;
|
||
TelegramUpdatesService.pollOnce(signal: AbortSignal): Promise<'disabled' | 'no-lease' | 'polled' | 'conflict' | 'error'>;
|
||
// NotificationSettingsView += telegram (configured = telegramBotAvailable(), nie das Token): { configured: boolean; botUsername: string | null; linked: boolean; firstName: string | null; linkedAt: string | null; enabled: boolean }
|
||
// POST /profile/telegram/link-token @AnyAuthenticatedUser @Throttle(SENSITIVE_ACTION_THROTTLE), Body { password: string; mfaCode?: string } -> ReauthService.verify; NICHT @Idempotent
|
||
// POST /profile/telegram/unlink @AnyAuthenticatedUser
|
||
```
|
||
- Verbindung ändern (`linkFromStartIn`, `stopIn`, `unlink`, 403-Pfad in Task 35 unverändert): erst `getOrCreate` der Präferenz, dann `lockRowForUpdate(tx, 'NotificationPreference', userId)` für alle betroffenen Nutzer (Schlüssel aufsteigend), `TelegramLink` und Präferenz erst danach lesen und ändern.
|
||
- Token: `randomBytes(32).toString('base64url')` (43 Zeichen), gespeichert `sha256` hex, `expiresAt = now() + 10 min` per SQL, ältere offene Token des Nutzers `consumedAt = now()`.
|
||
- Empfang (Spec 9a.5, 9a.7): Lease-SQL wörtlich; je Update eine Transaktion mit Wirkung und `updateOffset = update_id + 1` als Compare-and-Set: `UPDATE "TelegramPollState" SET "updateOffset" = $neu WHERE id = 'singleton' AND "leaseOwner" = $ich AND "leaseUntil" > now() AND "updateOffset" = $alt`; `count 0` (Lease verloren oder anderer Stand) bricht die Transaktion samt Wirkung ab und beendet den Zyklus mit `'no-lease'`; nur `chat.type = 'private'`; `/start <token>` → CAS `UPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "tokenHash" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now() RETURNING "userId"` (Nutzer aktiv), `TelegramLink` anlegen/ersetzen, war der Chat einem anderen Nutzer zugeordnet: dessen Verbindung lösen, `telegramEnabled = false`, Meldung `SYSTEM` „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“; `telegramEnabled = true`; Meldung `SYSTEM` „Telegram verbunden mit „{Vorname}“ am {TT.MM.JJJJ}. Warst du das nicht? In den Einstellungen trennen.“ (Link `/profile`); Antwort `LINKED`. Ungültig → `LINK_INVALID`. `/start` ohne Token, `/hilfe`, `/help`, anderer Text → `HELP`. `/stop` → `stopIn`, `STOPPED`. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher). Vorname getrimmt, ohne Steuerzeichen, ≤ 64 Zeichen.
|
||
- Schleife `telegram-updates`: Intervall 30 000 ms, kein Weckkanal; nach `'polled'` sofort `wake()`; Stop-Signal bricht den laufenden Abruf ab; 409 → `lastError`, 30 s warten; Störungen machen den Worker nicht ungesund.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
// telegram-link.service.spec.ts
|
||
it('creates a 43-character base64url token, stores only its SHA-256 and invalidates older open tokens', ...);
|
||
it('an expired or used token answers LINK_INVALID without naming an account', ...);
|
||
it('a chat that moves to another user unlinks the previous user with an in-app notice', ...);
|
||
it('a new link shows an in-app notice with the Telegram first name', ...);
|
||
it('createLinkToken answers 409 only when the bot username is empty, not when the token is unset', ...);
|
||
it('NotificationSettingsView.telegram.configured is true with the username set and no token in the API process', ...);
|
||
// telegram-updates.service.spec.ts
|
||
it('handles /start, /stop and /hilfe and ignores groups', ...);
|
||
it('answers at most 3 times per chat in 10 minutes', ...);
|
||
it('moves the offset in the same transaction as the effect', ...);
|
||
it('the offset advance is a compare-and-set on leaseOwner and the old offset; a lost lease rolls the effect back', ...);
|
||
it('takes the lease with the database clock only', ...); // keine Date-Parameter im SQL
|
||
// test/telegram.e2e-spec.ts
|
||
it('link token -> /start links the chat once; a second /start with the same token answers LINK_INVALID', ...);
|
||
it('link-token requires step-up and is throttled', ...);
|
||
it('only the lease holder polls; a second worker waits', ...);
|
||
it('unlink sends STOPPED and switches Telegram off', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/telegram src/profile src/notifications src/worker` und `test/telegram.e2e-spec.ts` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS, inklusive `test/authorization-coverage.e2e-spec.ts`.
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; kein Commit.
|
||
|
||
---
|
||
|
||
### Task 37: Telegram-Betrieb – System-API und Manifeste
|
||
|
||
**Files**
|
||
- Create: `src/system/notification-deliveries.service.ts` (+ Spec), `src/system/dto/notification-deliveries.query.ts`
|
||
- Modify: `src/system/system.controller.ts`, `src/system/system.module.ts`, `.env.example`, `deploy/k8s/components/backend-roles/worker-deployment.yaml`, `deploy/k8s/overlays/encry/configmap-backend.yaml`, `deploy/k8s/tests/manifest-policy.test.mjs`
|
||
- Test: `test/system.e2e-spec.ts` (ergänzen; nicht `telegram.e2e-spec.ts`, das Task 36 in derselben Welle ändert)
|
||
- Docs: ARCHITECTURE §7: Secret `apicore-telegram` (`optional: true`) nur im Worker, `TELEGRAM_BOT_USERNAME` in der ConfigMap, BotFather ohne Gruppen und ohne Webhook, je Umgebung ein Bot; §10 „Verwaltung > System“: Telegram-Karte.
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 34, 35; Rückgrat Task 24. (Task 36 liegt in derselben Welle: die Karte liest nur `TelegramPollState` und `NotificationDelivery` aus Task 34/35 und `telegramBotAvailable()` aus Task 34.)
|
||
- Produces:
|
||
```ts
|
||
export interface TelegramStatusView { // configured = telegramBotAvailable() (TELEGRAM_BOT_USERNAME nicht leer; der API-Pod kennt das Token nicht), ob der Empfang läuft, zeigt receiver
|
||
configured: boolean; botUsername: string | null;
|
||
receiver: { leaseOwner: string | null; lastPollAgeSeconds: number | null; lastError: string | null };
|
||
sender: { pending: number; oldestPendingAgeSeconds: number | null; failed24h: number; lastErrors: { statusCode: number | null; error: string; at: Date }[] };
|
||
}
|
||
export interface NotificationDeliveryView { id: string; status: NotificationDeliveryStatus; category: NotificationCategory | null; templateCode: string | null;
|
||
attempts: number; availableAt: Date; lastStatusCode: number | null; lastError: string | null; createdAt: Date; deliveredAt: Date | null } // nie chatId, nie Text
|
||
NotificationDeliveriesService.overview(status?: NotificationDeliveryStatus): Promise<{ telegram: TelegramStatusView; deliveries: NotificationDeliveryView[] }>; // Alter per SQL
|
||
NotificationDeliveriesService.retry(id: string, actingUserId: string): Promise<void>; // lockRowForUpdate(tx, 'NotificationDelivery', id), dann lesen; 404, 409 nicht FAILED; PENDING, attempts 0, Audit, notifyWorker
|
||
NotificationDeliveriesService.retryFailed(actingUserId: string): Promise<{ retried: number }>;
|
||
// GET /system/notification-deliveries?status= (system:manage); POST /system/notification-deliveries/:id/retry (204), POST /system/notification-deliveries/retry-failed (system:manage)
|
||
```
|
||
- Manifeste: Worker-Container `env` `TELEGRAM_BOT_TOKEN` aus `secretKeyRef { name: apicore-telegram, key: TELEGRAM_BOT_TOKEN, optional: true }`; api bekommt das Secret nicht; ConfigMap `TELEGRAM_BOT_USERNAME: ""` (Platzhalter, Runbook) und die Wetter-Variablen aus Task 24 bleiben.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```ts
|
||
it('shows the oldest pending age from the database clock and never a chat id or token', ...);
|
||
it('configured follows the bot username, not the token, and receiver.lastPollAgeSeconds is null while the worker never polled', ...);
|
||
it('retry moves FAILED back to PENDING, writes an audit entry and wakes the worker', ...);
|
||
// test/system.e2e-spec.ts
|
||
it('GET /system/notification-deliveries shows FAILED entries and retry moves them back to PENDING', ...);
|
||
it('staff without system:manage gets 403', ...);
|
||
// manifest-policy.test.mjs
|
||
test('only the worker references the apicore-telegram secret, and only optionally', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx jest src/system` und die e2e-Dateien → FAIL; `node --test deploy/k8s/tests/manifest-policy.test.mjs` (mit gerendertem Overlay) → FAIL.
|
||
- [ ] **Schritt 3: Implementieren** wie oben.
|
||
- [ ] **Schritt 4:** → PASS; `kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary -` → 0 Fehler (fehlt `kubectl` lokal, übernimmt der Orchestrator bzw. die CI).
|
||
- [ ] **Schritt 5: Verifikation** – typecheck, lint, `npm test -- --ci`; `test/authorization-coverage.e2e-spec.ts` → PASS; kein Apply gegen den Cluster, kein Commit.
|
||
|
||
---
|
||
|
||
### Task 38: Frontend – Telegram verbinden und Systemkarte
|
||
|
||
**Files** (alle in `ApiCore-Frontend/`)
|
||
- Create: `src/components/auth/step-up-dialog.tsx` (+ Spec), `src/app/(app)/profile/telegram-link-card.tsx` (+ Spec), `src/app/(app)/settings/system/telegram-card.tsx` (+ Spec), `src/lib/telegram/api.ts`, `src/lib/telegram/types.ts`
|
||
- Modify: `src/app/(app)/settings/backup/page.tsx` (+ Spec; nutzt `StepUpDialog` statt eigener Felder), `src/app/(app)/profile/page.tsx`, `src/app/(app)/profile/notification-settings-card.tsx` (Kategorien, „Mit Details“), `src/lib/notification-settings/types.ts`, `src/app/(app)/settings/system/page.tsx`, `src/lib/system/api.ts` + `types.ts`
|
||
|
||
**Interfaces**
|
||
- Consumes: Task 36, 37, Task 21.
|
||
- Produces:
|
||
```ts
|
||
export interface StepUpProof { password: string; mfaCode?: string }
|
||
export interface StepUpDialogProps { open: boolean; title: string; confirmLabel: string; needsMfa: boolean; onConfirm: (proof: StepUpProof) => Promise<void>; onOpenChange: (open: boolean) => void }
|
||
export function createTelegramLinkToken(proof: StepUpProof): Promise<{ deepLink: string; expiresAt: string }>;
|
||
export function unlinkTelegram(): Promise<void>;
|
||
export function fetchNotificationDeliveries(status?: NotificationDeliveryStatus): Promise<{ telegram: TelegramStatusView; deliveries: NotificationDeliveryView[] }>;
|
||
export function retryNotificationDelivery(id: string): Promise<void>; export function retryFailedNotificationDeliveries(): Promise<{ retried: number }>;
|
||
```
|
||
- `TelegramLinkCard` (Spec 9a.8): Status „Verbunden mit „{Vorname}“ seit {Datum}“ oder „Nicht verbunden“; Hauptknopf „Telegram verbinden“ → `StepUpDialog` → großer Knopf „In Telegram öffnen“ (`target="_blank" rel="noopener noreferrer"`) und „Gültig bis {HH:MM}“; Status wird bis zum Ablauf alle 5 s neu geladen; „Trennen“ mit `ConfirmDialog`; Kategorien als `ToggleChip`, Schalter „Mit Details“ mit Datenschutz-Hinweis „Nachrichten laufen über Server von Telegram und sind dort nicht Ende-zu-Ende verschlüsselt.“; ohne Bot „Telegram ist nicht eingerichtet.“ ohne Knopf.
|
||
- Systemkarte „Telegram“ (Spec 9a.9): Bot eingerichtet/Name, Empfang (Lease, Alter des letzten Abrufs, letzter Fehler), Versand (offen, ältestes mit Warnung ab 10 Minuten, `FAILED` 24 h, letzte Fehler), „Erneut versuchen“ einzeln und für alle nur mit `system:manage`.
|
||
|
||
- [ ] **Schritt 1: Failing Tests**
|
||
```tsx
|
||
it('asks for password (and TOTP with MFA) before creating the link', ...);
|
||
it('shows „In Telegram öffnen“ with the expiry time and later „Verbunden mit „Nick““', ...);
|
||
it('„Trennen“ asks and unlinks', ...);
|
||
it('without a bot shows „Telegram ist nicht eingerichtet.“ and no button', ...);
|
||
it('the backup import still asks for the password through StepUpDialog', ...);
|
||
it('the system card warns when the oldest delivery waits longer than 10 minutes and retries with system:manage only', ...);
|
||
```
|
||
- [ ] **Schritt 2:** `npx vitest run src/components/auth "src/app/(app)/profile" "src/app/(app)/settings"` → FAIL.
|
||
- [ ] **Schritt 3: Implementieren.**
|
||
- [ ] **Schritt 4:** → PASS.
|
||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npm test && npm run build`; kein Commit.
|
||
|
||
---
|
||
|
||
### Abschluss T4b + T4c (Orchestrator)
|
||
|
||
- [ ] Backend: `npm run lint && npm run typecheck && npm test && npm run build`; Test-DB migrieren, dann `npm run test:e2e` komplett.
|
||
- [ ] Frontend: `npm run lint && npm run typecheck && npm test && npm run build`.
|
||
- [ ] Drift-Check → 0; Manifeste: `kubectl kustomize deploy/k8s/overlays/encry | kubeconform -strict -summary -` und `node --test deploy/k8s/tests/manifest-policy.test.mjs`.
|
||
- [ ] Wächter grün: `season-enums.spec.ts`, `honey-batch-amount-writes.spec.ts`, `no-network-outside-loops.spec.ts`, `handler-contract.spec.ts`, Handler-IDs Worker = App, Backup-Modell-Wächter, `update-dto-null-handling.spec.ts`, Text-Wächter.
|
||
- [ ] Docs aus den Berichten in `ARCHITECTURE.md` (§7, §8, §9, §10, neue Annahmen mit nächster freier Nummer) und `TASKS.md` (T4b, T4c) eintragen und auf Widersprüche lesen.
|
||
- [ ] Rollout nach Spec 15 (jeder Schritt mit Freigabe des Inhabers): Backup und `pg_dump` vorher; nach Migration 6 die Invarianten-Abfrage (`INVARIANT_SQL`) → 0 Zeilen; `WEATHER_HISTORY_SOURCE=open-meteo` in der ConfigMap; Telegram-Schritte 9.1–9.4 (BotFather, Secret durch den Inhaber, Deploy, Test-Erinnerung).
|
||
- [ ] Ein zentraler Commit, Nachricht endet mit `Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>`.
|
||
|
||
---
|
||
|
||
## Abdeckung der Spec
|
||
|
||
| Spec-Punkt | Tasks |
|
||
|---|---|
|
||
| 1 Entscheidungen: Modell `Suggestion`, ein Eingang, Texte beim Lesen | 1, 4, 5, 17 |
|
||
| 1 Entscheidungen: Regelwerk im Code, Werte in `AutomationRule.config`, Parser | 1, 22 |
|
||
| 1 Entscheidungen: Saison aus Meldung, GTS, Kalender; Worker-Schleife Wetter; Open-Meteo hinter Adapter | 13, 23, 24, 25, 26 |
|
||
| 1 Entscheidungen: Volksstärke `occupiedFrames` | 3, 8, 20 |
|
||
| 1 Entscheidungen: Erinnerungen je Aufgabe und Morgenübersicht, in der App und per Telegram | 3, 16, 21, 34–38 |
|
||
| 1 Entscheidungen: `queenMaxAgeYears = 2` überall | 1, 13, 27 |
|
||
| 1 Entscheidungen: Durchsicht → Volkskarte im selben Request | 8, 20 |
|
||
| 1 Entscheidungen: Bewegungsbuch, Warnungen statt Sperren, Bestätigung Wassergehalt | 12, 28, 29, 30, 33 |
|
||
| 1 Entscheidungen: Fan-out je Nutzer statt `NotificationRead` | 3 |
|
||
| 1 Entscheidungen: Nachzügler `queens`, `extractions`, `honey-batches` auf Ereignisse | 10, 28 |
|
||
| 2.2 Befunde: Rundmeldungen, Königin bleibt zugeordnet, zwei Altersschwellen, Annahme-Nummern | 3, 9, 13, Global Constraints (Docs) |
|
||
| 2.4 Änderungen an T3-Bausteinen | 6, 15, 18, 19, 20 |
|
||
| 4.1 `Suggestion`, `Task.sourceSuggestionId` | 1 |
|
||
| 4.2 `PhenologyReport`, `ApiaryPhenology`, `ApiaryWeatherDay` | 23 |
|
||
| 4.3 `SplitQueenSource`, `SplitColonyDto.date/queenSource` | 2, 10 |
|
||
| 4.4 Futter-Vokabular, `FEED_CONDITION_LABELS` | 2, 20 |
|
||
| 4.5 `sampleBees`, Prozent | 2, 6, 19 |
|
||
| 4.6 Bewegungsbuch-Spalten und CHECK | 23, 29 |
|
||
| 4.7 `Product.shelfLifeMonths` | 23, 30, 33 |
|
||
| 4.8 Benachrichtigungen je Nutzer (`userId`, `dedupeKey`, `category`, `link`, `requiredPermissions`, `recordForUsers`) | 3, 35 |
|
||
| 4.9 Kalender `WITHDRAWAL`, `syncWithdrawal`, `endDate` | 1, 3, 12, 21 |
|
||
| 4.10 Rechte `phenology` | 23, 26 |
|
||
| 4.11 neue Trigger, `DEFAULT_RULES`, Parser, `notify`, `PATCH` 400 | 1, 7, 22 |
|
||
| 4.12 Migrationen 1–11 und Prüfungen | 1, 2, 3, 23, 34; Abschlüsse |
|
||
| 4.13 Backup und Import alter Bundles | 1, 2, 3, 23, 34 |
|
||
| 4.14 Volksstärke | 3, 8, 20 |
|
||
| 4.15 Benachrichtigungskanäle | 3, 34 |
|
||
| 5.1 neue Ereignisse | 10, 23 |
|
||
| 5.2 Handler-Tabelle | 7 (Regeln), 9, 10, 11, 12, 14, 16, 26, 27, 28 |
|
||
| 5.3 Regeln für Handler (Zeilensperre, `skipDuplicates`, beendetes Volk) | 4, 9, 11, 14 |
|
||
| 5.4 Erzeuger-Umstellungen, `releaseQueenIn`, Wächter `after-commit-usage` | 8, 9, 10, 28 |
|
||
| 6.1 Job-Schritte und Zähler | 13, 14, 16, 25 |
|
||
| 6.2 zeitbasierte Ereignisse | 12, 13 |
|
||
| 6.3 Schleife `phenology-weather`, Adapter, Konfiguration | 24 |
|
||
| 7.1 Varroa-Ampel | 6, 7, 19 |
|
||
| 7.2 Fälligkeit der Durchsicht | 13, 14, 15, 18 |
|
||
| 7.3 Pflanzenentwicklung, Saisonabschnitte, Kalibrierung | 13, 25, 26, 31 |
|
||
| 7.4 Biotechnik-Ketten | 10, 11 |
|
||
| 7.5 Wartezeit-Kette | 6 (Fenster-Helfer), 12, 28 |
|
||
| 7.6 Vorbelegung und Plausibilität | 28, 32 |
|
||
| 7.7 Bewegungsbuch | 29, 32 |
|
||
| 7.8 Qualität vor der Abfüllung | 30, 33 |
|
||
| 7.9 Durchsicht → Volkskarte | 8, 20 |
|
||
| 7.10 Volk beendet, Aufräumen | 9, 18 |
|
||
| 8.1–8.3 Autopilot-Eingaben, Regeln A1–A10, Abgleich | 14, 27 |
|
||
| 8.4–8.5 Rangfolge, Zusammenlegen, „die nächsten 3 Schritte“ | 15, 18 |
|
||
| 8.6 Was der Autopilot nicht tut | 14 (nur Vorschläge, keine Fachdaten) |
|
||
| 9.1–9.3 Lebenszyklus, Dubletten, Texte | 4, 5 |
|
||
| 9.4 Meldungen zu Vorschlägen | 4, 7 (Meldung in `SuggestionsService`, gilt für Ketten) |
|
||
| 9.5 bestehende Automatik-Aufgaben, „Aus Vorschlag“ | 7, 17 |
|
||
| 9a.1–9a.4 Erinnerungen, Zustellung anlegen | 16, 35 |
|
||
| 9a.5, 9a.7 Verbinden, Trennen, Long Polling | 36 |
|
||
| 9a.6 Versand | 35 |
|
||
| 9a.8 Einstellungen | 3, 21, 36, 38 |
|
||
| 9a.9 Verwaltung > System | 37, 38 |
|
||
| 9a.10 Konfiguration und Geheimnisse | 34, 37 |
|
||
| 10.1 neue Endpunkte | 3, 5, 6, 9, 15, 26, 28, 29, 30, 36, 37 |
|
||
| 10.2 geänderte Endpunkte | 2, 3, 6, 8, 10, 12, 13, 15, 28, 29, 30 |
|
||
| 10.3 Warnungsformat | 12 |
|
||
| 11 Oberfläche (Seiten, Liste, Bausteine, Barrierefreiheit) | 17–22, 31–33, 38 |
|
||
| 11.2 Ableger-Dialog (Datum, Herkunft der Königin) | 20 (mit Backend aus Task 10) |
|
||
| 12 Sicherheit | Global Constraints; 3, 5, 15, 16, 34–37 |
|
||
| 13 Fehlerfälle | über die Tests der jeweiligen Tasks (Review Focus) |
|
||
| 14.1–14.4 Tests und Wächter | in den Tasks benannt; 14.3 Textvorlagen je vorschlagendem Trigger: 27; `no-broadcast-notifications` samt Wiederherstellungspfad: 3 |
|
||
| 15 Rollout | Abschlüsse T4a sowie T4b + T4c |
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec-Lücken und Widersprüche, hier aufgelöst (bitte beim Review bestätigen)**
|
||
- **Migrationsreihenfolge gegen Lieferungen:** 4.12 ordnet 1–11 global, die Lieferungen verteilen sie. Gelöst: Reihenfolge 4.12 innerhalb jeder Lieferung, feste Zeitstempel in Erzeugungsreihenfolge; Migration 7 (`sampleBees` T4a, `shelfLifeMonths` T4b) und 11 (Kategorie/Einstellungen T4a, Telegram T4c) geteilt; bindende Abhängigkeiten (Enum-Werte zuerst, 8 vor 11) bleiben.
|
||
- **T4b ∥ T4c in einem Arbeitsbaum:** geteiltes `schema.prisma` und die Datenmigration 6 erlauben kein getrenntes Ausrollen. Gelöst: parallel bauen, gemeinsam ausrollen; soll T4c früher live, läuft die Lane 34–38 komplett vor Task 23 und die Zeitstempel-Blöcke tauschen (T4c `20261015100000`, T4b ab `20261015110000`).
|
||
- **Saison ohne GTS in T4a, Prisma-Enums erst in Migration 4 (T4b):** T4a nutzt TS-Konstanten (`season-types.ts`), Task 23 legt gleichnamige Prisma-Enums an, ein Wächter hält sie gleich.
|
||
- **Wartezeit-Fenster:** 7.5 nennt `(endDate ?? date)` als Beginn; der Bestand und Annahme 10 zählen ab Behandlungsbeginn (eine Ernte während der Behandlung ist ein Konflikt). Gelöst: Beginn = Behandlungsbeginn wie in `getWithdrawalStatus`, Ende exklusiv `(endDate ?? date) + Tage`.
|
||
- **„Bis 14 Tage nach Behandlung“ (7.1) gegen „Nachkontrolle ab 16.08.“ (A3):** gleich gemacht – keine Ampel bis vor `Ende + 14`, Nachkontrolle ab `Ende + 14`.
|
||
- **Zusammenlegen INSPECT (DO) mit SWARM_CHECK (PLAN) (8.4), aber der Abgleich fasst Kettenvorschläge nicht an (8.3):** Gelöst: zusammengelegt wird nur mit mindestens einem DO-Schritt, Aktion `CAPTURE`; `chains.queen-cells` setzt offene `SWARM_CHECK`/`QUEEN_ACCEPTANCE`-Kettenvorschläge bei einer Durchsicht am oder nach ihrer Fälligkeit auf `RESOLVED`.
|
||
- **Wächter „`fetch(` nur in `src/weather/` und `src/telegram/`“** widerspricht dem Bestand (Graph-Client, HIBP-Prüfung). Gelöst: diese zwei Dateien stehen auf der Erlaubtliste; Handler und Jobs bleiben ohne Netz.
|
||
- **`after-commit-usage`: „Liste verliert `queens`“** – `queen.created` bleibt Altpfad ohne Ereignis. Gelöst: `queens.service.ts` bleibt in der `webhooks.dispatch`-Liste, die `calendarSync.enqueue`-Liste wird leer.
|
||
- **`ColonyEnded` kennt die freigegebene Königin nicht:** Payload um `endedOn`, `releasedQueenId`, `via` erweitert (Spec 5.1 erlaubt Erweiterungen).
|
||
- **`requiredPermission` ist ein einzelnes Recht:** Gründe nennen nur Daten aus diesem Bereich (z. B. A4 ohne Erntedatum, Text „Noch keine Behandlung seit …“).
|
||
- **Projektregel Zeilensperre (nachgereicht 2026-10-14):** Jedes Read-modify-write liest `before` nach `lockRowForUpdate` in der Transaktion; eingearbeitet in Tasks 1, 3, 4, 6, 8, 9, 10, 14, 25, 26, 28, 29, 36, 37; neue Tabellen nur über Task 1 (T4a), 23 (T4b), 34 (T4c) in die Map; feste Sperr-Reihenfolge in Global Constraints. Die Spec nennt an zwei Stellen rohes `SELECT … FOR UPDATE` (5.3, 7.7) – beide laufen jetzt über den Helfer.
|
||
- **Nicht spezifiziert, hier festgelegt:** Empfänger der Konflikt-Meldung (`harvests:read` + `treatments:read`); „erntefähig“ zusätzlich nur mit `treatments:read` (Inhalt nennt das Mittel); Anker für Lagerregeln (jüngste Zugangsbuchung bzw. jüngste Charge); `TREATMENT_FOLLOW_UP`/`FEEDING_FOLLOW_UP` werden `FOLLOW_UP`-Vorschläge mit unveränderter Config; 401 von Telegram pausiert 15 min; `/start` ohne Token → `HELP`; `link-token` nicht `@Idempotent()` (Geheimnis in der Antwort, Annahme 11); Validierungsgrenzen der Configs (Global Constraints); `calibratePhases` als Config-Schlüssel für „auf Wunsch SALWEIDE“.
|
||
|
||
**Nachgereicht nach dem Plan-Review (2026-10-14)**
|
||
- **Fenster-Helfer früh:** `src/treatments/withdrawal-window.ts` (+ `treatment-windows.ts`) wandert von Task 12 (W5) in Task 6 (W3). Task 15 (W5) lag sonst in derselben Welle wie sein Lieferant, und Task 7 (W4) braucht dadurch keinen `VarroaBandsService`-Export.
|
||
- **`DailyMean`** steht in `season-types.ts` (Task 1), nicht in Task 24; Task 25 (gleiche Welle) importiert von dort. Task 37 führt Task 36 nicht mehr unter Consumes (gleiche Welle).
|
||
- **Telegram „eingerichtet“ auf der API-Seite** = `TELEGRAM_BOT_USERNAME` nicht leer (`telegramBotAvailable()`), nie das Token (das gibt es nur im Worker).
|
||
- **`no-broadcast-notifications`** erlaubt den Wiederherstellungspfad (`backup.service.ts`), prüft aber, dass jede Schreibstelle `userId` trägt; die Wiederherstellung überspringt Zeilen unbekannter Nutzer, statt `userId = null` zu schreiben.
|
||
- **Migration 11a** belegt Altzeilen mit `relatedType 'JobRun'` oder ohne `relatedType` als `SYSTEM`, den Rest als `SUGGESTION`; dieselbe Regel im Backup-Import.
|
||
- **Aufräumen erledigt Aufgaben** über die öffentliche `TasksService.completeIn` (Audit + `TaskCompleted`), nicht über das private `markDoneIn`.
|
||
- **`dedupeKey`** bleibt nur bei `OPEN`, `ACCEPTED`, `DISMISSED` und `OBSOLETE`; `EXPIRED` und `RESOLVED` setzen ihn auf `NULL`, damit Erinnerungen nach 30 Tagen wiederkommen.
|
||
- **Sperren und Meldungen:** `proposeIn` sperrt erst `Colony`, dann die Regelzeile; die Meldung zu Vorschlägen sitzt in `SuggestionsService` (Task 7) und gilt für alle Quellen; Telegram prüft Rechte beim Senden frisch (`getFreshPermissions`); der Offset-Fortschritt ist ein Compare-and-Set mit `leaseOwner`; der 403-Pfad löscht nur den Link desselben Chats.
|
||
- **Task 15/17:** Vorschlags-Schritte brauchen `tasks:read`. **Task 5:** `assignedUserId` wird validiert und auf „existiert und aktiv“ geprüft.
|
||
- **Übertrag aus Rückgrat Task 18:** `QueenAged` einmal je Königin und Jahr am ersten Jobtag mit gealterter Königin (Jahresanker im `dedupeKey`); Winter-Fälligkeiten kommen über „Erste Durchsicht im Jahr“ zu Frühjahrsbeginn (A1), keine eigene Märzmeldung.
|
||
- **Ableger-Dialog (Spec 11.2)** in Task 20; **Aufräum-Job** löscht alte `TelegramLinkToken` und `NotificationDelivery` (Task 35); **Migrations-Harness** nutzt `.tmp-migrations/` statt OS-Temp (Task 2).
|
||
|
||
**Geprüft**
|
||
- Jeder Spec-Abschnitt hat einen Task (Tabelle oben). Kein Task enthält „TBD“ oder unbestimmte Schritte; Funktionsrümpfe stehen nur dort, wo Signatur und Test sie nicht festlegen (SQL-Kerne, Formeln).
|
||
- Typen und Namen über die Tasks gleich: `ProposeInput`, `SuggestionEvidence`, `PlanStepConfig`, `SeasonState`, `readRule`/`lockRuleIn`, `PlausibilityWarning`, `WithdrawalWindow` (`fromDay`/`untilDay`), `NextStep`/`ColonyNextSteps`, `AutopilotToday`, `CreateNotificationInput`/`FanOutNotificationInput`, `recordForPermission(permission | permissions[], …)`, `DailyEvaluationStep`, `JobPartialFailure`, `deferClaim`/`settleSkipped`, `TelegramApiError`. Aufgaben-Bezüge stimmen mit den Interfaces-Blöcken (Consumes) überein.
|
||
- Jeder Task schließt mit Verifikation und „kein Commit“; Doku-Texte gehen über den Bericht an den Orchestrator.
|
||
- Geteilte Dateien je Welle höchstens einmal (Liste unter „Abhängigkeiten und Parallelisierung“). Paare, die nur durch Wellen getrennt sind: Tasks 1/2/3/23/34 (`schema.prisma`, Backup), 1/23/34 (`row-lock.ts`), 4/14/12/16/25/35 (`app.module.ts`, `worker.module.ts`), 13/14/16/25 (`jobs.module.ts`, `daily-evaluation.job.ts`), 1/2/3/7 (`automation.service.ts`), 10/8/9 (`colonies.service.ts`), 10/9/23 (`domain-event-types.ts`), 3/8/26/30 (`update-dto-null-handling.spec.ts`), 3/8/15 (`apiary-field.service.ts`), 13/3 (`job-run.service.ts`), 4/5/11/9 (`suggestions.module.ts`), 5/9 (`suggestions.controller.ts`), 1/3/12 (`calendar-sync.service.ts`), 10/12 (`calendar-sync.handlers.ts`), 12/29/30 (`bottling.service.ts`), 28/29/30 (`honey-batches.service.ts`), 10/28 (`webhook.handlers.ts`), 10/12/28 (`after-commit-usage.spec.ts`), 12/28 (`withdrawal.handlers.ts`), 14/15/27 (`src/autopilot/*`), 13/25 (`season.service.ts`), 3/35 (`notifications.service.ts`), 1/30 (`dates.ts`), 24/35/36 (`worker-runtime.service.ts`), 24/37 (`.env.example`, `deploy/k8s`), Frontend 20/18 (Volksseite), 17/18 (`suggestion-card.tsx`), 21/38 (Profilseite), 18/31 (Stand-Seite).
|
||
- Proportion: Code steht nur als Signaturen, Testnamen mit Kern-Assertions und gepinnte SQL-Fragmente.
|