Neues README im Root mit Zweck, Aufbau, Start, Prüfbefehlen, Betrieb und Projektstand (Issues #44, #43). Backend- und Frontend-README ersetzen die Framework-Vorlagen. CLAUDE.md: Ordnerstruktur und echte Arbeitsweise (main, gebündelt pushen, Rollout nur mit Freigabe). ARCHITECTURE §7/§8: Live-ConfigMap, ensure-secrets.sh, korrektes Admin-Seed-Verhalten. .env.example um BACKUP_MAX_IMPORT_BYTES und SEED_ADMIN_* ergänzt. Statuszeilen in Plänen Rückgrat, T3–T6 und Specs Fahrplan, T7. Refs #42 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
257 KiB
T4 Logik-Ketten und Saison-Autopilot Implementation Plan
Status (2026-10-16): T4a (Tasks 1–22), T4b (23–33) und T4c (34–38) umgesetzt und gemergt (Wellen 1–11, zuletzt 9760ce1, Browser-Fixes 7a14801). Nicht ausgerollt. Issues: #15, Folgepunkt packagingSize #36, Rollout #28.
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, privatesmarkDoneInmit privatemrecordCompleted),lockRowForUpdate(liefertPromise<boolean>),lockRowsForUpdate,lockRowsForShare(src/prisma/row-lock.ts;LOCKABLE_TABLESenthält schonColony,Task,BottlingBatch,Feeding,Harvest,Inspection,Product,ProductionBatch,RawMaterialLot,StockItem,Treatment,VarroaMeasurement),runBulkundassertLivingColonies(src/common/bulk/), die RegeltabelleDAILY_EVALUATION_RULESmitcatchUpEvents/inCatchUpWindow(src/jobs/daily-evaluation.rules.ts),JobRunService.run(src/system/job-run.service.ts),DomainEventsService.record/recordAll(tx, …),@OnDomainEvent(types, '<consumer>.<name>'),HANDLER_ID_PATTERN,dailyDedupeKeyundutcDay(beidesrc/domain-events/domain-event-types.ts),JobRunService.run,DailyEvaluationJobmitCATCH_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 inWorkerRuntime,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, FrontendPreviousValueHint,NumberStepper,StatusLightBadge,colonyStatusLight,startRound,useCaptureMutation, Warteschlangesrc/lib/offline/db.ts(Version 3),takeover.ts,varroaLight,fetchVarroaThreshold,nav-config.ts. - Alle Backend-Pfade relativ zu
ApiCore-Backend/, alle Frontend-Pfade relativ zuApiCore-Frontend/, sofern nicht anders angegeben.deploy/**unddocs/**liegen im Repo-WurzelverzeichnisApiCore/(Befehle mitdeploy/…laufen dort, nicht inApiCore-Backend/).
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 --scriptgegen die Test-DB, danach von Hand nachgearbeitet (Kopfkommentar wie20261012100000_calendar_sync_claims). Niedb push, niemigrate dev. - Bindend bleibt aus 4.12: Enum-Werte in eigener Migration vor jeder Nutzung; 8 vor 11;
categoryder Altzeilen wird belegt, danachNOT 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 alsYYYY-MM-DD; gespeichert alsDateTimeMitternacht UTC (dayToDate), wieTask.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 gelesenenSELECT now()berechnet (berlinDateTime). - Job
daily-evaluation04:30 Europe/Berlin. Schritte in fester Reihenfolgephenology→time-events→autopilot→suggestions-expiry→digest, jeder in eigenen Transaktionen; ein gescheiterter Schritt hält die anderen nicht auf,JobRunist dannFAILEDmit 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, sonstINTENT_OF_LEGACY_TRIGGER[sourceAutomationTrigger], sonstFOLLOW_UP. - Ein Vorschlag mit vorhandenem
dedupeKeyentsteht nie neu. Den Schlüssel behaltenOPEN,ACCEPTED,DISMISSEDundOBSOLETE;EXPIREDundRESOLVEDsetzendedupeKey = 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 nachEXPIRED/RESOLVEDdarf 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.).actionTemplateeiner Regel ersetzt nur den Titel, wenn es nicht leer ist. - Jeder Übergang ist ein Compare-and-Set (
updateMany … where status = 'OPEN'bzw.'DISMISSED') mit AuditentityType: 'Suggestion'in derselben Transaktion. Fehlschlag → 409{ message: 'Der Vorschlag ist nicht mehr offen.', status }. - Art: Regel- und Kettenvorschläge sind
PLAN;CLEANUPistDO; Autopilot A1, A2, A3, A5, A6, A7, A9, A10DO, A4 und A8PLAN. - 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):
dueInDays0–365; Varroa-Werte endlich ≥ 0,yellowFrom ≤ redAbove, Monate 1–12 eindeutig,youngColonyFactor(0, 1],recheckAfterDays0–60; Wassergehalt 10–30,warn ≤ confirm;honeySuperMinFrames1–40;varroaMeasureIntervalDays7–90;queenMaxAgeYears1–10;suggestionTtlDays1–365;maxStepsPerColony1–3;MM-DDgültig, Kalender-Rückfälle in Phasenreihenfolge nicht fallend;swarmSeason.fromvoruntil;maxGapShare0–0,5; GTS-Schwellen > 0; Plan-SchritteoffsetDays0–120,windowDays0–30. PATCH /automation/rules/:idmit 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, gelbWert ≥ 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. ProbesampleBees1–2000; beiPOWDERED_SUGAR/WASHING_METHODistcalculatedValue = miteCount / sampleBees × 100. - Durchsicht fällig (Spec 7.2):
inspectionDueOn= letzte Durchsicht (sonstcolony.createdAt) + Intervall des heutigen Abschnitts; liegt die letzte Durchsicht vor dem Beginn vonFRUEHJAHR, ist das Volk ab Abschnittsbeginn fällig („Erste Durchsicht im Jahr“); inWINTERRUHEnull. A1 abinspectionDueOn ≤ 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(abWINTERoder vorSALWEIDE),FRUEHJAHR(abSALWEIDE),SCHWARMZEIT(abswarmSeason.from),SOMMER(abswarmSeason.until, StandardLINDE),SPAETSOMMER(abTRACHTENDE),HERBST(abHERBST). - Ketten (Spec 7.4): Weiselzellen →
SWARM_CHECK+7; Königin zugesetzt →QUEEN_ACCEPTANCE+7; AblegerRAISE_OWN→SPLIT_PLANmit +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 vordate, sonstdate) bis ausschließlich(endDate ?? date) + withdrawalPeriodDays(= erster erntefähiger Tag =WithdrawalEnded.endedOn). OhnewithdrawalPeriodDayskein Fenster, keine Warnung. - Alter der Königin: gealtert, wenn
aktuelles Jahr − hatchYear ≥ queenMaxAgeYears(2). Gilt fürQueenAged, DashboardOLD_QUEENund A8;OLD_QUEEN_AGE_YEARSentfällt. - Volksstärke:
Colony.occupiedFramesundInspection.colonyStrength0–40 besetzte Waben, 400 „Höchstens 40 besetzte Waben.“;Colony.strength(1–10) bleibt unverändert, Swaggerdeprecated, nie umgerechnet. - Futter:
FEED_CONDITION_LABELSEXCELLENT„Reichlich“,GOOD„Ausreichend“,AVERAGE„Mittel“,WEAK„Niedrig“,CRITICAL„Kritisch“; Altwerteausreichend→GOOD,niedrig→WEAK,kritisch→CRITICAL(getrimmt, ohne Groß-/Kleinschreibung), leer →null, sonstnull+ „Futter (alt): {Text}“ am Ende vonnotes. - Honig: Warnungen in einem Format (
PlausibilityWarning, Spec 10.3) mit Stufe je Code:WITHDRAWAL_ACTIVEWARNUNG,EXTRACTION_EXCEEDS_HARVESTWARNUNG,HARVEST_NET_MISSINGHINWEIS,BATCHES_EXCEED_EXTRACTIONWARNUNG,WATER_CONTENT_HIGHWARNUNG,WATER_CONTENT_ABOVE_LEGALBESTAETIGEN,WATER_CONTENT_MISSINGHINWEIS,JAR_SIZE_DIFFERSHINWEIS. Nie 4xx außerPOST /bottlingüber der gesetzlichen Grenze ohneacknowledgedWarnings→ 409{ code: 'CONFIRMATION_REQUIRED', warnings }. - Bewegungsbuch:
quantityin 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 (
userIdPflicht). Geschrieben wird nur an aktive Nutzer mit aktiver Rolle, die jedes Recht ausrequiredPermissionshalten; gespeicherterequiredPermissions= 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:
NotificationPreferenceStandardtaskDueEnabled true,digestEnabled true,digestTime '06:30'(05:00–12:00, sonst 400 „Bitte eine Uhrzeit zwischen 05:00 und 12:00 wählen.“), Ruhezeit21:00–06:30(nur paarweise leerbar),telegramEnabled false,telegramCategories [TASK_DUE, DIGEST],telegramDetail true. Fehlt die Zeile, gelten die Standards.
Telegram
TELEGRAM_BOT_TOKENnur im Worker (Secretapicore-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 dorttelegramBotAvailable()=TELEGRAM_BOT_USERNAMEnicht leer (Link-Token 409,NotificationSettingsView.telegram.configured,TelegramStatusView.configured). Ob der Empfang tatsächlich läuft, zeigt nurTelegramPollState(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, Weckkanalnotification_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 ohneparse_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 ansystem:manageeinmal je Tag; 5xx/Timeout/Netz →retryDelayMs, nach 8 VersuchenFAILED. Veraltet:TASK_DUE/DIGESTnur 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:getUpdatesmittimeout=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 + 1in 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(Standardopen-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). Nurhttps:, nur aus der Konfiguration. Schlüssel und URL nie in Logs oderlastError.- Schleife
phenology-weather: 3 600 000 ms, kein Weckkanal; Koordinaten auf 0,01° gerundet, gleiche Punkte teilen eine Anfrage; letzte 90 Tage über Forecast mitpast_days=92, ältere Lücken über Archiv; Timeout 8 s; Antwort ≤ 1 000 000 Byte; Werte außerhalb −50…50 °C →null.cleanuplöschtApiaryWeatherDayvor 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-
txals erster Parameter, optionalertxals 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), liestbeforeinnerhalb der Transaktion nachlockRowForUpdate(tx, table, id)aussrc/prisma/row-lock.ts– nie aus einem Lesen vor der Transaktion. Neue Tabellen kommen in die feste Bezeichner-MapLOCKABLE_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 ReihenfolgeColony→Suggestion→Task→Queen→AutomationRule→ übrige Tabellen in Map-Reihenfolge, innerhalb einer Tabelle Schlüssel aufsteigend (die Map steht in genau dieser Reihenfolge). Mehrere Zeilen einer Tabelle sperrtlockRowsForUpdate(tx, table, ids)(eine Anweisung, Schlüssel aufsteigend), nie eine Schleife überlockRowForUpdate. 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.proposeInsperrt zuerst die Volkszeile (lockRowForUpdate(tx, 'Colony', colonyId), wenncolonyIdgesetzt;syncColonyInund der Handlersuggestions.colony-endedhalten 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 (ColonyvorAutomationRule). Kein Pfad sperrt erstAutomationRuleund dannColony;accept,dismiss,reopenundapply-cleanupsperrenSuggestion→Task→Queenund nieColony. - 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 vonAppModuleundWorkerModuleimportiert. Payloads undevidencetragen nur IDs, Enum-Werte, Zahlen, Tage. - Rechte-Auflösung in Worker und Jobs:
PermissionResolverServicekommt in der API aus dem globalenPermissionsModule, im Worker und in den Jobs aus dem globalen, resolver-onlyJobPermissionsModule(src/jobs/jobs.module.ts; Task 1 exportiert die Klasse,WorkerModuleimportiert sie ab Task 4,JobsModulehat sie schon). Kein Feature-Modul importiertPermissionsModule(seinPermissionCatalogSyncServiceschriebe den Katalog bei jedem Worker- und Job-Start neu) und keines deklariert den Resolver selbst; Module mit Handlern oder Schritten (Suggestions, Autopilot, Withdrawal, Reminders, Telegram) injizieren ihn einfach. - 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-Dateinpx 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); Checksnpm run lint && npm run typecheck && npx vitest run <pfade>. Diagramme: vorher dendataviz-Skill laden. ARCHITECTURE.mdundTASKS.mdträ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
- Rechte-Leck über Vorschläge, Schritte, Übersicht und Meldungen. Wer kein
varroa:readhat, 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
- Task 5 e2e
- 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
- Task 4 unit
- 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
- Task 2 e2e
- 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
- Task 34 unit
- 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
- Task 1 unit
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 nursrc/app.module.ts,src/worker/worker.module.ts: 4 (W2; Worker importiert dort auchJobPermissionsModule), 14 (W4), 12 (W5), 16 (W6), 25 (W8), 35 (W9, nur Worker)src/jobs/jobs.module.ts: 1 (W1, nurexportvonJobPermissionsModule), 13 (W2), 14 (W4), 16 (W6), 25 (W8)src/jobs/daily-evaluation.job.ts: 13 (W2), 14 (W4), 16 (W6), 25 (W8);src/jobs/daily-evaluation.rules.ts: 13 (W2), 6 (W3)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); Task 12 fasst weder sie nochdaily-evaluation.rules.tsansrc/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/mitrule-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_RULESausdefault-rules.ts;updatevalidiert und liestbeforeunter Sperre),src/automation/varroa-threshold.ts(+ Spec),src/calendar-sync/calendar-sync.service.ts(WITHDRAWALvorläufig aus),src/backup/backup.service.ts(+ Spec),src/prisma/row-lock.ts(+ Spec; T4a-Tabellen, Schlüsselspalte je Eintrag, auch fürlockRowsForUpdate/lockRowsForShare),src/jobs/jobs.module.ts(nurJobPermissionsModulewirdexportiert, damitWorkerModuleihn ab Task 4 importieren kann) - Test:
test/automation.e2e-spec.ts, neutest/t4-schema.e2e-spec.ts - Docs: ARCHITECTURE §8 „Export/Import“:
suggestionist Teil des Bundles (Import vortask).
Interfaces
- Consumes: –
- Produces:
// 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
// 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
// (BottlingBatch, Feeding, Harvest, Inspection, Product, ProductionBatch, RawMaterialLot, StockItem, Treatment, VarroaMeasurement), NotificationPreference
// lockRowForUpdate, lockRowsForUpdate und lockRowsForShare wählen und sortieren nach der Schlüsselspalte des Eintrags (SELECT 1 … ORDER BY <key>), nicht mehr fest nach "id"
export function lockRowForUpdate(tx: Prisma.TransactionClient, table: LockableTable, id: string): Promise<boolean>; // Signatur unverändert (true = Zeile existiert)
- Schritt 1: Failing Tests
// 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; gilt auch für lockRowsForUpdate und lockRowsForShare
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 listetSuggestion); e2e-Dateien → FAIL. - Schritt 3: Implementieren
- Migration 1 enthält nur
ALTER TYPE … ADD VALUE(9 + 1 + 1). Migration 2: Enums, TabelleSuggestionmit den drei Indizes aus Spec 4.1,Task.sourceSuggestionIdmit FK und Index. berlinToday/berlinDayOfüberIntl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year, month, day });berlinDateTimebestimmt den Offset am Zieltag (zweimal anwenden, damit der Umstellungstag stimmt).- Parser:
parse-helpers.tsmitintIn,numberIn,bool,monthDay,months,enumList;parsefällt je Schlüssel auf den Standard zurück und loggt eine Warnung mit Trigger und Feld (ohne Wert);validatemeldet nur vorhandene bekannte Schlüssel. automation.service.ts:DEFAULT_RULESimportieren;updateläuft in einer Transaktion:lockRowForUpdate(tx, 'AutomationRule', id),beforelesen (404 wie bisher), bei einem ObjektvalidateRuleConfig(before.triggerType, dto.config)(Issues →BadRequestException({ message: 'Die Einstellung ist ungültig.', issues })), schreiben, Audit mittx.row-lock.ts: Einträge mit Schlüsselspalte, neue Tabellen laut Interfaces (die zwölf bestehenden bleiben,Suggestionkommt hinterColony,QueenundAutomationRulehinterTask); alle drei Helfer lesen die Schlüsselspalte aus dem Eintrag; bestehende Aufrufer unverändert.jobs.module.ts:class JobPermissionsModule→export class JobPermissionsModule(sonst nichts).varroaThresholdOfliestruleConfigOf('VARROA_THRESHOLD', config).naturalFall.redAbove; Signatur bleibt (Task 7 löscht die Datei).calendar-sync.service.ts:WITHDRAWAL: falseim Schalter-Record undcase 'WITHDRAWAL': return null;mit Kommentar „Schalter Task 3, Inhalt Task 12“.- Backup:
suggestionin Export und Import, Import vortask,decidedByUserIdwie andere Nutzer-IDs aufgelöst.
- Migration 1 enthält nur
- 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
- typecheck, lint,
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(feedStatusalsColonyCondition),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, Webhookinspection.createdträgtfeedStatuskünftig alsGOOD/WEAK/CRITICAL– auch für den Changelog).
Interfaces
- Consumes: Task 1.
- Produces:
// 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?
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
// 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/exportund 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 perRAISE NOTICEin einemDO-Block, alte Spalte löschen, neue umbenennen. - Harness: legt Schema
mig_<random>an, kopiert die Migrationsordner vor dem Ziel samtmigration_lock.tomlin.tmp-migrations/<random>/unterApiCore-Backend/(git-ignoriert, nicht im OS-Temp, damitprisma.config.tsdas installierteprisma-Paket auflöst), schreibt dort eineprisma.config.ts(schemaabsolut,migrations.path= dieses Verzeichnis,datasource.url= Test-URL mit?schema=mig_…) und ruftnpx prisma migrate deploy --config .tmp-migrations/<random>/prisma.config.ts;queryüberpgmitSET search_path;migrateThroughkopiert bis einschließlich Ziel und ruft erneutdeploy;droplöscht Schema und das Verzeichnis unter.tmp-migrations/. - Regel
FEED_STATUS_CRITICALprüftinspection.feedStatus === 'CRITICAL'. - Stockkarte (PDF) und CSV-Export drucken
FEED_CONDITION_LABELS_DE; CSV-Import und Backup-Import nutzenfeedConditionFromLegacy+appendLegacyFeedNote.
- Migration 3 von Hand: neue Spalte
- 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
- typecheck, lint,
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/(jemigration.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;updatebildet die Felder von Hand ab,syncWithdrawalfehlt 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:
NotificationPreferenceim Backup.
Interfaces
- Consumes: Task 1, 2.
- Produces:
// 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
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
// 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/backupund 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 } } } })) } } }); danachnotification.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 AnweisungupdateMany({ where: { id, userId } }),count 0→ 404; kein vorheriges Lesen). NotificationPreferencesService.updatein 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.- Bestand:
record(bisheruserId?, liefertNotification) undrecordForPermission(bisher einPermissionKey, liefert die Zahl) bekommen die Signaturen oben; die zwei Aufruferautomation.service.ts(Rundmeldung überrecordohneuserId) undjob-run.service.ts(notifyFailure) werden in diesem Task angepasst, ihre Specs undfindAll/markRead(bisherForbiddenfür fremde Meldungen, jetzt 404) ebenso. InnotifyFailurebleiben Advisory-Lock und Tagesprüfung bestehen; sie sind ein zusätzlicher Schutz vor demdedupeKey. automation.service.ts: die bisherige Rundmeldung wirdrecordForPermission('tasks:read', { category: 'SUGGESTION', … }, tx)(Task 7 ersetzt den Pfad).apiary-field.service.ts: Meldungszählung nuruserIddes Aufrufers (Task 15 stellt auf Vorschläge um).calendar-sync.service.ts:WITHDRAWAL: settings.syncWithdrawal; DTO-FeldsyncWithdrawalmit@IsOptionalNotNull() @IsBoolean().calendar-sync-settings.service.ts:updatebildet die Felder von Hand ab (data: { enabled: dto.enabled, … syncQueenAssignment: dto.syncQueenAssignment }), dortsyncWithdrawal: dto.syncWithdrawalergänzen; sonst bliebe der Schalter wirkungslos.UpdateNotificationSettingsDto:taskDueEnabled,digestEnabled,telegramDetail(@IsOptionalNotNull() @IsBoolean());digestTime(@IsOptionalNotNull(),HH:MM, 05:00–12:00);quietFrom/quietUntil(HH:MModernull; nach dem Zusammenführen beide gesetzt oder beidenull, sonst 400 „Bitte Beginn und Ende der Ruhezeit zusammen leeren.“);telegramCategories(@IsOptionalNotNull() @IsArray() @ArrayUnique() @ArrayMaxSize(6) @IsEnum(NotificationCategory, { each: true })).telegramEnabledist nicht änderbar (setzen Verbinden/Trennen, Task 36).- Backup:
notificationPreferencegesichert; Import alter Bundles:NotificationohneuserIdje aktivem Nutzer kopieren (wie Migration 8), ohnecategorywie Migration 11a (relatedType 'JobRun'oder ohnerelatedType→SYSTEM, sonstSUGGESTION). Die Wiederherstellung (tx.notification.upsertinbackup.service.ts) bleibt als einziger Schreibpfad außerhalb desNotificationsService, schreibtuserIdaber immer: aufgelöst überresolveOptionalUserId; ist der Nutzer im Zielsystem unbekannt, wird die Zeile übersprungen und gezählt (userIdist nach Migration 8 Pflicht, nienullschreiben). - 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).
- Empfänger in einer Abfrage:
- 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
- typecheck, lint,
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(importiertSuggestionsModuleundJobPermissionsModuleaus../jobs/jobs.module: Task 5 injiziert den Resolver inSuggestionViewsService, und der Worker hat sonst keinen) - 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:
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
// 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 gesetztercolonyIdzuerstlockRowForUpdate(tx, 'Colony', colonyId)(serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, damit die Absichts-Prüfung über Regeln hinweg hält), dannlockRuleIn(tx, ruleKey);openIntentsInprüft offene Vorschläge (gleichecolonyId, sonst gleicherelatedType+relatedId) und offene Aufgaben (OPEN/IN_PROGRESS; Absicht nach Global Constraints); dannsuggestion.createManyAndReturn({ data: [row], skipDuplicates: true })und AuditSuggestion CREATEmittx.accept,dismiss,reopenund die Bulk-Varianten je in einer Transaktion:lockRowForUpdate(tx, 'Suggestion', id)(Bulk:lockRowsForUpdate(tx, 'Suggestion', ids)),beforelesen, Status prüfen (sonst 409 mit aktuellem Status), CAS-Update, Audit mitbeforeData/afterData.accept: CASOPEN → ACCEPTED(decidedAt = now(),decidedByUserId), dann Aufgaben übertasks.createInTx– Titel ausrenderSuggestion(mitactionTemplateder Regel), Beschreibung = Gründe,priority,relatedType/relatedId,sourceSuggestionId,sourceAutomationTrigger = ruleKey,sourceInspectionId = evidence.inspectionIdfalls vorhanden. PLAN mitplan: je SchrittbaseDate + offsetDays, TitelPLAN_STEP_TITLES[step], beiwindowDaysZusatz „bis {TT.MM.}“. Mitinput.dueDatewird die Basis so verschoben, dass der erste Schritt auf diesem Tag liegt. Ein PLAN ohneplanund ohnedueDate(Regelvorschlag ohnedueInDays, Spec 1) legt die Aufgabe auf heute (berlinToday()).resolveInundexpireDuesetzen"dedupeKey" = NULLim selben Statement wie den Statuswechsel;accept,dismiss,reopenundobsoleteForColonyInlassen den Schlüssel stehen.SuggestionsModuleimportiertTasksModule,AuditModule,NotificationsModule(Task 7 schreibt darüber die Meldung zu neuen Vorschlägen), exportiertSuggestionsService; inAppModuleundWorkerModuleimportiert.WorkerModuleimportiert zusätzlich das globaleJobPermissionsModule(Global Constraints, Rechte-Auflösung);test/worker-runtime.e2e-spec.tsstartetWorkerModuleund deckt fehlende Abhängigkeiten auf.
- 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
- typecheck, lint,
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:
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=undGET /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)) }–acceptprü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
// 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/suggestionsundtest/suggestions.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren wie in Interfaces; Rechte über
PermissionResolverService.getPermissions;apiaryId-Filter übercolony.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(RouteGET /varroa-measurements/thresholdentfä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, neutest/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:
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
// 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/jobsund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
bandsFor: Monat ausday; natürlicher Milbenfall inseasonMonths→{ yellowFrom, redAbove }, sonst{ yellowFrom: null, redAbove: winterRedAbove }; Jungvolk: alle Werte des Milbenfalls ×youngColonyFactor; Prozent-Bänder nur insummerMonths/autumnMonths.postTreatmentUntil=endDay + recheckAfterDaysder jüngsten Behandlung mitstartDay ≤ day < endDay + recheckAfterDays.calculatedValuebeiPOWDERED_SUGAR/WASHING_METHODmitsampleBeesüber den vorhandenen Ableitungs-Helfer (gleiche Rundung; ein manueller Wert gewinnt wie bisher).updateleitet aus dem Vorzustand ab:lockRowForUpdate(tx, 'VarroaMeasurement', id),beforein der Transaktion lesen.withdrawal-window.ts:TreatmentsService.getWithdrawalStatusundwithdrawalEndDay(daily-evaluation.rules.ts) rechnen überwithdrawalWindowOf; 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ürsampleBees: Eintrag inupdate-dto-null-handling.spec.tsmacht 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(importiertSuggestionsModule),src/suggestions/texts/rule-texts.ts,src/suggestions/suggestions.service.ts(+ Spec;notifyinproposeIn) - 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,treatmentWindowsInaussrc/treatments/treatment-windows.ts– eine Funktion, kein Export desVarroaBandsService, kein Import vonVarroaModule); Rückgrat Task 10 (Handler und…In-Methoden). - Produces:
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ätHIGHwie die bisherigen Automatik-Aufgaben,requiredPermissionnach Global Constraints;dueDateausconfig.dueInDaysab dem fachlichen Datum, sonstnull. -
Die Meldung (Spec 9.4) sitzt in
SuggestionsService.proposeIn(nicht inAutomationService), damit sie für alle Quellen gilt, auch für Kettenvorschläge (Task 11):ProposeInput.notify(Regeln und Ketten übergebenconfig.notify, der Autopilot nie),proposeForRuleInreichtnotify: rule.config.notifydurch. Bei neu eingefügter Zeile undnotify: Titel überrenderSuggestionmitbuildEvidenceLookup(tx, [evidence]), dannrecordForPermission(['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). AuditAutomationRule CREATEmitsuggestionIdwie bisher mittaskId. -
Schritt 1: Failing Tests
// 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/automationund die genannten e2e-Dateien → FAIL. - Schritt 3: Implementieren
- Alle Regel-Pfade (Handler und die Altpfade Schwarmkontrolle und Lagerartikel) rufen
proposeForRuleIn;tasks.createInTxund die Rundmeldung entfallen in der Automatik. - Varroa: Regel aktiv →
rateMeasurement(…)mit den Fenstern austreatmentWindowsIn(tx, colonyId)(Task 6); nurlight === 'red'schlägt vor.varroa-threshold.tsentfällt. - Beendetes Volk (Spec 5.3): Jeder Regel-Handler lädt das Volk über
txund kehrt zurück, wenn es nicht lebt (isLivingColonyStatus); die Altpfade (Schwarmkontrolle, Lagerartikel) prüfen dasselbe, soweit sie ein Volk haben. dueDatekommt ausconfig.dueInDays; fehltdueInDays, bleibt esnull, undacceptlegt 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).“
- Alle Regel-Pfade (Handler und die Altpfade Schwarmkontrolle und Lagerartikel) rufen
- 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;updateInals Kern vonupdate),src/colonies/dto/create-colony.dto.ts(occupiedFrames,strengthveraltet),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(importiertColoniesModule; der Resolver kommt global, keinPermissionsModule-Import),src/export/export.service.ts,src/import/import.service.ts(+ Spec),src/apiaries/apiary-field.service.ts+apiary-field.types.ts(occupiedFramesin Zeile und Paket),src/common/validation/update-dto-null-handling.spec.ts - Test: neu
test/inspection-takeover.e2e-spec.ts, neutest/colony-strength.e2e-spec.ts - Docs: neue Annahme „Volksstärke in besetzten Waben“ (0–40, Altwert
strength1–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 increate/update). - Produces:
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
// 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/validationund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
ColoniesService.updatewirdprisma.$transaction((tx) => this.updateIn(tx, …));updateInbeginnt mitlockRowForUpdate(tx, 'Colony', id)und liestbeforeerst danach (404 wie bisher), dann die bisherige Logik samt Standort-Historie, Audit und Ereignissen.InspectionsService.createin einer Transaktion: Durchsicht, Audit,InspectionRecorded; beiapplyToColonydannhasNewerInspection(date >diese undid ≠),canUpdateColonyüberPermissionResolverService.getPermissions(actingUserId),planColonyTakeover, bei nicht leerempatchcolonies.updateIn(tx, colonyId, patch, actingUserId). Werte nur aus der gespeicherten Durchsicht, nie aus dem Request. OhneapplyToColony→colonyTakeover: { applied: [], skipped: [] }.occupiedFramesinColonyOverviewRowundOfflinePackV1.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;colonyTransitionEventsbekommt 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:
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
// 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/queensund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
dissolve,merge,updateIn(Status aus dem DTO inTERMINAL_COLONY_STATUSES, Enddatum = heute) undmarkDeceasedIfRequested(Enddatum = Datum der Auswinterung) rufenreleaseQueenIn; der Status der Königin ändert sich dabei nicht. DanachcolonyTransitionEvents(before, after, actor, { endedOn, releasedQueenId, via })mitviaDISSOLVE,MERGE,EDITbzw.WINTER_CHECK; Handler und Tests lesenendedOnaus der Payload, nie aus der Uhr.dissolve,merge(alle Völker in einer Anweisung) undmarkDeceasedIfRequestedsperren die Volkszeilen mitlockRowForUpdate(tx, 'Colony', id)bzw.lockRowsForUpdate(tx, 'Colony', ids)und lesen den Zustand danach;releaseQueenInliestcurrentQueenIderst unter dieser Sperre.cancelInundcompleteInsperrenTask,updateStatusInsperrtQueen,CleanupService.applysperrtSuggestion, dann die Aufgaben (lockRowsForUpdate(tx, 'Task', ids)), dann die Königin (Reihenfolge laut Global Constraints);beforejeweils erst danach lesen.- Handler: zuerst
lockRowForUpdate(tx, 'Colony', colonyId)(Sperr-Reihenfolge Colony → Suggestion;obsoleteForColonyInundproposeInlaufen danach), dann Volk übertx;suggestions.obsoleteForColonyIn; offene AufgabenrelatedType = COLONY;releasedQueenIdmit StatusACTIVE; dannproposeInmitruleKey COLONY_ENDED_CLEANUP,CLEANUP,DO,NORMAL,requiredPermission 'colonies:read',anchor 'ended-<endedOn>',evidence { colonyId, status, endedOn, via, openTaskIds, queenId }. Regel aus → nurOBSOLETE. - Text
COLONY_ENDED: „B3 ist eingegangen (Auswinterung 12.03.). 2 Aufgaben sind noch offen.“ (Wort und Herkunft jestatus/via). CleanupService.applyin einer Transaktion: CAS prüftOPENundintent = CLEANUP; IDs nur ausevidence;CANCEL→cancelIn,DONE→completeIn(Audit undTaskCompletedwie bei einer normalen Erledigung; das privatemarkDoneInüberspränge beides),KEEP→ nichts; Königin →updateStatusIn; danachresolveIn(…, '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; KalenderQUEEN_ASSIGNMENTund Webhookqueen.replacedaus Handlern; Annahme 11: Altpfad-Liste ohne Kalender.
Interfaces
- Consumes: Task 2 (
SplitQueenSource). - Produces:
// 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
// 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.tsund die e2e-Dateien → FAIL. - Schritt 3: Implementieren
split: in der TransaktionlockRowForUpdate(tx, 'Colony', parentId), Muttervolk danach lesen und „lebt“ prüfen; Lineage mitdate(Standard heute) undqueenSource; Audit undColonySplitin der Transaktion.assignColony: in der Transaktion zuerstlockRowsForUpdate(tx, 'Colony', [Zielvolk, bisheriges Volk der Königin])(eine Anweisung, Schlüssel aufsteigend), dannlockRowForUpdate(tx, 'Queen', id); Königin und Völker danach lesen und die Prüfungen (verfügbar, lebt, nicht schon zugewiesen) dort ausführen; Audit undQueenAssignedin der Transaktion,assignedOn = utcDay(startDate);webhooks.dispatch('queen.replaced')undcalendarSync.enqueueentfallen;queen.createdbleibt 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:
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, AnkerhistoryId); AblegerRAISE_OWN/QUEEN_CELL→SPLIT_PLAN(colonies:read, AnkerlineageId),QUEEN→QUEEN_ACCEPTANCE(queens:read, AnkerlineageId). -
Dazu (Lösung Spec-Lücke, siehe Self-Review) räumt
chains.queen-cellsbei 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: beiqueenCellsPresent = truealle offenen (jedes Fälligkeitsdatum) – danach folgt der neue Vorschlag mit dem Anker dieser Durchsicht und Fälligkeit +7; sonst die mitdueDate ≤ Durchsichtsdatum.QUEEN_ACCEPTANCE-Kettenvorschläge des Volks mitdueDate ≤ Durchsichtsdatum: nur wennqueenSeenodereggsPresenttrueist (Annahme der Königin beobachtet); sonst bleiben sie offen.
-
Meldung (Spec 9.4): Die Handler übergeben
notify: config.notifyanproposeIn; geschrieben wird die Meldung vonSuggestionsService(Task 7), die Ketten bauen keine eigene. -
Schritt 1: Failing Tests
// 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/chainsundtest/chains.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren – Jeder Handler sperrt zuerst die Volkszeile (
lockRowForUpdate(tx, 'Colony', colonyId); das Auflösen berührtSuggestion-Zeilen und darf nie vor der Volkssperre liegen, sonst kehrt sich die Reihenfolge Colony → Suggestion gegenübersyncColonyInum). Dann laden die Handler übertx, prüfen „Volk lebt“ undreadRule(tx, …).active, dannsuggestions.proposeIn(tx, { source: 'CHAIN', mode: 'PLAN', priority: 'NORMAL', relatedType: 'COLONY', relatedId: colonyId, colonyId, evidence: { …, baseDate } }). Dubletten verhindertproposeInüber die Absicht. Reihenfolge je Durchsicht: erst auflösen, dannproposeIn; alle Aufrufe mitnotify: 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 mitwarnings, importiertWithdrawalModule),src/bottling/bottling.service.ts+bottling.controller.ts+bottling.module.ts(Antwort mitwarnings, importiertWithdrawalModule),src/calendar-sync/calendar-sync.service.ts(+ Spec; QuelleWITHDRAWAL, ungenutzter AltpfadenqueuesamtrunAfterCommitentfä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; TypWITHDRAWAL),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,WithdrawalWindowaussrc/treatments/withdrawal-window.ts, seit dem Plan-Review dort), Task 10 (Handler-Datei). - Produces:
// 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, KategorieWITHDRAWAL, Empfänger mitharvests:readundtreatments:read, Text „Wartezeit: {Volk} am {TT.MM.} geerntet, Wartezeit nach {Mittel} endet am {TT.MM.JJJJ}.“, Link/harvests/<id>, Präfixwithdrawal-conflict:<harvestId>:<colonyId>. -
Meldung „erntefähig“ (nur bei
WITHDRAWAL_ENDED.notifyund ohne weiteres laufendes Fenster des Volks):INFO, KategorieWITHDRAWAL, Empfänger mitharvests:createundtreatments:read, Text „{Volk} ist wieder erntefähig. Wartezeit nach {Mittel} endete am {TT.MM.JJJJ}.“, Link/colonies/<id>, Präfixwithdrawal-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
// 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/jobsund die e2e-Dateien → FAIL. - Schritt 3: Implementieren wie in Interfaces;
WithdrawalModule(importiertNotificationsModule; keinPermissionsModule, Global Constraints) inAppModuleundWorkerModule;HarvestsModuleundBottlingModuleimportierenWithdrawalModulefür die Antwort-Warnungen (Moduldateien stehen in der Dateiliste oben). Das Fenster kommt auswithdrawalWindowOf(Task 6);getWithdrawalStatusundwithdrawalEndDayrechnen 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,VARROA_MEASUREMENT_INTERVAL_DAYSsamtcolonyCyclesDue/latestCycleOccurrence/inActiveSeasonentfallen;queenAged,varroaMeasurementDueundinspectionOverdueinDAILY_EVALUATION_RULESrechnen neu überSeasonServiceund die Regeln,withdrawalEndedundtaskDuebleiben;CATCH_UP_DAYS,catchUpEvents,inCatchUpWindowbleiben),src/jobs/jobs.module.ts,src/system/job-run.service.ts(+ Spec;JobPartialFailure),src/dashboard/dashboard.service.ts(+ Spec;OLD_QUEEN_AGE_YEARSentfä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:
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; übernimmt die Schleife aus DailyEvaluationJob.evaluate über DAILY_EVALUATION_RULES
// DailyRule.occurrences(db: PrismaService, today: Date, ctx: { seasons: SeasonService; day: string }): today = dayToDate(day), day = Berliner Tag des Laufs; Ergebnis je Typ weiter { Typ: Zahl neuer Ereignisse }
-
Regeln
time-events(Nachholfenster 7 Tage,dailyDedupeKey, je Typ eine Transaktion wie bisher):InspectionOverdueam Tag nachinspectionDueOn, nie inWINTERRUHE;VarroaMeasurementDueamdueOnausvarroaMeasurementDue;QueenAgednicht 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öniginisQueenAged(hatchYear, heute, queenMaxAgeYears)ist, einmal je Königin und Jahr – SchlüsseldailyDedupeKey('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 inWINTERRUHE;inspectionDueOnsetzt sie auf den Beginn vonFRUEHJAHR(„Erste Durchsicht im Jahr“),InspectionOverduefolgt am Tag danach und A1 (Task 14) schlägtINSPECTION_FIRST_OF_YEARvor; der Saisonbeginn selbst erscheint als Meldungseason-segment(Task 26), eine feste Märzmeldung entfällt;WithdrawalEndedunverändert. -
A2-Winterfenster: Abschnitt
WINTERRUHE, heute zwischen11-15und12-20, keine Messung seit11-01→dueOn = <Jahr>-11-15,anchor = 'winter-<Jahr>'; sonst außerhalbWINTERRUHE(letzte Messung oder createdOn) + varroaMeasureIntervalDays,anchor= Mess-ID oder'none'. -
Schritt 1: Failing Tests
// 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/dashboardund die e2e-Datei → FAIL. - Schritt 3: Implementieren
time-events.step.tsverschiebtevaluate/evaluateRuleausDailyEvaluationJob(eine Transaktion je Regel, ein scheiternder Typ wird geloggt, hält die anderen nicht auf und lässt den Schritt scheitern);DailyEvaluationJob.evaluate(today: Date)entfällt, die bestehenden Aufrufe indaily-evaluation.job.spec.tsundtest/daily-evaluation.e2e-spec.tsrufenTimeEventsStep.run('<Tag>')bzw. den Job.DailyEvaluationJob.run(): stattSELECT now()(UTC)today = berlinToday(), Schritte inDAILY_EVALUATION_STEP_ORDER(Konstruktor injiziert die vorhandenen Schritte; Tasks 14, 16, 25 ergänzen ihre),runDailyEvaluationSteps; beifailed.length > 0→throw new JobPartialFailure(result, failed);JobRunService.runspeichert dannresultunderror.SeasonService.statesForliestPHENOLOGYeinmal und bautcalendarSeasonStateje Stand.- Dashboard liest
queenMaxAgeYearsüberreadRule(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-Schritteautopilotundsuggestions-expiry, Abgleich (Spec 8.3).
Interfaces
- Consumes: Task 4, 10, 13.
- Produces:
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'>; varroa: ActiveRule<'VARROA_THRESHOLD'>; phenology: PhenologyConfig } // varroa: nur recheckAfterDays für A3
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-synchört auf:InspectionRecorded,InspectionCorrected,VarroaMeasured,VarroaCorrected,TreatmentRecorded,TreatmentCorrected,FeedingRecorded,FeedingCorrected,HarvestRecorded,HarvestCorrected(je Volk),ColonyStatusChanged,ColonySplit(Ableger und Muttervolk),QueenAssigned(Volk undpreviousColonyId),TaskCompleted,TaskUpdated(Aufgabe übertxladen, nurrelatedType = COLONY),WithdrawalEnded,QueenAged(colonyId),InspectionOverdue,VarroaMeasurementDue.today = berlinToday(). -
Regeln: A1
INSPECT/DO/INSPECTION_INTERVAL, Bedingungsegment ≠ WINTERRUHEunddueOn ≤ heute + 2, FälligkeitdueOn,HIGHwenndueOn < heute, Anker letzte Durchsicht-ID odernone, reasonCodeINSPECTION_DUEbzw.INSPECTION_FIRST_OF_YEAR. A2VARROA_MEASURE/DOausvarroaMeasurementDue, reasonCodeVARROA_MEASURE_DUEbzw.VARROA_WINTER_MEASURE. A3VARROA_RECHECK/DO, Behandlungsende vor ≥varroa.config.recheckAfterDays(Standard 14), keine Messung seither, FälligkeitendDay + recheckAfterDays, Anker Behandlungs-ID, reasonCodeVARROA_RECHECK_DUE. AlleexpiresAt = dueDate + suggestionTtlDays. -
Abgleich exakt Spec 8.3: Volkszeile über
lockRowForUpdate(tx, 'Colony', colonyId), Eingaben erst danach laden; beendet → Ende;SEASON_AUTOPILOTaus → alle offenenAUTOPILOT→RESOLVED(„Autopilot aus“); Kandidat mit offenem Vorschlag gleichen Schlüssels →refreshIn; sonst offene Sache gleicher Absicht → verwerfen; sonstproposeIn(Kandidaten immer in der Reihenfolge A1…A10, damit die Regelsperren vonproposeInbei parallelen Abgleichen in gleicher Reihenfolge fallen); offene Autopilot-Vorschläge ohne Kandidat →RESOLVED(„Bedingung entfällt“).resolveInundexpireDuegeben dendedupeKeyfrei (Task 4), sodass dieselbe Bedingung nach Ablauf oder Erledigung neu vorgeschlagen werden darf;DISMISSEDhält ihn. Regel- und Kettenvorschläge bleiben unberührt. -
Schritt 1: Failing Tests
// 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/autopilotund die e2e-Dateien → FAIL. - Schritt 3: Implementieren wie oben;
AutopilotModuleimportiertSeasonModule,SuggestionsModule; Texte inautopilot-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(importiertAutopilotModule) - 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ägenHIGH/URGENT.
Interfaces
- Consumes: Task 4, 5 (
visibleWhere), 6 (withdrawalWindowOf, seit dem Plan-Review dort), 13, 14. - Produces (Spec 8.5 wörtlich, dazu):
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 undURGENT= 0), Priorität,INTENT_ORDER, Fälligkeit,createdAt,id. Zusammengelegte Gruppe: dringlichster Rang, höchstens zwei Gründe, AktionCAPTURE, 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
visibleWhereund nur mittasks:read(wieGET /suggestions; ohnetasks:readgibt es keine Vorschlags-Schritte, die AktionACCEPTzusätzlich nur mittasks:create, sonst höchstens derCAPTURE-Link, falls die Absicht ein Formular hat), Aufgaben nur mittasks:read;withdrawalUntil= jüngstes laufendesuntilDaynur mittreatments:read. -
Schritt 1: Failing Tests
// 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/apiariesund die e2e-Dateien → FAIL. - Schritt 3: Implementieren wie oben; Texte über
renderSuggestionmit einemEvidenceLookupfü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-Schrittdigest.
Interfaces
- Consumes: Task 3, 13, 14, 15 (
NextStepsService.today-Daten für Durchsicht und Saison). - Produces:
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 übertx; nurOPEN/IN_PROGRESS; Empfänger = Zuständiger, sonst alle aktiven Nutzer mittasks:complete; immer zusätzlichtasks:readundtaskDueEnabled(fehlende Präferenz = an);HINWEIS,TASK_DUE, „Heute fällig: {Titel}“ (Titel ≤ 200 Zeichen), Link/tasks,relatedType 'Task',relatedId,requiredPermissions ['tasks:read'], Präfixtask-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 nachrequiredPermission), Saison „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“ (apiaries:read, nur beisegmentSince = heute). Keine Zeile → keine Übersicht.INFO,DIGEST, Link/dashboard, Präfixdigest:<Tag>. -
Schritt 1: Failing Tests
// 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/remindersund die e2e-Datei → FAIL. - Schritt 3: Implementieren wie oben;
DigestServicelädt die Daten einmal je Lauf und filtert je Nutzer. Er injiziertPermissionResolverService, undRemindersModuleimportiert dafür keinPermissionsModule(Global Constraints, Rechte-Auflösung): inAppModuleistPermissionsModuleglobal, inWorkerModuleundJobsModuledas resolver-onlyJobPermissionsModule(aussrc/jobs/jobs.module.ts,WorkerModuleimportiert es seit Task 4).RemindersModulewird vonAppModule,WorkerModule(Handlernotifications.task-due) undJobsModule(DigestStep) importiert.test/worker-runtime.e2e-spec.tsstartetWorkerModuleund löst 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:
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“ (startRoundmit 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 mitaria-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) überDueDateSheet; 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, Rechttasks:read, Zähler aussummary.open.TaskSourceBadge: „Aus Vorschlag“ (Titel „Aus einem übernommenen Vorschlag“) beisourceSuggestionIdodersourceAutomationTrigger. -
Schritt 1: Failing Tests
// 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; BausteinePageHeader,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:
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
// field-types.ts: ColonyOverviewRow += occupiedFrames, inspectionDueOn, segment, nextStepTitle (openWarnings/openCritical zählen Vorschläge); OfflinePackV1 += nextSteps?
-
NextStepsCardoben auf der Volksseite: höchstens 3 Schritte mit Titel, ≤ 2 Gründen,DueChip, Aktion (CAPTURE→ Link mitcolonyId,ACCEPT→ Übernehmen,CLEANUP→CleanupSheet,OPEN_TASK→ Link), Hinweis „Wartezeit bis {TT.MM.}“ auswithdrawalUntil. -
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“ (startRoundnur 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 beiDECEASED„verstorben“, beiSOLD„verkauft“, sonst „unverändert“; ein Hauptknopf „Aufräumen“; ohnetasks:completeist „Erledigt“ deaktiviert mit Hinweis. -
Überwinterung:
/winter?colonyId=&season=öffnet den Erfassungsdialog für dieses Volk. -
Schritt 1: Failing Tests
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; altesvarroaLightundVARROA_WARN_FRACTIONentfallen),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:
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
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:
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:updatefehlt er. Nach dem Speichern beiNEWER_INSPECTIONToast „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 eineChoiceGroup„Woher kommt die Königin?“ mit „Eigene Zellen ziehen“ (RAISE_OWN), „Weiselzelle“ (QUEEN_CELL), „Königin“ (QUEEN) und „Weiß ich noch nicht“ (sendet keinqueenSource, 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.tsverliert den Freitext-Sonderfall. -
Volk bearbeiten: „Besetzte Waben“ (
NumberStepper0–40); „Volksstärke (alt, Skala 1–10)“ nur, wenn gespeichert; Leeren sendetnull. Neues Volk nuroccupiedFrames. Volksseite „{n} besetzte Waben“, sonst „Volksstärke (alt): 7 von 10“. -
Schritt 1: Failing Tests
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:
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 (NextLink), 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
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:
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;notifyals Schalter „Zusätzlich melden“; Hauptknopf „Speichern“. -
Schritt 1: Failing Tests
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, dannnpm run test:e2ekomplett. - 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) undTASKS.md(Abschnitt T4a) eintragen und auf Widersprüche lesen. - Vor dem Deploy: App-Backup und
pg_dump; PrüfabfragecolonyStrength > 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/(jemigration.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,HoneyBatcham 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:PhenologyReportgesichert,ApiaryPhenologyundApiaryWeatherDayausgenommen (Begründungen aus Spec 4.13).
Interfaces
- Consumes: Task 2 (
createScratchDatabase), Task 1 (season-types.ts). - Produces:
// 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?
// 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
// 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/backupund die e2e-Datei → FAIL. - Schritt 3: Implementieren
- Rückrechnung in Migration 6 je Charge mit
amount IS NOT NULL:initialAmount = amount + Σ totalAmountihrer Abfüllungen; je AbfüllungBOTTLINGmit-round(totalAmount * 1000),sourceBottlingBatchId,createdAtder Abfüllung, Notiz „Übernahme aus Abfüllung (T4)“. Den Namen des alten CHECK aus20261005100000_raw_materials_productionübernehmen. INVARIANT_SQLals exportierte Konstante intest/utils/honey-ledger.ts: Chargen mitabs(amount − (initialAmount + coalesce(Σ quantity, 0) / 1000)) > 0.0005.- Katalog-Eintrag
phenologyinpermission-catalog.ts; Backup-Import: Chargen ohneinitialAmountnach derselben Regel.
- Rückrechnung in Migration 6 je Charge mit
- 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; die dort fest verdrahtete KonstanteOPEN_METEO_FORECAST_URLentfällt, URL und Schlüssel kommen ausweatherHistoryConfig(), damit beide Pfade dieselben Variablen lesen),src/jobs/cleanup.job.ts(+ Spec),.env.example,deploy/k8s/base/backend/configmap.example.yaml(Bedeutung der Schlüssel; der Overlay verweist darauf),deploy/k8s/base/backend/secret.example.yaml(OPEN_METEO_API_KEY, leer),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 (DailyMeanaussrc/season/season-types.ts). - Produces:
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
meanTempCnull 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);apikeynur, wenn gesetzt; Upsert je Stand und Tag mitsource'forecast'/'archive'. Fehler je Punkt überloggableErrorohne URL, nächster Takt versucht es neu; kein Ereignis, keine Meldung. -
Schritt 1: Failing Tests
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/jobsund die e2e-Datei → FAIL. - Schritt 3: Implementieren wie oben; Antwort über
response.bodymit 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; liestApiaryPhenology),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, EreignisSeasonPhaseChanged.
Interfaces
- Consumes: Task 13 (
segmentOf,calendarPhase,SeasonService), Task 1 (DailyMeanaussrc/season/season-types.ts; nicht aus Task 24, gleiche Welle), Task 23, Task 24 (nur zur Laufzeit:ApiaryWeatherDaygefüllt). - Produces:
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: zuerstlockRowForUpdate(tx, 'Apiary', apiaryId), dann den bisherigenApiaryPhenology-Zustand lesen (mehrere Stände:lockRowsForUpdate(tx, 'Apiary', ids)); Meldungen des Jahres für den Stand und für alle Stände (apiaryId null), ohne zurückgezogene; GTS ausApiaryWeatherDaybis gestern; GTS älter als 3 Tage → nicht benutzt (WEATHER_STALE); ohne KoordinatenNO_COORDINATES; Schwellen =gtsThresholds(useGts) plus Kalibrierung fürcalibratePhases(calibrateFromReports); Kalender nur für Phasen ohne GTS-Schwelle oder ohne Wetterdaten. Maximum der drei, nie rückwärts außerallowBackward. UpsertApiaryPhenology (apiaryId, year); bei geänderter Phase oder AbschnittSeasonPhaseChangedmitdailyDedupeKey('SeasonPhaseChanged', apiaryId, today);segmentSince= heute bei Wechsel. -
SeasonService.statesFor: Zeile des laufenden Jahres, wenn vorhanden, sonst Kalender (T4a-Verhalten). -
Schritt 1: Failing Tests
// 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/seasonund die e2e-Datei → FAIL. - Schritt 3: Implementieren wie oben;
PhenologyModuleinAppModule,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:
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
-
updateundrevoke:lockRowForUpdate(tx, 'PhenologyReport', id),beforedanach lesen (404, beirevokeschon zurückgezogen → 409), schreiben, Audit mitbeforeData, Ereignis. -
Saison-Meldung:
INFO,SEASON, anapiaries:read, „Stand {Name}: {Abschnitt} beginnt. Durchsicht alle {n} Tage.“ (ohne Satz 2, wenn der Abschnitt kein Intervall hat), Link/apiaries/<id>, Präfixseason-segment:<apiaryId>:<segment>:<Tag>. -
Schritt 1: Failing Tests
// 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/validationund 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:
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 überconfig.rulesschaltbar; Phase und „gemeldet/geschätzt/Kalender“ im Grund ausSeasonState. -
Schritt 1: Failing Tests
// 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/autopilotund 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, Webhookbatch.createdaus Handler, Handlerwithdrawal.extraction-check; Annahme 11: Altpfad-Liste ohne Honigchargen.
Interfaces
- Consumes: Task 12 (
PlausibilityWarning,WithdrawalCheckService), Task 23. - Produces:
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 gleicherhoneyType; Chargenmenge = Schleudermenge − ΣinitialAmountvorhandener Chargen (nullzählt nicht), Sorte und Wassergehalt der Schleuderung. Warnungen beim Anlegen und Ändern, nie eine Sperre. -
create/updatebeider Services in einer Transaktion mit Audit (mittx) und Ereignis;updatesperrt zuerst (lockRowForUpdate(tx, 'Extraction' | 'HoneyBatch', id)) und liestbeforeerst danach;webhooks.dispatch('batch.created')entfällt; der Handler lädt die Charge übertxmit demselbenhoneyBatchIncludewie bisher, die Webhook-Payloaddatableibt also unverändert. -
Schritt 1: Failing Tests
// 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)', ...);
// after-commit-usage.spec.ts
it('webhooks.dispatch( is no longer called from honey-batches.service.ts', () =>
expect(filesCalling('webhooks.dispatch(')).toEqual(['src/colonies/colonies.service.ts', 'src/queens/queens.service.ts', 'src/storage/storage.service.ts'])); // seit Task 10: colonies, honey-batches, queens, storage
- Schritt 2:
npx jest src/extractions src/honey-batches src/webhooks src/withdrawal src/common/after-commit-usage.spec.tsund 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,normalizeAmountentfallen),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:
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“ (beiCORRECTION/WITHDRAWAL„Die Menge kann nicht unter 0 sinken.“);BatchMovementmitquantity = 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, dannbookIn(sperrt die Charge; bei Chargenwechsel beide Chargen überlockRowsForUpdate(tx, 'HoneyBatch', ids)).PATCH /honey-batches/:idliest den aktuellen Rest erst unter der Chargensperre. -
Vorgänge (Tabelle Spec 7.7): Charge mit Menge anlegen →
initialAmount = amount; Abfüllung anlegenBOTTLING −, ändernBOTTLINGDifferenz (Wechsel der Charge: zurück auf alte, ab von neuer), entfernenBOTTLING +mit Notiz „Abfüllung entfernt“; StatusBOTTLEDwie bisher;PATCH amount→CORRECTION(neu − aktuell);amount: nullbei vorhandenen Buchungen → 400 „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“;null → Wert→setInitialIn.createundupdateinhoney-batches.service.tsreichenamountnie per{ ...dto }an Prisma (const { amount, ...rest } = dto; Anlegen setztamountundinitialAmountgemeinsam übersetInitialIn); der Wächter-Regex erfasst die Spread-Form nicht, deshalb die Unit-Tests oben. -
Schritt 1: Failing Tests
// 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/bottlingund 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
shelfLifeMonthsohne Standardwert).
Interfaces
- Consumes: Task 1 (
WaterContentConfig), Task 12 (Warnungen), Task 29. - Produces:
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, sonstproduct.packagingSize; MHD = gesendet, sonstdate + 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 /bottlingliefert nur Warnungen. Honigmessung anlegen → Antwort mit Warnungen, nie 409. -
Schritt 1: Failing Tests
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/validationund 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, Storepending-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:
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
SeasonChipund Quelle („Schwarmzeit · alle 7 Tage · gemeldet am 18.04.“), Knopf „Pflanze blüht“ (nur mitphenology:create) öffnetPhenologyReportSheet: Zeilenmin-h-14je Phase mit Name, „für alle Stände“ vorgewählt (umschaltbar auf diesen Stand), ein Tipp meldet für heute überuseCaptureMutation(offline in die Warteschlange); darunter die Meldungen des Jahres mit „Zurückziehen“ (phenology:update). -
Schritt 1: Failing Tests
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; Propprefix),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:
// 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“ buchtCORRECTION, „Entnahme“ buchtWITHDRAWALmit Grund. -
Schritt 1: Failing Tests
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 untersrc/app/(app)/products/(+ Spec)
Interfaces
-
Consumes: Task 30, Task 21.
-
Produces:
fetchBottlingPrefill(query): Promise<BottlingPrefill>; FormularwerteacknowledgedWarnings: '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_REQUIREDConfirmDialog„Wassergehalt über 20 %. Trotzdem abfüllen?“ (Fokusfalle, Fokus zurück), Bestätigen sendet erneut mit gleichem Idempotency-Key undacknowledgedWarnings; Artikel-Formular: Feld „Haltbarkeit (Monate)“, leerbar. Spec 4.7:packagingSizegilt als Gramm wieBottlingBatch.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
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;NotificationDeliveryam Ende der Map, Schlüssel"id") - Docs: §8:
TelegramLink,TelegramLinkToken,NotificationDelivery,TelegramPollStateausgenommen (Begründungen Spec 4.13); §9 „Ausgehende Aufrufe“: Telegram nur aus dem Worker, Klartext, Token nie in Logs.
Interfaces
- Consumes: Task 3.
- Produces:
// 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')
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 wirdTelegramApiErrorohne URL; Antwortkörper nur aufok,description,parameters.retry_aftergelesen; Größe begrenzt (256 kB). -
Wächter
no-network-outside-loops.spec.ts: Wetter-Hosts nur insrc/weather/,api.telegram.orgnur insrc/telegram/;fetch(nur insrc/weather/,src/telegram/und den Bestandsdateiensrc/calendar-sync/graph-client.service.ts,src/common/password/password-policy.ts; nie in*.handlers.tsodersrc/jobs/.handler-contract.spec.tsverbietet zusätzlich Importe vontelegram-client. -
Schritt 1: Failing Tests
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; Schleifetelegram-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:
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, bestehendemTelegramLinkund Kategorie intelegramCategories;noweinmal perSELECT now(). -
Senden (Spec 9a.6):
claimBatch(…, 'NotificationDelivery', { limit: 25, orderBy: 'createdAt' }); je Nutzer bzw. Chat nur die älteste, die übrigendeferClaim(…, 1100); vor jedem Senden in einer SQL-Abfrage prüfen: Nutzer aktiv,telegramEnabled, Verbindung, Kategorie, Veraltung (TASK_DUE/DIGESTgleicher Berliner Tag überto_char(… AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD'), sonstnow() - "createdAt" <= interval '24 hours'), beiTASK_DUEAufgabe noch offen; Rechte überPermissionResolverService.getFreshPermissions(nichtgetPermissions: der Worker bekommt keininvalidateUserder API-Pods, der 30-s-Cache ließe ein entzogenes Recht bis zu 30 s weiter senden) ⊇requiredPermissions; sonstsettleSkippedmituser 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 beicount 1telegramEnabled = false(unterlockRowForUpdate(tx, 'NotificationPreference', userId)wie Task 36) und die Meldung an den Nutzer, beicount 0nichts davon;settleSkipped('chat unavailable'), MeldungSYSTEMan 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
// 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/workerundtest/telegram.e2e-spec.ts→ FAIL. - Schritt 3: Implementieren wie oben; Schleifenzeile
telegram-send(5 000 ms,notification_delivery) nur, wennTelegramClient.isConfigured().TelegramSenderinjiziertPermissionResolverService;TelegramModuleimportiert dafür keinPermissionsModule(Global Constraints, Rechte-Auflösung): der Worker bekommt den Resolver aus dem globalenJobPermissionsModule(Task 4), die API ausPermissionsModule, und der Katalogabgleich läuft nicht im Worker.cleanup.job.tsräumt wie beiWebhookDeliveryauf:DELETE FROM "NotificationDelivery"fürDELIVERED/SKIPPEDälter alsWEBHOOK_DELIVERY_RETENTION_DAYSundFAILEDälter alsFAILED_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; Schleifetelegram-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:
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): erstgetOrCreateder Präferenz, dannlockRowsForUpdate(tx, 'NotificationPreference', userIds)für alle betroffenen Nutzer (eine Anweisung, Schlüssel aufsteigend),TelegramLinkund Präferenz erst danach lesen und ändern. -
Token:
randomBytes(32).toString('base64url')(43 Zeichen), gespeichertsha256hex,expiresAt = now() + 10 minper SQL, ältere offene Token des NutzersconsumedAt = now(). -
Empfang (Spec 9a.5, 9a.7): Lease-SQL wörtlich; je Update eine Transaktion mit Wirkung und
updateOffset = update_id + 1als 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'; nurchat.type = 'private';/start <token>→ CASUPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "tokenHash" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now() RETURNING "userId"(Nutzer aktiv),TelegramLinkanlegen/ersetzen, war der Chat einem anderen Nutzer zugeordnet: dessen Verbindung lösen,telegramEnabled = false, MeldungSYSTEM„Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“;telegramEnabled = true; MeldungSYSTEM„Telegram verbunden mit „{Vorname}“ am {TT.MM.JJJJ}. Warst du das nicht? In den Einstellungen trennen.“ (Link/profile); AntwortLINKED. Ungültig →LINK_INVALID./startohne 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'sofortwake(); Stop-Signal bricht den laufenden Abruf ab; 409 →lastError, 30 s warten; Störungen machen den Worker nicht ungesund. -
Schritt 1: Failing Tests
// 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/workerundtest/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/base/backend/configmap.example.yaml,deploy/k8s/overlays/encry/configmap-backend.yaml,deploy/k8s/tests/manifest-policy.test.mjs(Manifeste liegen unterApiCore/deploy/, nicht unterApiCore-Backend/) - Test:
test/system.e2e-spec.ts(ergänzen; nichttelegram.e2e-spec.ts, das Task 36 in derselben Welle ändert) - Docs: ARCHITECTURE §7: Secret
apicore-telegram(optional: true) nur im Worker,TELEGRAM_BOT_USERNAMEin 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
TelegramPollStateundNotificationDeliveryaus Task 34/35 undtelegramBotAvailable()aus Task 34.) - Produces:
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
envTELEGRAM_BOT_TOKENaussecretKeyRef { name: apicore-telegram, key: TELEGRAM_BOT_TOKEN, optional: true }; api bekommt das Secret nicht; ConfigMapTELEGRAM_BOT_USERNAME: ""(Platzhalter, Runbook) und die Wetter-Variablen aus Task 24 bleiben. -
Schritt 1: Failing Tests
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/systemund 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 (fehltkubectllokal, ü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; nutztStepUpDialogstatt 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,src/lib/system/labels.ts(+labels.spec.ts;DOMAIN_EVENT_LABELSum die neuen Ereignisse ergänzen:ColonySplit„Ableger angelegt“,QueenAssigned„Königin zugewiesen“,ExtractionRecorded/ExtractionCorrected„Schleuderung erfasst/korrigiert“,HoneyBatchRecorded/HoneyBatchCorrected„Honigcharge angelegt/korrigiert“,PhenologyReported/PhenologyCorrected„Pflanzenmeldung erfasst/korrigiert“,SeasonPhaseChanged„Saisonabschnitt gewechselt“; die Seite fällt sonst auf den Typnamen zurück)
Interfaces
- Consumes: Task 36, 37, Task 21.
- Produces:
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“ mitConfirmDialog; Kategorien alsToggleChip, 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,
FAILED24 h, letzte Fehler), „Erneut versuchen“ einzeln und für alle nur mitsystem:manage. -
Schritt 1: Failing Tests
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', ...);
it('DOMAIN_EVENT_LABELS names ColonySplit, QueenAssigned, the extraction, honey batch and phenology events and SeasonPhaseChanged', ...); // labels.spec.ts
- Schritt 2:
npx vitest run src/components/auth src/lib/system "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, dannnpm run test:e2ekomplett. - 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 -undnode --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) undTASKS.md(T4b, T4c) eintragen und auf Widersprüche lesen. - Rollout nach Spec 15 (jeder Schritt mit Freigabe des Inhabers): Backup und
pg_dumpvorher; nach Migration 6 die Invarianten-Abfrage (INVARIANT_SQL) → 0 Zeilen;WEATHER_HISTORY_SOURCE=open-meteoin 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 (
sampleBeesT4a,shelfLifeMonthsT4b) 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.prismaund 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 (T4c20261015100000, T4b ab20261015110000). - 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 ingetWithdrawalStatus, 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 abEnde + 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-cellssetzt offeneSWARM_CHECK/QUEEN_ACCEPTANCE-Kettenvorschläge bei einer Durchsicht am oder nach ihrer Fälligkeit aufRESOLVED. - Wächter „
fetch(nur insrc/weather/undsrc/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 verliertqueens“ –queen.createdbleibt Altpfad ohne Ereignis. Gelöst:queens.service.tsbleibt in derwebhooks.dispatch-Liste, diecalendarSync.enqueue-Liste wird leer.ColonyEndedkennt die freigegebene Königin nicht: Payload umendedOn,releasedQueenId,viaerweitert (Spec 5.1 erlaubt Erweiterungen).requiredPermissionist 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
beforenachlockRowForUpdatein 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 rohesSELECT … 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 mittreatments:read(Inhalt nennt das Mittel); Anker für Lagerregeln (jüngste Zugangsbuchung bzw. jüngste Charge);TREATMENT_FOLLOW_UP/FEEDING_FOLLOW_UPwerdenFOLLOW_UP-Vorschläge mit unveränderter Config; 401 von Telegram pausiert 15 min;/startohne Token →HELP;link-tokennicht@Idempotent()(Geheimnis in der Antwort, Annahme 11); Validierungsgrenzen der Configs (Global Constraints);calibratePhasesals 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 keinenVarroaBandsService-Export. DailyMeansteht inseason-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_USERNAMEnicht leer (telegramBotAvailable()), nie das Token (das gibt es nur im Worker). no-broadcast-notificationserlaubt den Wiederherstellungspfad (backup.service.ts), prüft aber, dass jede SchreibstelleuserIdträgt; die Wiederherstellung überspringt Zeilen unbekannter Nutzer, stattuserId = nullzu schreiben.- Migration 11a belegt Altzeilen mit
relatedType 'JobRun'oder ohnerelatedTypealsSYSTEM, den Rest alsSUGGESTION; dieselbe Regel im Backup-Import. - Aufräumen erledigt Aufgaben über die öffentliche
TasksService.completeIn(Audit +TaskCompleted), nicht über das privatemarkDoneIn. dedupeKeybleibt nur beiOPEN,ACCEPTED,DISMISSEDundOBSOLETE;EXPIREDundRESOLVEDsetzen ihn aufNULL, damit Erinnerungen nach 30 Tagen wiederkommen.- Sperren und Meldungen:
proposeInsperrt erstColony, dann die Regelzeile; die Meldung zu Vorschlägen sitzt inSuggestionsService(Task 7) und gilt für alle Quellen; Telegram prüft Rechte beim Senden frisch (getFreshPermissions); der Offset-Fortschritt ist ein Compare-and-Set mitleaseOwner; der 403-Pfad löscht nur den Link desselben Chats. - Task 15/17: Vorschlags-Schritte brauchen
tasks:read. Task 5:assignedUserIdwird validiert und auf „existiert und aktiv“ geprüft. - Übertrag aus Rückgrat Task 18:
QueenAgedeinmal je Königin und Jahr am ersten Jobtag mit gealterter Königin (Jahresanker imdedupeKey); 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
TelegramLinkTokenundNotificationDelivery(Task 35); Migrations-Harness nutzt.tmp-migrations/statt OS-Temp (Task 2).
Konsistenzdurchgang nach den Textersetzungen (2026-10-14)
- Resolver in Worker und Jobs: kein Feature-Modul importiert
PermissionsModule; Task 1 exportiertJobPermissionsModule, Task 4 importiert es inWorkerModule(schon vor Task 5, dessenSuggestionViewsServiceden Resolver braucht). Tasks 8, 12, 16 und 35 entsprechend bereinigt. - Sperr-Reihenfolge: die Handler der Ketten (Task 11) und der Aufräum-Handler (Task 9) sperren zuerst die Volkszeile; Autopilot-Kandidaten werden in der Reihenfolge A1…A10 vorgeschlagen. Neue Tabellen gehen in die vorhandene Map (zwölf Bestandseinträge,
Productist schon darin);lockRowForUpdateliefertboolean, alle drei Helfer nutzen die Schlüsselspalte. - Tagesauswertung: Task 13 baut auf der vorhandenen Regeltabelle
DAILY_EVALUATION_RULESauf (Schleife wandert inTimeEventsStep,evaluate(Date)entfällt), statt sie neu zu erfinden. - Bestand korrigiert: Wetterdienst liest URL und Schlüssel künftig aus der gemeinsamen Konfiguration (Task 24; vorher feste Konstante),
deploy/liegt im Repo-Wurzelverzeichnis, Systemseite bekommt Ereignis-Labels (Task 38), A3 nutztrecheckAfterDaysstatt fester 14.
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.