docs: T4 plan review and consistency fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
1964286459
commit
64a2b7dad9
1 file changed
+55
-36
@@ -15,9 +15,9 @@
|
|||||||
## Global Constraints
|
## Global Constraints
|
||||||
|
|
||||||
**Voraussetzungen (nicht neu bauen, genau so nutzen)**
|
**Voraussetzungen (nicht neu bauen, genau so nutzen)**
|
||||||
- Rückgrat Tasks 1–26 sind gemergt. Genutzt werden: `TasksService.createInTx(tx, input, actingUserId)`, `TasksService.update/complete` (gemeinsames `markDoneIn`), `DomainEventsService.record/recordAll(tx, …)`, `@OnDomainEvent(types, '<consumer>.<name>')`, `HANDLER_ID_PATTERN`, `dailyDedupeKey` und `utcDay` (beide `src/domain-events/domain-event-types.ts`), `JobRunService.run`, `DailyEvaluationJob` mit `CATCH_UP_DAYS = 7`, `claimBatch`/`releaseStaleClaims`/`settleSuccess`/`settleFailure` (`src/common/outbox/outbox-claims.ts`), `retryDelayMs`/`MAX_ATTEMPTS = 8` (`src/common/retry-policy.ts`), `notifyWorker`/`WAKE_CHANNELS`, `loggableError`, `withTimeout`, `DbClient`, `assertTransactionClient`, `PollingLoop`, Loop-Tabelle in `WorkerRuntime`, `DOMAIN_EVENT_WEBHOOKS`, `CalendarSyncService.enqueueIn`, `ReauthService.verify`, `PermissionResolverService.getPermissions`, `SystemQueueService`, `drainDomainEvents` (`test/utils/domain-events.ts`), `deliverWebhooks`.
|
- Rückgrat Tasks 1–26 sind gemergt. Genutzt werden: `TasksService.createInTx(tx, input, actingUserId)`, `TasksService.update/complete` (gemeinsames, privates `markDoneIn` mit privatem `recordCompleted`), `lockRowForUpdate` (liefert `Promise<boolean>`), `lockRowsForUpdate`, `lockRowsForShare` (`src/prisma/row-lock.ts`; `LOCKABLE_TABLES` enthält schon `Colony`, `Task`, `BottlingBatch`, `Feeding`, `Harvest`, `Inspection`, `Product`, `ProductionBatch`, `RawMaterialLot`, `StockItem`, `Treatment`, `VarroaMeasurement`), `runBulk` und `assertLivingColonies` (`src/common/bulk/`), die Regeltabelle `DAILY_EVALUATION_RULES` mit `catchUpEvents`/`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`, `dailyDedupeKey` und `utcDay` (beide `src/domain-events/domain-event-types.ts`), `JobRunService.run`, `DailyEvaluationJob` mit `CATCH_UP_DAYS = 7`, `claimBatch`/`releaseStaleClaims`/`settleSuccess`/`settleFailure` (`src/common/outbox/outbox-claims.ts`), `retryDelayMs`/`MAX_ATTEMPTS = 8` (`src/common/retry-policy.ts`), `notifyWorker`/`WAKE_CHANNELS`, `loggableError`, `withTimeout`, `DbClient`, `assertTransactionClient`, `PollingLoop`, Loop-Tabelle in `WorkerRuntime`, `DOMAIN_EVENT_WEBHOOKS`, `CalendarSyncService.enqueueIn`, `ReauthService.verify`, `PermissionResolverService.getPermissions`, `SystemQueueService`, `drainDomainEvents` (`test/utils/domain-events.ts`), `deliverWebhooks`.
|
||||||
- T3 ist gemergt: `ApiaryFieldService` (`colony-overview`, `offline-pack`), `assertLivingColonies`, Frontend `PreviousValueHint`, `NumberStepper`, `StatusLightBadge`, `colonyStatusLight`, `startRound`, `useCaptureMutation`, Warteschlange `src/lib/offline/db.ts` (Version 3), `takeover.ts`, `varroaLight`, `fetchVarroaThreshold`, `nav-config.ts`.
|
- T3 ist gemergt: `ApiaryFieldService` (`colony-overview`, `offline-pack`), `assertLivingColonies`, Frontend `PreviousValueHint`, `NumberStepper`, `StatusLightBadge`, `colonyStatusLight`, `startRound`, `useCaptureMutation`, Warteschlange `src/lib/offline/db.ts` (Version 3), `takeover.ts`, `varroaLight`, `fetchVarroaThreshold`, `nav-config.ts`.
|
||||||
- Alle Backend-Pfade relativ zu `ApiCore-Backend/`, alle Frontend-Pfade relativ zu `ApiCore-Frontend/`, sofern nicht anders angegeben.
|
- Alle Backend-Pfade relativ zu `ApiCore-Backend/`, alle Frontend-Pfade relativ zu `ApiCore-Frontend/`, sofern nicht anders angegeben. `deploy/**` und `docs/**` liegen im Repo-Wurzelverzeichnis `ApiCore/` (Befehle mit `deploy/…` laufen dort, nicht in `ApiCore-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`).
|
**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`).
|
||||||
|
|
||||||
@@ -132,10 +132,11 @@
|
|||||||
|
|
||||||
**Projektregeln**
|
**Projektregeln**
|
||||||
- Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-`tx` als erster Parameter, optionaler `tx` als letzter.
|
- Sicherheit hat Priorität 1. Konsistenz ist oberstes Gesetz: ein Muster, gemeinsame Helfer, keine Kopien. Pflicht-`tx` als erster Parameter, optionaler `tx` als letzter.
|
||||||
- **Read-modify-write nur unter Zeilensperre:** Jede Änderung, die auf dem bisherigen Zustand aufbaut (Statuswechsel, Differenzbuchung, Zusammenführen mit gespeicherten Werten, Audit mit `beforeData`), liest `before` **innerhalb** der Transaktion nach `lockRowForUpdate(tx, table, id)` aus `src/prisma/row-lock.ts` – nie aus einem Lesen vor der Transaktion. Neue Tabellen kommen in die feste Bezeichner-Map `LOCKABLE_TABLES` (nie ein Tabellenname aus Eingaben); T4a-Tabellen trägt Task 1 ein, T4b-Tabellen Task 23, T4c-Tabellen Task 34. Sperren mehrere Zeilen, dann in fester Reihenfolge `Colony` → `Suggestion` → `Task` → `Queen` → `AutomationRule` → übrige Tabellen in Map-Reihenfolge, innerhalb einer Tabelle Schlüssel aufsteigend (die Map steht in genau dieser Reihenfolge). Compare-and-Set bleibt zusätzlich, wo es im Plan steht. Reine Ein-Anweisungs-Updates ohne gelesenen Vorzustand (`updateMany … where status = …`, Lease-SQL, Claims) brauchen keine zusätzliche Sperre.
|
- **Read-modify-write nur unter Zeilensperre:** Jede Änderung, die auf dem bisherigen Zustand aufbaut (Statuswechsel, Differenzbuchung, Zusammenführen mit gespeicherten Werten, Audit mit `beforeData`), liest `before` **innerhalb** der Transaktion nach `lockRowForUpdate(tx, table, id)` aus `src/prisma/row-lock.ts` – nie aus einem Lesen vor der Transaktion. Neue Tabellen kommen in die feste Bezeichner-Map `LOCKABLE_TABLES` (nie ein Tabellenname aus Eingaben); T4a-Tabellen trägt Task 1 ein, T4b-Tabellen Task 23, T4c-Tabellen Task 34. Sperren mehrere Zeilen, dann in fester Reihenfolge `Colony` → `Suggestion` → `Task` → `Queen` → `AutomationRule` → übrige Tabellen in Map-Reihenfolge, innerhalb einer Tabelle Schlüssel aufsteigend (die Map steht in genau dieser Reihenfolge). Mehrere Zeilen einer Tabelle sperrt `lockRowsForUpdate(tx, table, ids)` (eine Anweisung, Schlüssel aufsteigend), nie eine Schleife über `lockRowForUpdate`. Compare-and-Set bleibt zusätzlich, wo es im Plan steht. Reine Ein-Anweisungs-Updates ohne gelesenen Vorzustand (`updateMany … where status = …`, Lease-SQL, Claims) brauchen keine zusätzliche Sperre.
|
||||||
- **Sperren bei Vorschlägen:** `SuggestionsService.proposeIn` sperrt zuerst die Volkszeile (`lockRowForUpdate(tx, 'Colony', colonyId)`, wenn `colonyId` gesetzt; `syncColonyIn` und der Handler `suggestions.colony-ended` halten sie dann schon) und danach die Regelzeile (`lockRuleIn`). Das serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, sodass die Absichts-Prüfung über Regeln hinweg keine Dublette durchlässt; die Reihenfolge entspricht der Map (`Colony` vor `AutomationRule`). Kein Pfad sperrt erst `AutomationRule` und dann `Colony`; `accept`, `dismiss`, `reopen` und `apply-cleanup` sperren `Suggestion` → `Task` → `Queen` und nie `Colony`.
|
- **Sperren bei Vorschlägen:** `SuggestionsService.proposeIn` sperrt zuerst die Volkszeile (`lockRowForUpdate(tx, 'Colony', colonyId)`, wenn `colonyId` gesetzt; `syncColonyIn` und der Handler `suggestions.colony-ended` halten sie dann schon) und danach die Regelzeile (`lockRuleIn`). Das serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, sodass die Absichts-Prüfung über Regeln hinweg keine Dublette durchlässt; die Reihenfolge entspricht der Map (`Colony` vor `AutomationRule`). Kein Pfad sperrt erst `AutomationRule` und dann `Colony`; `accept`, `dismiss`, `reopen` und `apply-cleanup` sperren `Suggestion` → `Task` → `Queen` und nie `Colony`.
|
||||||
- Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20); jede neue oder geänderte Update-DTO steht in `src/common/validation/update-dto-null-handling.spec.ts`.
|
- Jedes Feld bleibt bearbeitbar (Null-Regel, Annahme 20); jede neue oder geänderte Update-DTO steht in `src/common/validation/update-dto-null-handling.spec.ts`.
|
||||||
- Handler schreiben nur über `tx`, keine Netzaufrufe; Handler-IDs `<consumer>.<name>`; jedes Modul mit Handlern wird von `AppModule` **und** `WorkerModule` importiert. Payloads und `evidence` tragen nur IDs, Enum-Werte, Zahlen, Tage.
|
- Handler schreiben nur über `tx`, keine Netzaufrufe; Handler-IDs `<consumer>.<name>`; jedes Modul mit Handlern wird von `AppModule` **und** `WorkerModule` importiert. Payloads und `evidence` tragen nur IDs, Enum-Werte, Zahlen, Tage.
|
||||||
|
- **Rechte-Auflösung in Worker und Jobs:** `PermissionResolverService` kommt in der API aus dem globalen `PermissionsModule`, im Worker und in den Jobs aus dem globalen, resolver-only `JobPermissionsModule` (`src/jobs/jobs.module.ts`; Task 1 exportiert die Klasse, `WorkerModule` importiert sie ab Task 4, `JobsModule` hat sie schon). Kein Feature-Modul importiert `PermissionsModule` (sein `PermissionCatalogSyncService` schriebe 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.
|
- 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.
|
- 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.
|
- TDD: zuerst der fehlschlagende Test. Testnamen englisch.
|
||||||
@@ -203,8 +204,9 @@
|
|||||||
Geteilte Dateien, deshalb je Welle nur ein Task:
|
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)
|
- `prisma/schema.prisma`, `src/backup/backup.service.ts` (+ Spec): 1 (W1), 2 (W2), 3 (W3), 23 (W7), 34 (W8)
|
||||||
- `src/prisma/row-lock.ts` (+ Spec): 1 (W1), 23 (W7), 34 (W8) – alle übrigen Tasks nutzen den Helfer nur
|
- `src/prisma/row-lock.ts` (+ Spec): 1 (W1), 23 (W7), 34 (W8) – alle übrigen Tasks nutzen den Helfer nur
|
||||||
- `src/app.module.ts`, `src/worker/worker.module.ts`: 4 (W2), 14 (W4), 12 (W5), 16 (W6), 25 (W8), 35 (W9, nur Worker)
|
- `src/app.module.ts`, `src/worker/worker.module.ts`: 4 (W2; Worker importiert dort auch `JobPermissionsModule`), 14 (W4), 12 (W5), 16 (W6), 25 (W8), 35 (W9, nur Worker)
|
||||||
- `src/jobs/jobs.module.ts`, `src/jobs/daily-evaluation.job.ts`: 13 (W2), 14 (W4), 16 (W6), 25 (W8)
|
- `src/jobs/jobs.module.ts`: 1 (W1, nur `export` von `JobPermissionsModule`), 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/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/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/colonies/colonies.service.ts`: 10 (W3), 8 (W4), 9 (W5)
|
||||||
@@ -219,7 +221,7 @@ Geteilte Dateien, deshalb je Welle nur ein Task:
|
|||||||
- `src/autopilot/*`: 14 (W4), 15 (W5), 27 (W9); `src/season/season.service.ts`: 13 (W2), 25 (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/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/worker/worker-runtime.service.ts`: 24 (W8), 35 (W9), 36 (W10)
|
||||||
- `src/treatments/withdrawal-window.ts`, `src/treatments/treatments.service.ts`: nur 6 (W3); `src/jobs/daily-evaluation.rules.ts`: 13 (W2), 6 (W3) – Task 12 fasst sie nicht mehr an
|
- `src/treatments/withdrawal-window.ts`, `src/treatments/treatments.service.ts`: nur 6 (W3); Task 12 fasst weder sie noch `daily-evaluation.rules.ts` an
|
||||||
- `src/suggestions/suggestions.service.ts`: 4 (W2), 7 (W4); `src/jobs/cleanup.job.ts`: 24 (W8), 35 (W9); `src/auth/permissions/permission-resolver.service.ts`: nur 35 (W9)
|
- `src/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)
|
- `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)
|
- `.env.example`, `deploy/k8s/**`: 24 (W8), 37 (W10)
|
||||||
@@ -239,7 +241,7 @@ Geteilte Dateien, deshalb je Welle nur ein Task:
|
|||||||
- Create: `prisma/migrations/20261014100000_t4_enum_values/migration.sql`, `prisma/migrations/20261014100100_t4_suggestions/migration.sql`
|
- 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/common/dates.ts` (+ `.spec.ts`), `src/season/season-types.ts`
|
||||||
- Create: `src/automation/rule-config/` mit `rule-config.types.ts`, `parse-helpers.ts`, `default-rules.ts`, `simple.config.ts`, `varroa-threshold.config.ts`, `inspection-interval.config.ts`, `chain.config.ts`, `split-schedule.config.ts`, `water-content.config.ts`, `season-autopilot.config.ts`, `phenology.config.ts`, `rule-config.reader.ts`, `index.ts`, `rule-config.spec.ts`
|
- Create: `src/automation/rule-config/` mit `rule-config.types.ts`, `parse-helpers.ts`, `default-rules.ts`, `simple.config.ts`, `varroa-threshold.config.ts`, `inspection-interval.config.ts`, `chain.config.ts`, `split-schedule.config.ts`, `water-content.config.ts`, `season-autopilot.config.ts`, `phenology.config.ts`, `rule-config.reader.ts`, `index.ts`, `rule-config.spec.ts`
|
||||||
- Modify: `src/automation/automation.service.ts` (`DEFAULT_RULES` aus `default-rules.ts`; `update` validiert und liest `before` unter Sperre), `src/automation/varroa-threshold.ts` (+ Spec), `src/calendar-sync/calendar-sync.service.ts` (`WITHDRAWAL` vorläufig aus), `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec; T4a-Tabellen, Schlüsselspalte je Eintrag)
|
- Modify: `src/automation/automation.service.ts` (`DEFAULT_RULES` aus `default-rules.ts`; `update` validiert und liest `before` unter Sperre), `src/automation/varroa-threshold.ts` (+ Spec), `src/calendar-sync/calendar-sync.service.ts` (`WITHDRAWAL` vorläufig aus), `src/backup/backup.service.ts` (+ Spec), `src/prisma/row-lock.ts` (+ Spec; T4a-Tabellen, Schlüsselspalte je Eintrag, auch für `lockRowsForUpdate`/`lockRowsForShare`), `src/jobs/jobs.module.ts` (nur `JobPermissionsModule` wird `export`iert, damit `WorkerModule` ihn ab Task 4 importieren kann)
|
||||||
- Test: `test/automation.e2e-spec.ts`, neu `test/t4-schema.e2e-spec.ts`
|
- Test: `test/automation.e2e-spec.ts`, neu `test/t4-schema.e2e-spec.ts`
|
||||||
- Docs: ARCHITECTURE §8 „Export/Import“: `suggestion` ist Teil des Bundles (Import vor `task`).
|
- Docs: ARCHITECTURE §8 „Export/Import“: `suggestion` ist Teil des Bundles (Import vor `task`).
|
||||||
|
|
||||||
@@ -316,8 +318,10 @@ export function readRule<K extends AutomationTriggerType>(db: DbClient, trigger:
|
|||||||
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
|
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:
|
// 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).
|
// Suggestion, Queen, AutomationRule ("id") und NotificationPreference (Schlüssel "userId"; Tabelle entsteht in Task 3).
|
||||||
// Map-Reihenfolge = Sperr-Reihenfolge: Colony, Suggestion, Task, Queen, AutomationRule, dann die bisherigen übrigen, NotificationPreference
|
// Map-Reihenfolge = Sperr-Reihenfolge: Colony, Suggestion, Task, Queen, AutomationRule, dann die bisherigen übrigen
|
||||||
export function lockRowForUpdate(tx: Prisma.TransactionClient, table: LockableTable, id: string): Promise<void>; // Signatur unverändert
|
// (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**
|
- [ ] **Schritt 1: Failing Tests**
|
||||||
@@ -355,7 +359,7 @@ it('lists the nine new rules with their defaults', ...);
|
|||||||
// t4-schema.e2e-spec.ts
|
// t4-schema.e2e-spec.ts
|
||||||
it('keeps at most one suggestion per dedupe key', ...); // createMany skipDuplicates zweimal -> count 1, dann 0
|
it('keeps at most one suggestion per dedupe key', ...); // createMany skipDuplicates zweimal -> count 1, dann 0
|
||||||
// row-lock.spec.ts
|
// row-lock.spec.ts
|
||||||
it('locks NotificationPreference by "userId" and every other table by "id"', ...); // SQL-Text prüfen, id als Parameter
|
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', ...);
|
it('rejects a table outside the map', ...);
|
||||||
// automation.service.spec.ts
|
// automation.service.spec.ts
|
||||||
it('update locks the rule row and reads before inside the transaction', ...);
|
it('update locks the rule row and reads before inside the transaction', ...);
|
||||||
@@ -368,7 +372,8 @@ it('exports and restores suggestions with their decision before tasks', ...);
|
|||||||
- `berlinToday`/`berlinDayOf` über `Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year, month, day })`; `berlinDateTime` bestimmt den Offset am Zieltag (zweimal anwenden, damit der Umstellungstag stimmt).
|
- `berlinToday`/`berlinDayOf` über `Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year, month, day })`; `berlinDateTime` bestimmt den Offset am Zieltag (zweimal anwenden, damit der Umstellungstag stimmt).
|
||||||
- Parser: `parse-helpers.ts` mit `intIn`, `numberIn`, `bool`, `monthDay`, `months`, `enumList`; `parse` fällt je Schlüssel auf den Standard zurück und loggt eine Warnung mit Trigger und Feld (ohne Wert); `validate` meldet nur vorhandene bekannte Schlüssel.
|
- Parser: `parse-helpers.ts` mit `intIn`, `numberIn`, `bool`, `monthDay`, `months`, `enumList`; `parse` fällt je Schlüssel auf den Standard zurück und loggt eine Warnung mit Trigger und Feld (ohne Wert); `validate` meldet nur vorhandene bekannte Schlüssel.
|
||||||
- `automation.service.ts`: `DEFAULT_RULES` importieren; `update` läuft in einer Transaktion: `lockRowForUpdate(tx, 'AutomationRule', id)`, `before` lesen (404 wie bisher), bei einem Objekt `validateRuleConfig(before.triggerType, dto.config)` (Issues → `BadRequestException({ message: 'Die Einstellung ist ungültig.', issues })`), schreiben, Audit mit `tx`.
|
- `automation.service.ts`: `DEFAULT_RULES` importieren; `update` läuft in einer Transaktion: `lockRowForUpdate(tx, 'AutomationRule', id)`, `before` lesen (404 wie bisher), bei einem Objekt `validateRuleConfig(before.triggerType, dto.config)` (Issues → `BadRequestException({ message: 'Die Einstellung ist ungültig.', issues })`), schreiben, Audit mit `tx`.
|
||||||
- `row-lock.ts`: Einträge mit Schlüsselspalte, neue Tabellen laut Interfaces; bestehende Aufrufer unverändert.
|
- `row-lock.ts`: Einträge mit Schlüsselspalte, neue Tabellen laut Interfaces (die zwölf bestehenden bleiben, `Suggestion` kommt hinter `Colony`, `Queen` und `AutomationRule` hinter `Task`); alle drei Helfer lesen die Schlüsselspalte aus dem Eintrag; bestehende Aufrufer unverändert.
|
||||||
|
- `jobs.module.ts`: `class JobPermissionsModule` → `export class JobPermissionsModule` (sonst nichts).
|
||||||
- `varroaThresholdOf` liest `ruleConfigOf('VARROA_THRESHOLD', config).naturalFall.redAbove`; Signatur bleibt (Task 7 löscht die Datei).
|
- `varroaThresholdOf` liest `ruleConfigOf('VARROA_THRESHOLD', config).naturalFall.redAbove`; Signatur bleibt (Task 7 löscht die Datei).
|
||||||
- `calendar-sync.service.ts`: `WITHDRAWAL: false` im Schalter-Record und `case 'WITHDRAWAL': return null;` mit Kommentar „Schalter Task 3, Inhalt Task 12“.
|
- `calendar-sync.service.ts`: `WITHDRAWAL: false` im Schalter-Record und `case 'WITHDRAWAL': return null;` mit Kommentar „Schalter Task 3, Inhalt Task 12“.
|
||||||
- Backup: `suggestion` in Export und Import, Import vor `task`, `decidedByUserId` wie andere Nutzer-IDs aufgelöst.
|
- Backup: `suggestion` in Export und Import, Import vor `task`, `decidedByUserId` wie andere Nutzer-IDs aufgelöst.
|
||||||
@@ -538,6 +543,7 @@ it('an old bundle notification without userId is copied per active user: categor
|
|||||||
- `assertAppLink`: `/^\/(?![\/\\])[A-Za-z0-9\-._~\/?=&%]{0,299}$/`.
|
- `assertAppLink`: `/^\/(?![\/\\])[A-Za-z0-9\-._~\/?=&%]{0,299}$/`.
|
||||||
- Controller: `GET /notifications` → `findOwn`, `POST /notifications/read-all` → `markAllRead`, `POST /notifications/:id/read` → `markRead` (eine Anweisung `updateMany({ where: { id, userId } })`, `count 0` → 404; kein vorheriges Lesen).
|
- Controller: `GET /notifications` → `findOwn`, `POST /notifications/read-all` → `markAllRead`, `POST /notifications/:id/read` → `markRead` (eine Anweisung `updateMany({ where: { id, userId } })`, `count 0` → 404; kein vorheriges Lesen).
|
||||||
- `NotificationPreferencesService.update` in einer Transaktion: `getOrCreate(userId, tx)` (Upsert), `lockRowForUpdate(tx, 'NotificationPreference', userId)`, gespeicherte Werte lesen, mit dem DTO zusammenführen, Paar-Regel der Ruhezeit prüfen, schreiben.
|
- `NotificationPreferencesService.update` in einer Transaktion: `getOrCreate(userId, tx)` (Upsert), `lockRowForUpdate(tx, 'NotificationPreference', userId)`, gespeicherte Werte lesen, mit dem DTO zusammenführen, Paar-Regel der Ruhezeit prüfen, schreiben.
|
||||||
|
- Bestand: `record` (bisher `userId?`, liefert `Notification`) und `recordForPermission` (bisher ein `PermissionKey`, liefert die Zahl) bekommen die Signaturen oben; die zwei Aufrufer `automation.service.ts` (Rundmeldung über `record` ohne `userId`) und `job-run.service.ts` (`notifyFailure`) werden in diesem Task angepasst, ihre Specs und `findAll`/`markRead` (bisher `Forbidden` für fremde Meldungen, jetzt 404) ebenso. In `notifyFailure` bleiben Advisory-Lock und Tagesprüfung bestehen; sie sind ein zusätzlicher Schutz vor dem `dedupeKey`.
|
||||||
- `automation.service.ts`: die bisherige Rundmeldung wird `recordForPermission('tasks:read', { category: 'SUGGESTION', … }, tx)` (Task 7 ersetzt den Pfad).
|
- `automation.service.ts`: die bisherige Rundmeldung wird `recordForPermission('tasks:read', { category: 'SUGGESTION', … }, tx)` (Task 7 ersetzt den Pfad).
|
||||||
- `apiary-field.service.ts`: Meldungszählung nur `userId` des Aufrufers (Task 15 stellt auf Vorschläge um).
|
- `apiary-field.service.ts`: Meldungszählung nur `userId` des Aufrufers (Task 15 stellt auf Vorschläge um).
|
||||||
- `calendar-sync.service.ts`: `WITHDRAWAL: settings.syncWithdrawal`; DTO-Feld `syncWithdrawal` mit `@IsOptionalNotNull() @IsBoolean()`. `calendar-sync-settings.service.ts`: `update` bildet die Felder von Hand ab (`data: { enabled: dto.enabled, … syncQueenAssignment: dto.syncQueenAssignment }`), dort `syncWithdrawal: dto.syncWithdrawal` ergänzen; sonst bliebe der Schalter wirkungslos.
|
- `calendar-sync.service.ts`: `WITHDRAWAL: settings.syncWithdrawal`; DTO-Feld `syncWithdrawal` mit `@IsOptionalNotNull() @IsBoolean()`. `calendar-sync-settings.service.ts`: `update` bildet die Felder von Hand ab (`data: { enabled: dto.enabled, … syncQueenAssignment: dto.syncQueenAssignment }`), dort `syncWithdrawal: dto.syncWithdrawal` ergänzen; sonst bliebe der Schalter wirkungslos.
|
||||||
@@ -556,7 +562,7 @@ it('an old bundle notification without userId is copied per active user: categor
|
|||||||
|
|
||||||
**Files**
|
**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`
|
- 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`
|
- Modify: `src/tasks/tasks.service.ts` (`NewTaskInput.sourceSuggestionId`), `src/app.module.ts`, `src/worker/worker.module.ts` (importiert `SuggestionsModule` und `JobPermissionsModule` aus `../jobs/jobs.module`: Task 5 injiziert den Resolver in `SuggestionViewsService`, 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).
|
- 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**
|
**Interfaces**
|
||||||
@@ -640,10 +646,10 @@ it('falls back to INTENT_TITLES for an unknown reason code', ...);
|
|||||||
- [ ] **Schritt 2:** `npx jest src/suggestions src/tasks` → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/suggestions src/tasks` → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren**
|
- [ ] **Schritt 3: Implementieren**
|
||||||
- `proposeIn`: `assertTransactionClient`; bei gesetzter `colonyId` zuerst `lockRowForUpdate(tx, 'Colony', colonyId)` (serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, damit die Absichts-Prüfung über Regeln hinweg hält), dann `lockRuleIn(tx, ruleKey)`; `openIntentsIn` prüft offene Vorschläge (gleiche `colonyId`, sonst gleiche `relatedType` + `relatedId`) und offene Aufgaben (`OPEN`/`IN_PROGRESS`; Absicht nach Global Constraints); dann `suggestion.createManyAndReturn({ data: [row], skipDuplicates: true })` und Audit `Suggestion CREATE` mit `tx`.
|
- `proposeIn`: `assertTransactionClient`; bei gesetzter `colonyId` zuerst `lockRowForUpdate(tx, 'Colony', colonyId)` (serialisiert Regel-, Ketten- und Autopilot-Vorschläge desselben Volks, damit die Absichts-Prüfung über Regeln hinweg hält), dann `lockRuleIn(tx, ruleKey)`; `openIntentsIn` prüft offene Vorschläge (gleiche `colonyId`, sonst gleiche `relatedType` + `relatedId`) und offene Aufgaben (`OPEN`/`IN_PROGRESS`; Absicht nach Global Constraints); dann `suggestion.createManyAndReturn({ data: [row], skipDuplicates: true })` und Audit `Suggestion CREATE` mit `tx`.
|
||||||
- `accept`, `dismiss`, `reopen` und die Bulk-Varianten je in einer Transaktion: `lockRowForUpdate(tx, 'Suggestion', id)` (Bulk: IDs aufsteigend), `before` lesen, Status prüfen (sonst 409 mit aktuellem Status), CAS-Update, Audit mit `beforeData`/`afterData`.
|
- `accept`, `dismiss`, `reopen` und die Bulk-Varianten je in einer Transaktion: `lockRowForUpdate(tx, 'Suggestion', id)` (Bulk: `lockRowsForUpdate(tx, 'Suggestion', ids)`), `before` lesen, Status prüfen (sonst 409 mit aktuellem Status), CAS-Update, Audit mit `beforeData`/`afterData`.
|
||||||
- `accept`: CAS `OPEN → ACCEPTED` (`decidedAt = now()`, `decidedByUserId`), dann Aufgaben über `tasks.createInTx` – Titel aus `renderSuggestion` (mit `actionTemplate` der Regel), Beschreibung = Gründe, `priority`, `relatedType`/`relatedId`, `sourceSuggestionId`, `sourceAutomationTrigger = ruleKey`, `sourceInspectionId = evidence.inspectionId` falls vorhanden. PLAN mit `plan`: je Schritt `baseDate + offsetDays`, Titel `PLAN_STEP_TITLES[step]`, bei `windowDays` Zusatz „bis {TT.MM.}“. Mit `input.dueDate` wird die Basis so verschoben, dass der erste Schritt auf diesem Tag liegt. Ein PLAN ohne `plan` und ohne `dueDate` (Regelvorschlag ohne `dueInDays`, Spec 1) legt die Aufgabe auf heute (`berlinToday()`).
|
- `accept`: CAS `OPEN → ACCEPTED` (`decidedAt = now()`, `decidedByUserId`), dann Aufgaben über `tasks.createInTx` – Titel aus `renderSuggestion` (mit `actionTemplate` der Regel), Beschreibung = Gründe, `priority`, `relatedType`/`relatedId`, `sourceSuggestionId`, `sourceAutomationTrigger = ruleKey`, `sourceInspectionId = evidence.inspectionId` falls vorhanden. PLAN mit `plan`: je Schritt `baseDate + offsetDays`, Titel `PLAN_STEP_TITLES[step]`, bei `windowDays` Zusatz „bis {TT.MM.}“. Mit `input.dueDate` wird die Basis so verschoben, dass der erste Schritt auf diesem Tag liegt. Ein PLAN ohne `plan` und ohne `dueDate` (Regelvorschlag ohne `dueInDays`, Spec 1) legt die Aufgabe auf heute (`berlinToday()`).
|
||||||
- `resolveIn` und `expireDue` setzen `"dedupeKey" = NULL` im selben Statement wie den Statuswechsel; `accept`, `dismiss`, `reopen` und `obsoleteForColonyIn` lassen den Schlüssel stehen.
|
- `resolveIn` und `expireDue` setzen `"dedupeKey" = NULL` im selben Statement wie den Statuswechsel; `accept`, `dismiss`, `reopen` und `obsoleteForColonyIn` lassen den Schlüssel stehen.
|
||||||
- `SuggestionsModule` importiert `TasksModule`, `AuditModule`, `NotificationsModule` (Task 7 schreibt darüber die Meldung zu neuen Vorschlägen), exportiert `SuggestionsService`; in `AppModule` und `WorkerModule` importiert.
|
- `SuggestionsModule` importiert `TasksModule`, `AuditModule`, `NotificationsModule` (Task 7 schreibt darüber die Meldung zu neuen Vorschlägen), exportiert `SuggestionsService`; in `AppModule` und `WorkerModule` importiert. `WorkerModule` importiert zusätzlich das globale `JobPermissionsModule` (Global Constraints, Rechte-Auflösung); `test/worker-runtime.e2e-spec.ts` startet `WorkerModule` und deckt fehlende Abhängigkeiten auf.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 4:** → PASS.
|
||||||
- [ ] **Schritt 5: Verifikation**
|
- [ ] **Schritt 5: Verifikation**
|
||||||
- typecheck, lint, `npm test -- --ci`
|
- typecheck, lint, `npm test -- --ci`
|
||||||
@@ -856,7 +862,7 @@ it('creates exactly one automation suggestion when the handler fails once and is
|
|||||||
|
|
||||||
**Files**
|
**Files**
|
||||||
- Create: `src/colonies/colony-takeover.ts` (+ `.spec.ts`)
|
- Create: `src/colonies/colony-takeover.ts` (+ `.spec.ts`)
|
||||||
- Modify: `src/colonies/colonies.service.ts` (+ Spec; `updateIn` als Kern von `update`), `src/colonies/dto/create-colony.dto.ts` (`occupiedFrames`, `strength` veraltet), `src/inspections/inspections.service.ts` (+ Spec), `src/inspections/dto/create-inspection.dto.ts` (`applyToColony`, `colonyStrength` ≤ 40), `src/inspections/inspections.controller.ts`, `src/inspections/inspections.module.ts` (importiert `ColoniesModule`, `PermissionsModule`), `src/export/export.service.ts`, `src/import/import.service.ts` (+ Spec), `src/apiaries/apiary-field.service.ts` + `apiary-field.types.ts` (`occupiedFrames` in Zeile und Paket), `src/common/validation/update-dto-null-handling.spec.ts`
|
- Modify: `src/colonies/colonies.service.ts` (+ Spec; `updateIn` als Kern von `update`), `src/colonies/dto/create-colony.dto.ts` (`occupiedFrames`, `strength` veraltet), `src/inspections/inspections.service.ts` (+ Spec), `src/inspections/dto/create-inspection.dto.ts` (`applyToColony`, `colonyStrength` ≤ 40), `src/inspections/inspections.controller.ts`, `src/inspections/inspections.module.ts` (importiert `ColoniesModule`; der Resolver kommt global, kein `PermissionsModule`-Import), `src/export/export.service.ts`, `src/import/import.service.ts` (+ Spec), `src/apiaries/apiary-field.service.ts` + `apiary-field.types.ts` (`occupiedFrames` in Zeile und Paket), `src/common/validation/update-dto-null-handling.spec.ts`
|
||||||
- Test: neu `test/inspection-takeover.e2e-spec.ts`, neu `test/colony-strength.e2e-spec.ts`
|
- Test: neu `test/inspection-takeover.e2e-spec.ts`, neu `test/colony-strength.e2e-spec.ts`
|
||||||
- Docs: neue Annahme „Volksstärke in besetzten Waben“ (0–40, Altwert `strength` 1–10 bleibt, keine Umrechnung, Anzeige); Annahme zur Übernahme Durchsicht → Volkskarte (im selben Request, Server nimmt die Werte aus der Durchsicht, Überspring-Gründe, Brutbild → Brutstatus = Annahme).
|
- Docs: neue Annahme „Volksstärke in besetzten Waben“ (0–40, Altwert `strength` 1–10 bleibt, keine Umrechnung, Anzeige); Annahme zur Übernahme Durchsicht → Volkskarte (im selben Request, Server nimmt die Werte aus der Durchsicht, Überspring-Gründe, Brutbild → Brutstatus = Annahme).
|
||||||
|
|
||||||
@@ -970,9 +976,9 @@ it('400 for a task id that is not in the evidence', ...); // 'Nur Aufgaben und
|
|||||||
```
|
```
|
||||||
- [ ] **Schritt 2:** `npx jest src/colonies src/winter-checks src/suggestions src/tasks src/queens` und die e2e-Dateien → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/colonies src/winter-checks src/suggestions src/tasks src/queens` und die e2e-Dateien → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren**
|
- [ ] **Schritt 3: Implementieren**
|
||||||
- `dissolve`, `merge`, `updateIn` (Status aus dem DTO in `TERMINAL_COLONY_STATUSES`, Enddatum = heute) und `markDeceasedIfRequested` (Enddatum = Datum der Auswinterung) rufen `releaseQueenIn`; der Status der Königin ändert sich dabei nicht. Danach `colonyTransitionEvents(before, after, actor, { endedOn, releasedQueenId, via })` mit `via` `DISSOLVE`, `MERGE`, `EDIT` bzw. `WINTER_CHECK`; Handler und Tests lesen `endedOn` aus der Payload, nie aus der Uhr. `dissolve`, `merge` (alle Völker, `id` aufsteigend) und `markDeceasedIfRequested` sperren die Volkszeilen mit `lockRowForUpdate(tx, 'Colony', id)` und lesen den Zustand danach; `releaseQueenIn` liest `currentQueenId` erst unter dieser Sperre.
|
- `dissolve`, `merge`, `updateIn` (Status aus dem DTO in `TERMINAL_COLONY_STATUSES`, Enddatum = heute) und `markDeceasedIfRequested` (Enddatum = Datum der Auswinterung) rufen `releaseQueenIn`; der Status der Königin ändert sich dabei nicht. Danach `colonyTransitionEvents(before, after, actor, { endedOn, releasedQueenId, via })` mit `via` `DISSOLVE`, `MERGE`, `EDIT` bzw. `WINTER_CHECK`; Handler und Tests lesen `endedOn` aus der Payload, nie aus der Uhr. `dissolve`, `merge` (alle Völker in einer Anweisung) und `markDeceasedIfRequested` sperren die Volkszeilen mit `lockRowForUpdate(tx, 'Colony', id)` bzw. `lockRowsForUpdate(tx, 'Colony', ids)` und lesen den Zustand danach; `releaseQueenIn` liest `currentQueenId` erst unter dieser Sperre.
|
||||||
- `cancelIn` und `completeIn` sperren `Task`, `updateStatusIn` sperrt `Queen`, `CleanupService.apply` sperrt `Suggestion`, dann die Aufgaben (`id` aufsteigend), dann die Königin (Reihenfolge laut Global Constraints); `before` jeweils erst danach lesen.
|
- `cancelIn` und `completeIn` sperren `Task`, `updateStatusIn` sperrt `Queen`, `CleanupService.apply` sperrt `Suggestion`, dann die Aufgaben (`lockRowsForUpdate(tx, 'Task', ids)`), dann die Königin (Reihenfolge laut Global Constraints); `before` jeweils erst danach lesen.
|
||||||
- Handler: Volk über `tx`; `suggestions.obsoleteForColonyIn`; offene Aufgaben `relatedType = COLONY`; `releasedQueenId` mit Status `ACTIVE`; dann `proposeIn` mit `ruleKey COLONY_ENDED_CLEANUP`, `CLEANUP`, `DO`, `NORMAL`, `requiredPermission 'colonies:read'`, `anchor 'ended-<endedOn>'`, `evidence { colonyId, status, endedOn, via, openTaskIds, queenId }`. Regel aus → nur `OBSOLETE`.
|
- Handler: zuerst `lockRowForUpdate(tx, 'Colony', colonyId)` (Sperr-Reihenfolge Colony → Suggestion; `obsoleteForColonyIn` und `proposeIn` laufen danach), dann Volk über `tx`; `suggestions.obsoleteForColonyIn`; offene Aufgaben `relatedType = COLONY`; `releasedQueenId` mit Status `ACTIVE`; dann `proposeIn` mit `ruleKey COLONY_ENDED_CLEANUP`, `CLEANUP`, `DO`, `NORMAL`, `requiredPermission 'colonies:read'`, `anchor 'ended-<endedOn>'`, `evidence { colonyId, status, endedOn, via, openTaskIds, queenId }`. Regel aus → nur `OBSOLETE`.
|
||||||
- Text `COLONY_ENDED`: „B3 ist eingegangen (Auswinterung 12.03.). 2 Aufgaben sind noch offen.“ (Wort und Herkunft je `status`/`via`).
|
- Text `COLONY_ENDED`: „B3 ist eingegangen (Auswinterung 12.03.). 2 Aufgaben sind noch offen.“ (Wort und Herkunft je `status`/`via`).
|
||||||
- `CleanupService.apply` in einer Transaktion: CAS prüft `OPEN` und `intent = CLEANUP`; IDs nur aus `evidence`; `CANCEL` → `cancelIn`, `DONE` → `completeIn` (Audit und `TaskCompleted` wie bei einer normalen Erledigung; das private `markDoneIn` überspränge beides), `KEEP` → nichts; Königin → `updateStatusIn`; danach `resolveIn(…, 'Aufgeräumt')`.
|
- `CleanupService.apply` in einer Transaktion: CAS prüft `OPEN` und `intent = CLEANUP`; IDs nur aus `evidence`; `CANCEL` → `cancelIn`, `DONE` → `completeIn` (Audit und `TaskCompleted` wie bei einer normalen Erledigung; das private `markDoneIn` überspränge beides), `KEEP` → nichts; Königin → `updateStatusIn`; danach `resolveIn(…, 'Aufgeräumt')`.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 4:** → PASS.
|
||||||
@@ -1018,7 +1024,7 @@ it('assigning a queen leaves a calendar entry and a queen.replaced delivery afte
|
|||||||
- [ ] **Schritt 2:** `npx jest src/colonies src/queens src/calendar-sync src/webhooks src/common/after-commit-usage.spec.ts` und die e2e-Dateien → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/colonies src/queens src/calendar-sync src/webhooks src/common/after-commit-usage.spec.ts` und die e2e-Dateien → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren**
|
- [ ] **Schritt 3: Implementieren**
|
||||||
- `split`: in der Transaktion `lockRowForUpdate(tx, 'Colony', parentId)`, Muttervolk danach lesen und „lebt“ prüfen; Lineage mit `date` (Standard heute) und `queenSource`; Audit und `ColonySplit` in der Transaktion.
|
- `split`: in der Transaktion `lockRowForUpdate(tx, 'Colony', parentId)`, Muttervolk danach lesen und „lebt“ prüfen; Lineage mit `date` (Standard heute) und `queenSource`; Audit und `ColonySplit` in der Transaktion.
|
||||||
- `assignColony`: in der Transaktion zuerst `lockRowForUpdate(tx, 'Colony', …)` für Zielvolk und bisheriges Volk der Königin (`id` aufsteigend), dann `lockRowForUpdate(tx, 'Queen', id)`; Königin und Völker danach lesen und die Prüfungen (verfügbar, lebt, nicht schon zugewiesen) dort ausführen; Audit und `QueenAssigned` in der Transaktion, `assignedOn = utcDay(startDate)`; `webhooks.dispatch('queen.replaced')` und `calendarSync.enqueue` entfallen; `queen.created` bleibt Altpfad.
|
- `assignColony`: in der Transaktion zuerst `lockRowsForUpdate(tx, 'Colony', [Zielvolk, bisheriges Volk der Königin])` (eine Anweisung, Schlüssel aufsteigend), dann `lockRowForUpdate(tx, 'Queen', id)`; Königin und Völker danach lesen und die Prüfungen (verfügbar, lebt, nicht schon zugewiesen) dort ausführen; Audit und `QueenAssigned` in der Transaktion, `assignedOn = utcDay(startDate)`; `webhooks.dispatch('queen.replaced')` und `calendarSync.enqueue` entfallen; `queen.created` bleibt Altpfad.
|
||||||
- `CalendarSyncService.enqueue` (Altpfad) hat danach keine Aufrufer mehr; Task 12 entfernt ihn (gleiche Welle wie Task 3 wäre ein Dateikonflikt).
|
- `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 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.
|
- [ ] **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.
|
||||||
@@ -1075,7 +1081,7 @@ it('the handlers pass notify from the chain config to proposeIn', ...);
|
|||||||
it('nothing for an ended colony or an inactive rule', ...);
|
it('nothing for an ended colony or an inactive rule', ...);
|
||||||
```
|
```
|
||||||
- [ ] **Schritt 2:** `npx jest src/suggestions/chains` und `test/chains.e2e-spec.ts` → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/suggestions/chains` und `test/chains.e2e-spec.ts` → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren** – Handler laden über `tx`, prüfen „Volk lebt“ und `readRule(tx, …).active`, dann `suggestions.proposeIn(tx, { source: 'CHAIN', mode: 'PLAN', priority: 'NORMAL', relatedType: 'COLONY', relatedId: colonyId, colonyId, evidence: { …, baseDate } })`. Dubletten verhindert `proposeIn` über die Absicht. Reihenfolge je Durchsicht: erst auflösen, dann `proposeIn`; alle Aufrufe mit `notify: config.notify`.
|
- [ ] **Schritt 3: Implementieren** – Jeder Handler sperrt zuerst die Volkszeile (`lockRowForUpdate(tx, 'Colony', colonyId)`; das Auflösen berührt `Suggestion`-Zeilen und darf nie vor der Volkssperre liegen, sonst kehrt sich die Reihenfolge Colony → Suggestion gegenüber `syncColonyIn` um). Dann laden die Handler über `tx`, prüfen „Volk lebt“ und `readRule(tx, …).active`, dann `suggestions.proposeIn(tx, { source: 'CHAIN', mode: 'PLAN', priority: 'NORMAL', relatedType: 'COLONY', relatedId: colonyId, colonyId, evidence: { …, baseDate } })`. Dubletten verhindert `proposeIn` über die Absicht. Reihenfolge je Durchsicht: erst auflösen, dann `proposeIn`; alle Aufrufe mit `notify: config.notify`.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 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.
|
- [ ] **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.
|
||||||
|
|
||||||
@@ -1134,7 +1140,7 @@ it('a treatment with withdrawal days leaves a WITHDRAWAL calendar entry; removin
|
|||||||
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', ...);
|
it('lists WITHDRAWAL as a span with endDate for users with treatments:read', ...);
|
||||||
```
|
```
|
||||||
- [ ] **Schritt 2:** `npx jest src/treatments src/withdrawal src/common/warnings src/calendar-sync src/calendar src/jobs` und die e2e-Dateien → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/treatments src/withdrawal src/common/warnings src/calendar-sync src/calendar src/jobs` und die e2e-Dateien → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren** wie in Interfaces; `WithdrawalModule` (importiert `NotificationsModule`, `PermissionsModule`) in `AppModule` und `WorkerModule`; `HarvestsModule` und `BottlingModule` importieren `WithdrawalModule` für die Antwort-Warnungen (Moduldateien stehen in der Dateiliste oben). Das Fenster kommt aus `withdrawalWindowOf` (Task 6); `getWithdrawalStatus` und `withdrawalEndDay` rechnen dort schon darüber.
|
- [ ] **Schritt 3: Implementieren** wie in Interfaces; `WithdrawalModule` (importiert `NotificationsModule`; kein `PermissionsModule`, Global Constraints) in `AppModule` und `WorkerModule`; `HarvestsModule` und `BottlingModule` importieren `WithdrawalModule` für die Antwort-Warnungen (Moduldateien stehen in der Dateiliste oben). Das Fenster kommt aus `withdrawalWindowOf` (Task 6); `getWithdrawalStatus` und `withdrawalEndDay` rechnen dort schon darüber.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 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.
|
- [ ] **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.
|
||||||
|
|
||||||
@@ -1144,7 +1150,7 @@ it('lists WITHDRAWAL as a span with endDate for users with treatments:read', ...
|
|||||||
|
|
||||||
**Files**
|
**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)
|
- Create: `src/season/season-calendar.ts` (+ Spec), `src/season/season-segment.ts` (+ Spec), `src/season/inspection-due.ts` (+ Spec), `src/season/varroa-due.ts` (+ Spec), `src/season/season.service.ts` (+ Spec), `src/season/season.module.ts`, `src/season/queen-age-threshold.spec.ts` (Wächter), `src/jobs/daily-evaluation-steps.ts` (+ Spec), `src/jobs/steps/time-events.step.ts` (+ Spec)
|
||||||
- Modify: `src/jobs/daily-evaluation.job.ts`, `src/jobs/daily-evaluation.rules.ts` (+ Spec; `INSPECTION_INTERVAL_DAYS`, `ACTIVE_SEASON_MONTHS`, `QUEEN_AGED_MIN_YEARS` entfallen), `src/jobs/jobs.module.ts`, `src/system/job-run.service.ts` (+ Spec; `JobPartialFailure`), `src/dashboard/dashboard.service.ts` (+ Spec; `OLD_QUEEN_AGE_YEARS` entfällt)
|
- 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_DAYS` samt `colonyCyclesDue`/`latestCycleOccurrence`/`inActiveSeason` entfallen; `queenAged`, `varroaMeasurementDue` und `inspectionOverdue` in `DAILY_EVALUATION_RULES` rechnen neu über `SeasonService` und die Regeln, `withdrawalEnded` und `taskDue` bleiben; `CATCH_UP_DAYS`, `catchUpEvents`, `inCatchUpWindow` bleiben), `src/jobs/jobs.module.ts`, `src/system/job-run.service.ts` (+ Spec; `JobPartialFailure`), `src/dashboard/dashboard.service.ts` (+ Spec; `OLD_QUEEN_AGE_YEARS` entfällt)
|
||||||
- Test: `test/daily-evaluation.e2e-spec.ts`
|
- 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.
|
- 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.
|
||||||
|
|
||||||
@@ -1166,7 +1172,8 @@ export type DailyEvaluationStepName = (typeof DAILY_EVALUATION_STEP_ORDER)[numbe
|
|||||||
export interface DailyEvaluationStep { readonly name: DailyEvaluationStepName; run(today: string): Promise<JobResult> }
|
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 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
|
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
|
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): `InspectionOverdue` am Tag nach `inspectionDueOn`, nie in `WINTERRUHE`; `VarroaMeasurementDue` am `dueOn` aus `varroaMeasurementDue`; `QueenAged` nicht mehr nur am `<Jahr>-01-01` (das Nachholfenster von 7 Tagen ließ eine später zugewiesene oder später gealterte Königin das ganze Jahr aus; Entscheidung zum Übertrag aus Rückgrat Task 18): an jedem Jobtag für jedes lebende Volk, dessen aktuelle Königin `isQueenAged(hatchYear, heute, queenMaxAgeYears)` ist, einmal je Königin und Jahr – Schlüssel `dailyDedupeKey('QueenAged', queenId, '<Jahr>-01-01')` (Jahresanker), Ereignis am Jobtag; das deckt Zuweisung im Jahr, Wechsel des Schlupfjahres und Jahreswechsel ab. Fälligkeiten im Winter (November–Februar) bleiben ohne Ereignis in `WINTERRUHE`; `inspectionDueOn` setzt sie auf den Beginn von `FRUEHJAHR` („Erste Durchsicht im Jahr“), `InspectionOverdue` folgt am Tag danach und A1 (Task 14) schlägt `INSPECTION_FIRST_OF_YEAR` vor; der Saisonbeginn selbst erscheint als Meldung `season-segment` (Task 26), eine feste Märzmeldung entfällt; `WithdrawalEnded` unverändert.
|
- Regeln `time-events` (Nachholfenster 7 Tage, `dailyDedupeKey`, je Typ eine Transaktion wie bisher): `InspectionOverdue` am Tag nach `inspectionDueOn`, nie in `WINTERRUHE`; `VarroaMeasurementDue` am `dueOn` aus `varroaMeasurementDue`; `QueenAged` nicht mehr nur am `<Jahr>-01-01` (das Nachholfenster von 7 Tagen ließ eine später zugewiesene oder später gealterte Königin das ganze Jahr aus; Entscheidung zum Übertrag aus Rückgrat Task 18): an jedem Jobtag für jedes lebende Volk, dessen aktuelle Königin `isQueenAged(hatchYear, heute, queenMaxAgeYears)` ist, einmal je Königin und Jahr – Schlüssel `dailyDedupeKey('QueenAged', queenId, '<Jahr>-01-01')` (Jahresanker), Ereignis am Jobtag; das deckt Zuweisung im Jahr, Wechsel des Schlupfjahres und Jahreswechsel ab. Fälligkeiten im Winter (November–Februar) bleiben ohne Ereignis in `WINTERRUHE`; `inspectionDueOn` setzt sie auf den Beginn von `FRUEHJAHR` („Erste Durchsicht im Jahr“), `InspectionOverdue` folgt am Tag danach und A1 (Task 14) schlägt `INSPECTION_FIRST_OF_YEAR` vor; der Saisonbeginn selbst erscheint als Meldung `season-segment` (Task 26), eine feste Märzmeldung entfällt; `WithdrawalEnded` unverändert.
|
||||||
- A2-Winterfenster: Abschnitt `WINTERRUHE`, heute zwischen `11-15` und `12-20`, keine Messung seit `11-01` → `dueOn = <Jahr>-11-15`, `anchor = 'winter-<Jahr>'`; sonst außerhalb `WINTERRUHE` `(letzte Messung oder createdOn) + varroaMeasureIntervalDays`, `anchor` = Mess-ID oder `'none'`.
|
- A2-Winterfenster: Abschnitt `WINTERRUHE`, heute zwischen `11-15` und `12-20`, keine Messung seit `11-01` → `dueOn = <Jahr>-11-15`, `anchor = 'winter-<Jahr>'`; sonst außerhalb `WINTERRUHE` `(letzte Messung oder createdOn) + varroaMeasureIntervalDays`, `anchor` = Mess-ID oder `'none'`.
|
||||||
@@ -1206,7 +1213,8 @@ it('InspectionOverdue follows the segment interval', async () => { await app.get
|
|||||||
```
|
```
|
||||||
- [ ] **Schritt 2:** `npx jest src/season src/jobs src/system src/dashboard` und die e2e-Datei → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/season src/jobs src/system src/dashboard` und die e2e-Datei → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren**
|
- [ ] **Schritt 3: Implementieren**
|
||||||
- `DailyEvaluationJob.run()`: `today = berlinToday()`, Schritte in `DAILY_EVALUATION_STEP_ORDER` (Konstruktor injiziert die vorhandenen Schritte; Tasks 14, 16, 25 ergänzen ihre), `runDailyEvaluationSteps`; bei `failed.length > 0` → `throw new JobPartialFailure(result, failed)`; `JobRunService.run` speichert dann `result` und `error`.
|
- `time-events.step.ts` verschiebt `evaluate`/`evaluateRule` aus `DailyEvaluationJob` (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 in `daily-evaluation.job.spec.ts` und `test/daily-evaluation.e2e-spec.ts` rufen `TimeEventsStep.run('<Tag>')` bzw. den Job.
|
||||||
|
- `DailyEvaluationJob.run()`: statt `SELECT now()` (UTC) `today = berlinToday()`, Schritte in `DAILY_EVALUATION_STEP_ORDER` (Konstruktor injiziert die vorhandenen Schritte; Tasks 14, 16, 25 ergänzen ihre), `runDailyEvaluationSteps`; bei `failed.length > 0` → `throw new JobPartialFailure(result, failed)`; `JobRunService.run` speichert dann `result` und `error`.
|
||||||
- `SeasonService.statesFor` liest `PHENOLOGY` einmal und baut `calendarSeasonState` je Stand.
|
- `SeasonService.statesFor` liest `PHENOLOGY` einmal und baut `calendarSeasonState` je Stand.
|
||||||
- Dashboard liest `queenMaxAgeYears` über `readRule(prisma, 'SEASON_AUTOPILOT')`.
|
- Dashboard liest `queenMaxAgeYears` über `readRule(prisma, 'SEASON_AUTOPILOT')`.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 4:** → PASS.
|
||||||
@@ -1237,7 +1245,7 @@ export interface AutopilotInput {
|
|||||||
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
|
winterChecks: { winterYear: number; season: WinterCheckSeason }[];
|
||||||
season: SeasonState;
|
season: SeasonState;
|
||||||
}
|
}
|
||||||
export interface AutopilotConfigs { autopilot: ActiveRule<'SEASON_AUTOPILOT'>; interval: ActiveRule<'INSPECTION_INTERVAL'>; phenology: PhenologyConfig }
|
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;
|
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 }
|
reasonCode: string; evidence: SuggestionEvidence; dueDate: string; priority: TaskPriority; anchor: string; requiredPermission: PermissionKey }
|
||||||
export type AutopilotRule = (input: AutopilotInput, configs: AutopilotConfigs, today: string) => AutopilotCandidate | null;
|
export type AutopilotRule = (input: AutopilotInput, configs: AutopilotConfigs, today: string) => AutopilotCandidate | null;
|
||||||
@@ -1251,8 +1259,8 @@ AutopilotStep.run(today): Promise<JobResult>; // { autopilotColonies,
|
|||||||
SuggestionsExpiryStep.run(today): Promise<JobResult>; // { suggestionsExpired }
|
SuggestionsExpiryStep.run(today): Promise<JobResult>; // { suggestionsExpired }
|
||||||
```
|
```
|
||||||
- `autopilot.colony-sync` hört auf: `InspectionRecorded`, `InspectionCorrected`, `VarroaMeasured`, `VarroaCorrected`, `TreatmentRecorded`, `TreatmentCorrected`, `FeedingRecorded`, `FeedingCorrected`, `HarvestRecorded`, `HarvestCorrected` (je Volk), `ColonyStatusChanged`, `ColonySplit` (Ableger und Muttervolk), `QueenAssigned` (Volk und `previousColonyId`), `TaskCompleted`, `TaskUpdated` (Aufgabe über `tx` laden, nur `relatedType = COLONY`), `WithdrawalEnded`, `QueenAged` (`colonyId`), `InspectionOverdue`, `VarroaMeasurementDue`. `today = berlinToday()`.
|
- `autopilot.colony-sync` hört auf: `InspectionRecorded`, `InspectionCorrected`, `VarroaMeasured`, `VarroaCorrected`, `TreatmentRecorded`, `TreatmentCorrected`, `FeedingRecorded`, `FeedingCorrected`, `HarvestRecorded`, `HarvestCorrected` (je Volk), `ColonyStatusChanged`, `ColonySplit` (Ableger und Muttervolk), `QueenAssigned` (Volk und `previousColonyId`), `TaskCompleted`, `TaskUpdated` (Aufgabe über `tx` laden, nur `relatedType = COLONY`), `WithdrawalEnded`, `QueenAged` (`colonyId`), `InspectionOverdue`, `VarroaMeasurementDue`. `today = berlinToday()`.
|
||||||
- Regeln: A1 `INSPECT`/`DO`/`INSPECTION_INTERVAL`, Bedingung `segment ≠ WINTERRUHE` und `dueOn ≤ heute + 2`, Fälligkeit `dueOn`, `HIGH` wenn `dueOn < heute`, Anker letzte Durchsicht-ID oder `none`, reasonCode `INSPECTION_DUE` bzw. `INSPECTION_FIRST_OF_YEAR`. A2 `VARROA_MEASURE`/`DO` aus `varroaMeasurementDue`, reasonCode `VARROA_MEASURE_DUE` bzw. `VARROA_WINTER_MEASURE`. A3 `VARROA_RECHECK`/`DO`, Behandlungsende vor ≥ `recheckAfterDays`, keine Messung seither, Fälligkeit `endDay + 14`, Anker Behandlungs-ID, reasonCode `VARROA_RECHECK_DUE`. Alle `expiresAt = dueDate + suggestionTtlDays`.
|
- Regeln: A1 `INSPECT`/`DO`/`INSPECTION_INTERVAL`, Bedingung `segment ≠ WINTERRUHE` und `dueOn ≤ heute + 2`, Fälligkeit `dueOn`, `HIGH` wenn `dueOn < heute`, Anker letzte Durchsicht-ID oder `none`, reasonCode `INSPECTION_DUE` bzw. `INSPECTION_FIRST_OF_YEAR`. A2 `VARROA_MEASURE`/`DO` aus `varroaMeasurementDue`, reasonCode `VARROA_MEASURE_DUE` bzw. `VARROA_WINTER_MEASURE`. A3 `VARROA_RECHECK`/`DO`, Behandlungsende vor ≥ `varroa.config.recheckAfterDays` (Standard 14), keine Messung seither, Fälligkeit `endDay + recheckAfterDays`, Anker Behandlungs-ID, reasonCode `VARROA_RECHECK_DUE`. Alle `expiresAt = dueDate + suggestionTtlDays`.
|
||||||
- Abgleich exakt Spec 8.3: Volkszeile über `lockRowForUpdate(tx, 'Colony', colonyId)`, Eingaben erst danach laden; beendet → Ende; `SEASON_AUTOPILOT` aus → alle offenen `AUTOPILOT` → `RESOLVED` („Autopilot aus“); Kandidat mit offenem Vorschlag gleichen Schlüssels → `refreshIn`; sonst offene Sache gleicher Absicht → verwerfen; sonst `proposeIn`; offene Autopilot-Vorschläge ohne Kandidat → `RESOLVED` („Bedingung entfällt“). `resolveIn` und `expireDue` geben den `dedupeKey` frei (Task 4), sodass dieselbe Bedingung nach Ablauf oder Erledigung neu vorgeschlagen werden darf; `DISMISSED` hält ihn. Regel- und Kettenvorschläge bleiben unberührt.
|
- Abgleich exakt Spec 8.3: Volkszeile über `lockRowForUpdate(tx, 'Colony', colonyId)`, Eingaben erst danach laden; beendet → Ende; `SEASON_AUTOPILOT` aus → alle offenen `AUTOPILOT` → `RESOLVED` („Autopilot aus“); Kandidat mit offenem Vorschlag gleichen Schlüssels → `refreshIn`; sonst offene Sache gleicher Absicht → verwerfen; sonst `proposeIn` (Kandidaten immer in der Reihenfolge A1…A10, damit die Regelsperren von `proposeIn` bei parallelen Abgleichen in gleicher Reihenfolge fallen); offene Autopilot-Vorschläge ohne Kandidat → `RESOLVED` („Bedingung entfällt“). `resolveIn` und `expireDue` geben den `dedupeKey` frei (Task 4), sodass dieselbe Bedingung nach Ablauf oder Erledigung neu vorgeschlagen werden darf; `DISMISSED` hält ihn. Regel- und Kettenvorschläge bleiben unberührt.
|
||||||
|
|
||||||
- [ ] **Schritt 1: Failing Tests**
|
- [ ] **Schritt 1: Failing Tests**
|
||||||
```ts
|
```ts
|
||||||
@@ -1397,7 +1405,7 @@ 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', ...);
|
it('the digest of a user without varroa or inspection rights has no such line', ...);
|
||||||
```
|
```
|
||||||
- [ ] **Schritt 2:** `npx jest src/reminders` und die e2e-Datei → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/reminders` und die e2e-Datei → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren** wie oben; `DigestService` lädt die Daten einmal je Lauf und filtert je Nutzer. `RemindersModule` importiert `PermissionsModule` (für `PermissionResolverService`): `WorkerModule` hat keinen globalen Resolver, und `DigestService` braucht ihn beim Start; in `AppModule` ist `PermissionsModule` ohnehin global, in `JobsModule` liefert `JobPermissionsModule` einen eigenen Resolver (der zusätzliche Import ändert dort nichts am Verhalten; `PermissionCatalogSyncService.onModuleInit` ist idempotent, `skipDuplicates`). `test/worker-runtime.e2e-spec.ts` startet `WorkerModule` und löst damit die Abhängigkeit auf.
|
- [ ] **Schritt 3: Implementieren** wie oben; `DigestService` lädt die Daten einmal je Lauf und filtert je Nutzer. Er injiziert `PermissionResolverService`, und `RemindersModule` importiert dafür **kein** `PermissionsModule` (Global Constraints, Rechte-Auflösung): in `AppModule` ist `PermissionsModule` global, in `WorkerModule` und `JobsModule` das resolver-only `JobPermissionsModule` (aus `src/jobs/jobs.module.ts`, `WorkerModule` importiert es seit Task 4). `RemindersModule` wird von `AppModule`, `WorkerModule` (Handler `notifications.task-due`) und `JobsModule` (`DigestStep`) importiert. `test/worker-runtime.e2e-spec.ts` startet `WorkerModule` und löst die Abhängigkeit auf.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **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.
|
- [ ] **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.
|
||||||
|
|
||||||
@@ -1477,6 +1485,7 @@ export const SEGMENT_LABELS: Record<SeasonSegment, string>; // WINTERRUHE 'Wint
|
|||||||
export function seasonChipText(s: { segment: SeasonSegment; intervalDays: number | null }): string; // 'Schwarmzeit · alle 7 Tage'
|
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;
|
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
|
// 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?
|
||||||
```
|
```
|
||||||
- `NextStepsCard` oben auf der Volksseite: höchstens 3 Schritte mit Titel, ≤ 2 Gründen, `DueChip`, Aktion (`CAPTURE` → Link mit `colonyId`, `ACCEPT` → Übernehmen, `CLEANUP` → `CleanupSheet`, `OPEN_TASK` → Link), Hinweis „Wartezeit bis {TT.MM.}“ aus `withdrawalUntil`.
|
- `NextStepsCard` oben auf der Volksseite: höchstens 3 Schritte mit Titel, ≤ 2 Gründen, `DueChip`, Aktion (`CAPTURE` → Link mit `colonyId`, `ACCEPT` → Übernehmen, `CLEANUP` → `CleanupSheet`, `OPEN_TASK` → Link), Hinweis „Wartezeit bis {TT.MM.}“ aus `withdrawalUntil`.
|
||||||
- Heute: „Nächster Schritt“ aus `GET /autopilot/today` (offline wie bisher aus Aufgaben); Karte „Durchsicht fällig“ je Stand mit Völkerliste und „Runde starten“ (`startRound` nur mit den fälligen Völkern, nach Code); Chips „Vorschläge {n}“ und Saison je Stand.
|
- Heute: „Nächster Schritt“ aus `GET /autopilot/today` (offline wie bisher aus Aufgaben); Karte „Durchsicht fällig“ je Stand mit Völkerliste und „Runde starten“ (`startRound` nur mit den fälligen Völkern, nach Code); Chips „Vorschläge {n}“ und Saison je Stand.
|
||||||
@@ -1731,7 +1740,7 @@ it('an old bundle without initialAmount is recalculated like migration 6', ...);
|
|||||||
|
|
||||||
**Files**
|
**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`
|
- Create: `src/weather/history/weather-history-source.ts`, `src/weather/history/weather-history.config.ts` (+ Spec), `src/weather/history/open-meteo-history.source.ts` (+ Spec), `src/weather/history/weather-history.service.ts` (+ Spec), `src/weather/history/weather-history.module.ts`, `test/weather-history.e2e-spec.ts`
|
||||||
- Modify: `src/worker/worker-runtime.service.ts` (+ Spec; Schleifen nach Name, Weckkanal optional), `src/worker/worker-runtime.module.ts`, `src/weather/weather.service.ts` (+ Spec; gleiche URL- und Schlüssel-Variablen), `src/jobs/cleanup.job.ts` (+ Spec), `.env.example`, `deploy/k8s/overlays/encry/configmap-backend.yaml`
|
- 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 Konstante `OPEN_METEO_FORECAST_URL` entfällt, URL und Schlüssel kommen aus `weatherHistoryConfig()`, 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`.
|
- 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**
|
**Interfaces**
|
||||||
@@ -1797,7 +1806,7 @@ PhenologyStateService.recomputeIn(tx: Prisma.TransactionClient, apiaryId: string
|
|||||||
PhenologyStateService.recomputeAll(today: string): Promise<{ apiaries: number; changes: number }>; // je Stand eine Transaktion
|
PhenologyStateService.recomputeAll(today: string): Promise<{ apiaries: number; changes: number }>; // je Stand eine Transaktion
|
||||||
PhenologyStep.run(today: string): Promise<JobResult>; // { phenologyApiaries, seasonChanges }
|
PhenologyStep.run(today: string): Promise<JobResult>; // { phenologyApiaries, seasonChanges }
|
||||||
```
|
```
|
||||||
- `recomputeIn`: zuerst `lockRowForUpdate(tx, 'Apiary', apiaryId)`, dann den bisherigen `ApiaryPhenology`-Zustand lesen (mehrere Stände: `id` aufsteigend); Meldungen des Jahres für den Stand und für alle Stände (`apiaryId null`), ohne zurückgezogene; GTS aus `ApiaryWeatherDay` bis gestern; GTS älter als 3 Tage → nicht benutzt (`WEATHER_STALE`); ohne Koordinaten `NO_COORDINATES`; Schwellen = `gtsThresholds` (`useGts`) plus Kalibrierung für `calibratePhases` (`calibrateFromReports`); Kalender nur für Phasen ohne GTS-Schwelle oder ohne Wetterdaten. Maximum der drei, nie rückwärts außer `allowBackward`. Upsert `ApiaryPhenology (apiaryId, year)`; bei geänderter Phase oder Abschnitt `SeasonPhaseChanged` mit `dailyDedupeKey('SeasonPhaseChanged', apiaryId, today)`; `segmentSince` = heute bei Wechsel.
|
- `recomputeIn`: zuerst `lockRowForUpdate(tx, 'Apiary', apiaryId)`, dann den bisherigen `ApiaryPhenology`-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 aus `ApiaryWeatherDay` bis gestern; GTS älter als 3 Tage → nicht benutzt (`WEATHER_STALE`); ohne Koordinaten `NO_COORDINATES`; Schwellen = `gtsThresholds` (`useGts`) plus Kalibrierung für `calibratePhases` (`calibrateFromReports`); Kalender nur für Phasen ohne GTS-Schwelle oder ohne Wetterdaten. Maximum der drei, nie rückwärts außer `allowBackward`. Upsert `ApiaryPhenology (apiaryId, year)`; bei geänderter Phase oder Abschnitt `SeasonPhaseChanged` mit `dailyDedupeKey('SeasonPhaseChanged', apiaryId, today)`; `segmentSince` = heute bei Wechsel.
|
||||||
- `SeasonService.statesFor`: Zeile des laufenden Jahres, wenn vorhanden, sonst Kalender (T4a-Verhalten).
|
- `SeasonService.statesFor`: Zeile des laufenden Jahres, wenn vorhanden, sonst Kalender (T4a-Verhalten).
|
||||||
|
|
||||||
- [ ] **Schritt 1: Failing Tests**
|
- [ ] **Schritt 1: Failing Tests**
|
||||||
@@ -1964,6 +1973,9 @@ 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('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('a new honey batch delivers batch.created through the handler', ...);
|
||||||
it('the batch.created payload has the same shape as before (honeyBatchInclude)', ...);
|
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.ts` und die e2e-Dateien → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/extractions src/honey-batches src/webhooks src/withdrawal src/common/after-commit-usage.spec.ts` und die e2e-Dateien → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren** wie oben.
|
- [ ] **Schritt 3: Implementieren** wie oben.
|
||||||
@@ -1995,7 +2007,7 @@ export interface HoneyBatchMovementView { id: string; type: BatchMovementType; q
|
|||||||
// CreateHoneyBatchMovementDto { type: 'WITHDRAWAL' | 'CORRECTION'; deltaKg: number (≠ 0, |x| ≤ 100000); reason?: WithdrawalReason (Pflicht bei WITHDRAWAL); notes?: string (≤ 500) }
|
// CreateHoneyBatchMovementDto { type: 'WITHDRAWAL' | 'CORRECTION'; deltaKg: number (≠ 0, |x| ≤ 100000); reason?: WithdrawalReason (Pflicht bei WITHDRAWAL); notes?: string (≤ 500) }
|
||||||
```
|
```
|
||||||
- `bookIn` (Spec 7.7): `lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId)`, Charge danach lesen; `initialAmount null` → keine Buchung, `{ amountKg: null }`; negatives Delta nur bei Rest ≥ |Delta| − 0,0005 kg, sonst 400 „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“ (bei `CORRECTION`/`WITHDRAWAL` „Die Menge kann nicht unter 0 sinken.“); `BatchMovement` mit `quantity = round(deltaKg × 1000)`; `amount = max(0, round((initialAmount + Σ quantity / 1000) × 1000) / 1000)`.
|
- `bookIn` (Spec 7.7): `lockRowForUpdate(tx, 'HoneyBatch', honeyBatchId)`, Charge danach lesen; `initialAmount null` → keine Buchung, `{ amountKg: null }`; negatives Delta nur bei Rest ≥ |Delta| − 0,0005 kg, sonst 400 „Abfüllmenge übersteigt die verfügbare Menge der Honigcharge“ (bei `CORRECTION`/`WITHDRAWAL` „Die Menge kann nicht unter 0 sinken.“); `BatchMovement` mit `quantity = round(deltaKg × 1000)`; `amount = max(0, round((initialAmount + Σ quantity / 1000) × 1000) / 1000)`.
|
||||||
- Abfüllung ändern/entfernen: zuerst `lockRowForUpdate(tx, 'BottlingBatch', id)`, Abfüllung danach lesen, dann `bookIn` (sperrt die Charge; bei Chargenwechsel beide Chargen `id` aufsteigend). `PATCH /honey-batches/:id` liest den aktuellen Rest erst unter der Chargensperre.
|
- Abfüllung ändern/entfernen: zuerst `lockRowForUpdate(tx, 'BottlingBatch', id)`, Abfüllung danach lesen, dann `bookIn` (sperrt die Charge; bei Chargenwechsel beide Chargen über `lockRowsForUpdate(tx, 'HoneyBatch', ids)`). `PATCH /honey-batches/:id` liest den aktuellen Rest erst unter der Chargensperre.
|
||||||
- Vorgänge (Tabelle Spec 7.7): Charge mit Menge anlegen → `initialAmount = amount`; Abfüllung anlegen `BOTTLING −`, ändern `BOTTLING` Differenz (Wechsel der Charge: zurück auf alte, ab von neuer), entfernen `BOTTLING +` mit Notiz „Abfüllung entfernt“; Status `BOTTLED` wie bisher; `PATCH amount` → `CORRECTION` (neu − aktuell); `amount: null` bei vorhandenen Buchungen → 400 „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“; `null → Wert` → `setInitialIn`. `create` und `update` in `honey-batches.service.ts` reichen `amount` nie per `{ ...dto }` an Prisma (`const { amount, ...rest } = dto`; Anlegen setzt `amount` und `initialAmount` gemeinsam über `setInitialIn`); der Wächter-Regex erfasst die Spread-Form nicht, deshalb die Unit-Tests oben.
|
- Vorgänge (Tabelle Spec 7.7): Charge mit Menge anlegen → `initialAmount = amount`; Abfüllung anlegen `BOTTLING −`, ändern `BOTTLING` Differenz (Wechsel der Charge: zurück auf alte, ab von neuer), entfernen `BOTTLING +` mit Notiz „Abfüllung entfernt“; Status `BOTTLED` wie bisher; `PATCH amount` → `CORRECTION` (neu − aktuell); `amount: null` bei vorhandenen Buchungen → 400 „Die Menge wird über das Bewegungsbuch geführt. Bitte eine Korrektur buchen.“; `null → Wert` → `setInitialIn`. `create` und `update` in `honey-batches.service.ts` reichen `amount` nie per `{ ...dto }` an Prisma (`const { amount, ...rest } = dto`; Anlegen setzt `amount` und `initialAmount` gemeinsam über `setInitialIn`); der Wächter-Regex erfasst die Spread-Form nicht, deshalb die Unit-Tests oben.
|
||||||
|
|
||||||
- [ ] **Schritt 1: Failing Tests**
|
- [ ] **Schritt 1: Failing Tests**
|
||||||
@@ -2269,7 +2281,7 @@ it('two send loops in parallel deliver each message exactly once', ...);
|
|||||||
it('a revoked permission skips the delivery at send time', ...);
|
it('a revoked permission skips the delivery at send time', ...);
|
||||||
```
|
```
|
||||||
- [ ] **Schritt 2:** `npx jest src/notifications src/telegram src/worker` und `test/telegram.e2e-spec.ts` → FAIL.
|
- [ ] **Schritt 2:** `npx jest src/notifications src/telegram src/worker` und `test/telegram.e2e-spec.ts` → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren** wie oben; Schleifenzeile `telegram-send` (5 000 ms, `notification_delivery`) nur, wenn `TelegramClient.isConfigured()`. `TelegramModule` importiert `PermissionsModule` (`WorkerModule` hat keinen globalen Resolver; der Katalogabgleich `PermissionCatalogSyncService.onModuleInit` läuft dann auch im Worker mit, idempotent per `skipDuplicates`). `cleanup.job.ts` räumt wie bei `WebhookDelivery` auf: `DELETE FROM "NotificationDelivery"` für `DELIVERED`/`SKIPPED` älter als `WEBHOOK_DELIVERY_RETENTION_DAYS` und `FAILED` älter als `FAILED_RETENTION_DAYS`; `DELETE FROM "TelegramLinkToken"` mit `"expiresAt" < now() - interval '30 days'`.
|
- [ ] **Schritt 3: Implementieren** wie oben; Schleifenzeile `telegram-send` (5 000 ms, `notification_delivery`) nur, wenn `TelegramClient.isConfigured()`. `TelegramSender` injiziert `PermissionResolverService`; `TelegramModule` importiert dafür **kein** `PermissionsModule` (Global Constraints, Rechte-Auflösung): der Worker bekommt den Resolver aus dem globalen `JobPermissionsModule` (Task 4), die API aus `PermissionsModule`, und der Katalogabgleich läuft nicht im Worker. `cleanup.job.ts` räumt wie bei `WebhookDelivery` auf: `DELETE FROM "NotificationDelivery"` für `DELIVERED`/`SKIPPED` älter als `WEBHOOK_DELIVERY_RETENTION_DAYS` und `FAILED` älter als `FAILED_RETENTION_DAYS`; `DELETE FROM "TelegramLinkToken"` mit `"expiresAt" < now() - interval '30 days'`.
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 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.
|
- [ ] **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.
|
||||||
|
|
||||||
@@ -2298,7 +2310,7 @@ TelegramUpdatesService.pollOnce(signal: AbortSignal): Promise<'disabled' | 'no-l
|
|||||||
// POST /profile/telegram/link-token @AnyAuthenticatedUser @Throttle(SENSITIVE_ACTION_THROTTLE), Body { password: string; mfaCode?: string } -> ReauthService.verify; NICHT @Idempotent
|
// POST /profile/telegram/link-token @AnyAuthenticatedUser @Throttle(SENSITIVE_ACTION_THROTTLE), Body { password: string; mfaCode?: string } -> ReauthService.verify; NICHT @Idempotent
|
||||||
// POST /profile/telegram/unlink @AnyAuthenticatedUser
|
// POST /profile/telegram/unlink @AnyAuthenticatedUser
|
||||||
```
|
```
|
||||||
- Verbindung ändern (`linkFromStartIn`, `stopIn`, `unlink`, 403-Pfad in Task 35 unverändert): erst `getOrCreate` der Präferenz, dann `lockRowForUpdate(tx, 'NotificationPreference', userId)` für alle betroffenen Nutzer (Schlüssel aufsteigend), `TelegramLink` und Präferenz erst danach lesen und ändern.
|
- Verbindung ändern (`linkFromStartIn`, `stopIn`, `unlink`, 403-Pfad in Task 35 unverändert): erst `getOrCreate` der Präferenz, dann `lockRowsForUpdate(tx, 'NotificationPreference', userIds)` für alle betroffenen Nutzer (eine Anweisung, Schlüssel aufsteigend), `TelegramLink` und Präferenz erst danach lesen und ändern.
|
||||||
- Token: `randomBytes(32).toString('base64url')` (43 Zeichen), gespeichert `sha256` hex, `expiresAt = now() + 10 min` per SQL, ältere offene Token des Nutzers `consumedAt = now()`.
|
- Token: `randomBytes(32).toString('base64url')` (43 Zeichen), gespeichert `sha256` hex, `expiresAt = now() + 10 min` per SQL, ältere offene Token des Nutzers `consumedAt = now()`.
|
||||||
- Empfang (Spec 9a.5, 9a.7): Lease-SQL wörtlich; je Update eine Transaktion mit Wirkung und `updateOffset = update_id + 1` als Compare-and-Set: `UPDATE "TelegramPollState" SET "updateOffset" = $neu WHERE id = 'singleton' AND "leaseOwner" = $ich AND "leaseUntil" > now() AND "updateOffset" = $alt`; `count 0` (Lease verloren oder anderer Stand) bricht die Transaktion samt Wirkung ab und beendet den Zyklus mit `'no-lease'`; nur `chat.type = 'private'`; `/start <token>` → CAS `UPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "tokenHash" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now() RETURNING "userId"` (Nutzer aktiv), `TelegramLink` anlegen/ersetzen, war der Chat einem anderen Nutzer zugeordnet: dessen Verbindung lösen, `telegramEnabled = false`, Meldung `SYSTEM` „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“; `telegramEnabled = true`; Meldung `SYSTEM` „Telegram verbunden mit „{Vorname}“ am {TT.MM.JJJJ}. Warst du das nicht? In den Einstellungen trennen.“ (Link `/profile`); Antwort `LINKED`. Ungültig → `LINK_INVALID`. `/start` ohne Token, `/hilfe`, `/help`, anderer Text → `HELP`. `/stop` → `stopIn`, `STOPPED`. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher). Vorname getrimmt, ohne Steuerzeichen, ≤ 64 Zeichen.
|
- Empfang (Spec 9a.5, 9a.7): Lease-SQL wörtlich; je Update eine Transaktion mit Wirkung und `updateOffset = update_id + 1` als Compare-and-Set: `UPDATE "TelegramPollState" SET "updateOffset" = $neu WHERE id = 'singleton' AND "leaseOwner" = $ich AND "leaseUntil" > now() AND "updateOffset" = $alt`; `count 0` (Lease verloren oder anderer Stand) bricht die Transaktion samt Wirkung ab und beendet den Zyklus mit `'no-lease'`; nur `chat.type = 'private'`; `/start <token>` → CAS `UPDATE "TelegramLinkToken" SET "consumedAt" = now() WHERE "tokenHash" = $1 AND "consumedAt" IS NULL AND "expiresAt" > now() RETURNING "userId"` (Nutzer aktiv), `TelegramLink` anlegen/ersetzen, war der Chat einem anderen Nutzer zugeordnet: dessen Verbindung lösen, `telegramEnabled = false`, Meldung `SYSTEM` „Deine Telegram-Verbindung wurde getrennt, weil der Chat mit einem anderen Konto verbunden wurde.“; `telegramEnabled = true`; Meldung `SYSTEM` „Telegram verbunden mit „{Vorname}“ am {TT.MM.JJJJ}. Warst du das nicht? In den Einstellungen trennen.“ (Link `/profile`); Antwort `LINKED`. Ungültig → `LINK_INVALID`. `/start` ohne Token, `/hilfe`, `/help`, anderer Text → `HELP`. `/stop` → `stopIn`, `STOPPED`. Höchstens 3 Antworten je Chat in 10 Minuten (Zähler im Speicher). Vorname getrimmt, ohne Steuerzeichen, ≤ 64 Zeichen.
|
||||||
- Schleife `telegram-updates`: Intervall 30 000 ms, kein Weckkanal; nach `'polled'` sofort `wake()`; Stop-Signal bricht den laufenden Abruf ab; 409 → `lastError`, 30 s warten; Störungen machen den Worker nicht ungesund.
|
- Schleife `telegram-updates`: Intervall 30 000 ms, kein Weckkanal; nach `'polled'` sofort `wake()`; Stop-Signal bricht den laufenden Abruf ab; 409 → `lastError`, 30 s warten; Störungen machen den Worker nicht ungesund.
|
||||||
@@ -2335,7 +2347,7 @@ it('unlink sends STOPPED and switches Telegram off', ...);
|
|||||||
|
|
||||||
**Files**
|
**Files**
|
||||||
- Create: `src/system/notification-deliveries.service.ts` (+ Spec), `src/system/dto/notification-deliveries.query.ts`
|
- Create: `src/system/notification-deliveries.service.ts` (+ Spec), `src/system/dto/notification-deliveries.query.ts`
|
||||||
- Modify: `src/system/system.controller.ts`, `src/system/system.module.ts`, `.env.example`, `deploy/k8s/components/backend-roles/worker-deployment.yaml`, `deploy/k8s/overlays/encry/configmap-backend.yaml`, `deploy/k8s/tests/manifest-policy.test.mjs`
|
- 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 unter `ApiCore/deploy/`, nicht unter `ApiCore-Backend/`)
|
||||||
- Test: `test/system.e2e-spec.ts` (ergänzen; nicht `telegram.e2e-spec.ts`, das Task 36 in derselben Welle ändert)
|
- Test: `test/system.e2e-spec.ts` (ergänzen; nicht `telegram.e2e-spec.ts`, das Task 36 in derselben Welle ändert)
|
||||||
- Docs: ARCHITECTURE §7: Secret `apicore-telegram` (`optional: true`) nur im Worker, `TELEGRAM_BOT_USERNAME` in der ConfigMap, BotFather ohne Gruppen und ohne Webhook, je Umgebung ein Bot; §10 „Verwaltung > System“: Telegram-Karte.
|
- Docs: ARCHITECTURE §7: Secret `apicore-telegram` (`optional: true`) nur im Worker, `TELEGRAM_BOT_USERNAME` in der ConfigMap, BotFather ohne Gruppen und ohne Webhook, je Umgebung ein Bot; §10 „Verwaltung > System“: Telegram-Karte.
|
||||||
|
|
||||||
@@ -2379,7 +2391,7 @@ test('only the worker references the apicore-telegram secret, and only optionall
|
|||||||
|
|
||||||
**Files** (alle in `ApiCore-Frontend/`)
|
**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`
|
- Create: `src/components/auth/step-up-dialog.tsx` (+ Spec), `src/app/(app)/profile/telegram-link-card.tsx` (+ Spec), `src/app/(app)/settings/system/telegram-card.tsx` (+ Spec), `src/lib/telegram/api.ts`, `src/lib/telegram/types.ts`
|
||||||
- Modify: `src/app/(app)/settings/backup/page.tsx` (+ Spec; nutzt `StepUpDialog` statt eigener Felder), `src/app/(app)/profile/page.tsx`, `src/app/(app)/profile/notification-settings-card.tsx` (Kategorien, „Mit Details“), `src/lib/notification-settings/types.ts`, `src/app/(app)/settings/system/page.tsx`, `src/lib/system/api.ts` + `types.ts`
|
- Modify: `src/app/(app)/settings/backup/page.tsx` (+ Spec; nutzt `StepUpDialog` statt eigener Felder), `src/app/(app)/profile/page.tsx`, `src/app/(app)/profile/notification-settings-card.tsx` (Kategorien, „Mit Details“), `src/lib/notification-settings/types.ts`, `src/app/(app)/settings/system/page.tsx`, `src/lib/system/api.ts` + `types.ts`, `src/lib/system/labels.ts` (+ `labels.spec.ts`; `DOMAIN_EVENT_LABELS` um 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**
|
**Interfaces**
|
||||||
- Consumes: Task 36, 37, Task 21.
|
- Consumes: Task 36, 37, Task 21.
|
||||||
@@ -2403,8 +2415,9 @@ it('„Trennen“ asks and unlinks', ...);
|
|||||||
it('without a bot shows „Telegram ist nicht eingerichtet.“ and no button', ...);
|
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 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('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/app/(app)/profile" "src/app/(app)/settings"` → FAIL.
|
- [ ] **Schritt 2:** `npx vitest run src/components/auth src/lib/system "src/app/(app)/profile" "src/app/(app)/settings"` → FAIL.
|
||||||
- [ ] **Schritt 3: Implementieren.**
|
- [ ] **Schritt 3: Implementieren.**
|
||||||
- [ ] **Schritt 4:** → PASS.
|
- [ ] **Schritt 4:** → PASS.
|
||||||
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npm test && npm run build`; kein Commit.
|
- [ ] **Schritt 5: Verifikation** – `npm run lint && npm run typecheck && npm test && npm run build`; kein Commit.
|
||||||
@@ -2524,6 +2537,12 @@ it('the system card warns when the oldest delivery waits longer than 10 minutes
|
|||||||
- **Übertrag aus Rückgrat Task 18:** `QueenAged` einmal je Königin und Jahr am ersten Jobtag mit gealterter Königin (Jahresanker im `dedupeKey`); Winter-Fälligkeiten kommen über „Erste Durchsicht im Jahr“ zu Frühjahrsbeginn (A1), keine eigene Märzmeldung.
|
- **Übertrag aus Rückgrat Task 18:** `QueenAged` einmal je Königin und Jahr am ersten Jobtag mit gealterter Königin (Jahresanker im `dedupeKey`); Winter-Fälligkeiten kommen über „Erste Durchsicht im Jahr“ zu Frühjahrsbeginn (A1), keine eigene Märzmeldung.
|
||||||
- **Ableger-Dialog (Spec 11.2)** in Task 20; **Aufräum-Job** löscht alte `TelegramLinkToken` und `NotificationDelivery` (Task 35); **Migrations-Harness** nutzt `.tmp-migrations/` statt OS-Temp (Task 2).
|
- **Ableger-Dialog (Spec 11.2)** in Task 20; **Aufräum-Job** löscht alte `TelegramLinkToken` und `NotificationDelivery` (Task 35); **Migrations-Harness** nutzt `.tmp-migrations/` statt OS-Temp (Task 2).
|
||||||
|
|
||||||
|
**Konsistenzdurchgang nach den Textersetzungen (2026-10-14)**
|
||||||
|
- **Resolver in Worker und Jobs:** kein Feature-Modul importiert `PermissionsModule`; Task 1 exportiert `JobPermissionsModule`, Task 4 importiert es in `WorkerModule` (schon vor Task 5, dessen `SuggestionViewsService` den 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, `Product` ist schon darin); `lockRowForUpdate` liefert `boolean`, alle drei Helfer nutzen die Schlüsselspalte.
|
||||||
|
- **Tagesauswertung:** Task 13 baut auf der vorhandenen Regeltabelle `DAILY_EVALUATION_RULES` auf (Schleife wandert in `TimeEventsStep`, `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 nutzt `recheckAfterDays` statt fester 14.
|
||||||
|
|
||||||
**Geprüft**
|
**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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
Reference in new issue
Block a user