Compare commits
6
Commits
af6d142bec
...
45274f4301
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45274f4301 | ||
|
|
61985afc4b | ||
|
|
9795f98006 | ||
|
|
2a4dc868d9 | ||
|
|
af9df0afe4 | ||
|
|
257286fbef |
No files matched your search
@@ -1,3 +1,7 @@
|
|||||||
# Shell scripts must keep LF line endings, also on Windows checkouts with
|
# Shell scripts must keep LF line endings, also on Windows checkouts with
|
||||||
# core.autocrlf, or bash fails on the carriage returns.
|
# core.autocrlf, or bash fails on the carriage returns.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# The Whisper word list is generated (npm run export:voice-vocabulary) and
|
||||||
|
# copied into the image; keep LF so the file stays byte-identical everywhere.
|
||||||
|
ApiCore-Whisper/vocabulary.txt text eol=lf
|
||||||
+22
-6
@@ -76,7 +76,9 @@ Zentrale Schnellaktion "+ Neu" (Abschnitt 22): permanent sichtbarer Floating-But
|
|||||||
|
|
||||||
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
|
**Offline-Seiten:** Ohne Netz zeigen `/offline` (Start: geladene Stände), `/offline/apiary?id=…` und `/offline/colony?id=…` die Daten aus den Offline-Paketen (IndexedDB). QR- und NFC-Deep-Links (`/apiaries/<id>`, `/colonies/<id>`) weicht der Service Worker ohne Netz auf diese Seiten aus; der Weg zurück („Mit Netz öffnen“) übernimmt `from` nur bei gleicher Herkunft (same-origin). Formulare lesen Völker und Königinnen aus dem Paket, wenn die API nicht antwortet (`withPackFallback`, `networkMode: "always"`).
|
||||||
|
|
||||||
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`, aktuell der Hash `0681ff4b095d`; ein Spec prüft, dass die Version zum Inhalt von `sw-routes.js` passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: `/api/v1/compliance` (Prüfbereitschaft, Anzeigen-Einstellungen), `authority-notices`, `apiary-restrictions`, `apiaries/:id/restrictions`, `veterinary-medicines`, `veterinary-medicine-lots`, die Etikett-Prüfung und der Druck (`label-templates/check`, `label-templates/:id/check…`, `label-templates/:id/print…`; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument `documents/:id` (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` (die Art steht in der Query `/documents?entityType=…`, daher prüft `isCacheableApiUrl` die URL samt Query; Listen der Durchsicht-Fotos und `documents/:id/download` bleiben offline verfügbar, das Präfix `/api/v1/documents` wird nicht als Ganzes gesperrt). **Der globale Header `Cache-Control: no-store` des Backends ist bewusst kein Signal für den Worker:** Jede API-Antwort trägt ihn (Abschnitt 9, `noStoreMiddleware`), ein Header-Check würde das gesamte Offline-Verhalten abschalten. `isKeepableApiAnswer` prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87).
|
**Beute offline (Abschnitt 13.1):** Das Paket (`offline-pack`) trägt optional `hives` (Aufbau je Volk) und `hiveCatalog` (aktive Systeme plus die von den Aufbauten genutzten); ein älteres Paket ohne diese Felder sagt „unbekannt“, nicht „kein Aufbau“ (`hiveFromPacks`, `lib/hive/pack.ts`). Änderungen ohne Netz landen im IndexedDB-Speicher `pending-hive-changes` (DB-Version 5, `queueHiveChange`). `drainTimeline` (`lib/offline/sync.ts`) sendet diese Einträge und die Durchsichten, die eine `hiveChange` tragen, gemeinsam nach `queuedAt`, nacheinander. Netzfehler und 5xx stoppen den Lauf; jede andere Ablehnung (`HIVE_CONFLICT`, 403, 400) wird am Eintrag gespeichert, der Eintrag bleibt in der Warteschlange und hält die späteren Einträge desselben Volks zurück, andere Völker laufen weiter. **Überholschutz:** Solange für ein Volk eine Beute-Änderung wartet (auch eine abgelehnte), reiht `useHiveMutation` jede neue Kartenänderung hinter sie ein, statt online zu senden (`hasQueuedHiveChange`). Die Karte zeigt die wartenden Operationen auf dem geladenen Stand; eine abgelehnte Änderung zeigt `HivePendingNote` als Hinweis mit Weg in die Warteschlange (Annahme 102).
|
||||||
|
|
||||||
|
**API-Client und Service Worker.** `apiFetch` und `apiFetchFormData` nehmen die Option `refreshOn401: false`: Ein 401 wird dann weder erneuert noch wiederholt, die deutsche Meldung des Servers kommt unverändert an. Alle Nachweis-Aufrufe (Step-up, Abschnitt 11.13) setzen sie (Annahme 56). Der Service Worker legt `/api/v1/profile/telegram`, `/api/v1/profile/notification-settings` und `/api/v1/system` nie im Cache ab (`NEVER_CACHED_API_PATHS` in `public/sw-routes.js`; eine Änderung hebt `SW_ROUTES_VERSION` in `public/sw.js`, aktuell der Hash `215afb9f84e9`; ein Spec prüft, dass die Version zum Inhalt von `sw-routes.js` passt, jede Änderung an der Datei braucht also eine neue Version). Seit T6 kommen dazu: `/api/v1/compliance` (Prüfbereitschaft, Anzeigen-Einstellungen), `authority-notices`, `apiary-restrictions`, `apiaries/:id/restrictions`, `veterinary-medicines`, `veterinary-medicine-lots`, die Etikett-Prüfung und der Druck (`label-templates/check`, `label-templates/:id/check…`, `label-templates/:id/print…`; sie tragen die Anschrift des Lebensmittelunternehmers), das Einzeldokument `documents/:id` (die Art steht nicht im Pfad) und die Dokumentlisten der drei eingeschränkten Arten `MEDICINE_LOT`, `AUTHORITY_NOTICE` und `APIARY_RESTRICTION` (die Art steht in der Query `/documents?entityType=…`, daher prüft `isCacheableApiUrl` die URL samt Query; Listen der Durchsicht-Fotos und `documents/:id/download` bleiben offline verfügbar, das Präfix `/api/v1/documents` wird nicht als Ganzes gesperrt). **Der globale Header `Cache-Control: no-store` des Backends ist bewusst kein Signal für den Worker:** Jede API-Antwort trägt ihn (Abschnitt 9, `noStoreMiddleware`), ein Header-Check würde das gesamte Offline-Verhalten abschalten. `isKeepableApiAnswer` prüft nur Erfolg und JSON; ob etwas behalten wird, entscheidet allein der Pfad (Annahme 87). Ebenfalls nie gespeichert wird `/api/v1/colonies/:id/hive` (`/\/api\/v1\/colonies\/[^/]+\/hive\/?$/i`): das Paket antwortet dort, eine alte Kopie würde einen neueren Stand verdecken. `…/hive/changes` und der Katalog (`/hive-systems`) bleiben cachebar.
|
||||||
|
|
||||||
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
|
**Stand-Seite und Standrunde:** `apiaries/[id]` zeigt die lebenden Völker mit Ampel und „Runde starten“; nach dem Speichern führt „Weiter mit …“ zum nächsten Volk. Der Rundenzustand liegt je Benutzer lokal und wird beim Abmelden gelöscht.
|
||||||
|
|
||||||
@@ -139,7 +141,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
19. **Backups versiegelt**: AES-256-GCM über das gzip-Bundle, Schlüssel aus dem Schlüsselbund `BACKUP_ENCRYPTION_KEY` (+ `BACKUP_ENCRYPTION_KEY_PREVIOUS`, in Produktion Pflicht, siehe Annahme 22); Dateien von vor dem Schlüsselbund (Schlüssel per HKDF aus `JWT_ACCESS_SECRET`) bleiben lesbar, solange `JWT_ACCESS_SECRET` unverändert ist. Nur versiegelte Dateien stellen vollständig wieder her (und überschreiben). Eine unversiegelte (alte v2-)Datei darf nur die 27 Stamm- und Feldtabellen der Erlaubt-Liste `LEGACY_UNSEALED_TABLES` ergänzen – fehlende Zeilen anlegen, bestehende nie ändern. Alles andere (Benutzer, Rollen, Webhooks, Audit-Log, Kalender-Sync, Backup- und Website-Einstellungen, Nummernkreise, Bestands- und Chargenbewegungen, Shop-Anfragen, Rohstoffe/Herstellung, Dokumente, Benachrichtigungen samt Einstellungen, Vorschläge, Pflanzenentwicklungs-Meldungen und jede künftige Tabelle) kommt nur aus versiegelten Dateien; die Oberfläche listet übersprungene Tabellen auf; Import verlangt Passwort (+ TOTP). MFA- und Webhook-Secrets liegen mit eigenen Schlüsseln (`MFA_ENCRYPTION_KEY`, `WEBHOOK_ENCRYPTION_KEY`) verschlüsselt in der DB und damit auch im Backup – für eine Wiederherstellung in einem anderen Deployment dort dieselben Schlüssel setzen (oder als `*_PREVIOUS`), sonst müssen Webhook-Secrets und MFA neu eingerichtet werden.
|
||||||
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
|
20. **Bearbeiten-Regel für `null`** (Grundsatz „alles bearbeitbar“): In jeder Bearbeiten-Anfrage bleibt ein weggelassenes Feld unverändert, `null` leert ein Feld, das leer sein darf, und `null` auf einem Pflichtfeld wird mit 400 abgelehnt (statt still ignoriert oder als 500). Umgesetzt über `PartialType(..., { skipNullProperties: false })` in den Update-DTOs, `@IsOptionalNotNull()` (`src/common/validation/is-optional-not-null.ts`) für weglassbare, aber nicht leerbare Felder und `optionalDate()` (`src/common/patch-values.ts`) für leerbare Datumsfelder; `update-dto-null-handling.spec.ts` hält den Vertrag je DTO fest. Das Frontend sendet geleerte optionale Felder über `emptyToNull` (`ApiCore-Frontend/src/lib/forms/empty-to-null.ts`) als `null`. Für Regel-Configs gilt dasselbe Muster: `null` löscht einen optionalen Schlüssel (z. B. `dueInDays`, `windowDays`, `minGrams`, einzelne GTS-Schwellen), ein Pflichtschlüssel (z. B. `queenMaxAgeYears`, `notify`, die Gruppe `swarmSeason`) lehnt `null` mit 400 ab (Abschnitt 11.2). Neu leerbar sind `Colony.occupiedFrames`, `VarroaMeasurement.sampleBees` und `Inspection.feedStatus`.
|
||||||
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
21. **Lebende Völker**: „Lebend“ heißt jeder Status außer `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` – also auch `WEAK`, `CONSPICUOUS`, `QUEENLESS`. Backend-Abfragen filtern über `LIVING_COLONY_WHERE` (`colonies/colony-status.ts`), das Frontend über `isLivingColony` (`lib/colonies/status.ts`); `status = ACTIVE` steht nur dort, wo wirklich genau dieser Status gemeint ist.
|
||||||
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die drei Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt.
|
22. **Schlüsselbund**: Jeder Zweck hat einen eigenen Schlüssel, nie ein JWT-Secret: `MFA_ENCRYPTION_KEY` (TOTP-Secrets), `WEBHOOK_ENCRYPTION_KEY` (Webhook-Signaturschlüssel), `BACKUP_ENCRYPTION_KEY` (Backup-Dateien), `VOICE_ENCRYPTION_KEY` (Sprachnotizen: Transkript, Vorschlag, Aufnahme; seit T7, AAD `capture:<id>:<art>`). In Produktion Pflicht: je ≥ 32 Zeichen, paarweise verschieden und verschieden von `JWT_ACCESS_SECRET`/`JWT_REFRESH_SECRET` – sonst startet kein Prozess (`src/runtime/startup-checks.ts`). Außerhalb der Produktion fällt ein fehlender Schlüssel je Zweck auf eine Ableitung aus `JWT_ACCESS_SECRET` zurück. Format `k<8 hex>:<iv>.<tag>.<data>` (Base64-Teile, AES-256-GCM, 128-Bit-Tag Pflicht); AES-Schlüssel = HKDF-SHA256(Secret, Salt `apicore-key-ring`, Info `apicore-<zweck>-v1`), Schlüssel-ID = `k` + die ersten 8 Hex-Zeichen von sha256(Schlüssel). Backups behalten Dateiformat v3 und ihre bisherige Ableitung (`keyId` 12 Hex), folgen aber denselben Ring-Regeln. Code: `src/common/crypto/key-ring.ts`. In Produktion lehnt der Start außerdem Platzhalterwerte ab (`change-me…`, ohne Groß-/Kleinschreibung) – für beide JWT-Secrets, die vier Schlüssel und ihre `*_PREVIOUS`, ein gesetztes `OIDC_CLIENT_SECRET` und das Passwort in `DATABASE_URL`; auch `*_PREVIOUS` muss von den JWT-Secrets verschieden sein. Außerhalb der Produktion meldet jeder Prozess einmal je Variable, dass ein aus `JWT_ACCESS_SECRET` abgeleiteter Entwicklungsschlüssel einspringt. Der Job `rekey` versiegelt beim Schlüsseltausch nur offene Sprachnotizen neu (bestätigte, verworfene und abgelaufene haben keinen Inhalt mehr). **Rollout:** Ein Image mit T7 startet in Produktion in keiner Rolle ohne `VOICE_ENCRYPTION_KEY` in `apicore-backend-secret`; vorher `deploy/k8s/scripts/ensure-secrets.sh` ausführen und den Schlüssel wie die anderen sichern.
|
||||||
23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden.
|
23. **Öffentliche Herkunftsseite ohne Standort**: Sie nennt nur `Apiary.publicLocationLabel` (je Stand, z. B. „Nordhessen“) und `SiteSettings.originCountry`. Name, Ort, Adresse und Koordinaten eines Stands werden nie öffentlich ausgeliefert (Diebstahlschutz). Ohne Label erscheint nur das Land. Eine Label-Änderung kann am Edge bis zu 1 h verzögert sichtbar werden.
|
||||||
**Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
|
**Rotation über `*_PREVIOUS`:** alten Wert nach `<NAME>_PREVIOUS` (öffnet weiter, versiegelt nie), neuen `<NAME>` setzen, api und worker neu starten (sonst versiegeln sie weiter mit dem alten Schlüssel), Job `rekey` laufen lassen; danach muss `rekey --dry-run` bei `rekeyed`, `plaintextSealed`, `changedMeanwhile` und `failed` 0 melden, erst dann `*_PREVIOUS` entfernen. Backup-Dateien werden nicht umgeschlüsselt: `BACKUP_ENCRYPTION_KEY_PREVIOUS` bleibt gesetzt, solange ältere Dateien einspielbar sein sollen.
|
||||||
**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
|
**Altchiffrate sind nur lesbar** (nie geschrieben), **und nur, solange `JWT_ACCESS_SECRET` unverändert ist**: MFA-Secrets ohne Präfix und Webhook-Secrets mit `enc:v1:` (beide AES-Schlüssel sha256(`JWT_ACCESS_SECRET`)), Webhook-Secrets im Klartext, Backup-Dateien mit JWT-abgeleiteter `keyId`. Liest ein Prozess ein solches MFA- oder Webhook-Secret, warnt er einmal je Zweck „legacy ciphertext read – run the rekey job“ (ohne Werte). Reihenfolge darum: Schlüssel setzen → `rekey` → neues Backup ziehen → erst dann die JWT-Secrets rotieren (alle Sitzungen enden einmal; JWT-versiegelte Backup-Dateien sind danach nicht mehr einspielbar).
|
||||||
@@ -159,7 +161,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
|
36. **Saison aus Pflanzenentwicklung**: Die Saison (Abschnitte `WINTERRUHE`, `FRUEHJAHR`, `SCHWARMZEIT`, `SOMMER`, `SPAETSOMMER`, `HERBST`) leitet sich aus der Pflanzenentwicklung ab (Abschnitt 11.3). Der Kalender-Rückfall (`PHENOLOGY.calendarFallback`, Standard `SALWEIDE` 20.03., `KIRSCHE` 15.04., `APFEL` 28.04., `HOLUNDER` 30.05., `LINDE` 20.06., `TRACHTENDE` 01.08., `HERBST` 15.09., `WINTER` 15.11.). Die Tage sind eine Annahme für Schwaben und per Regel anpassbar. Ohne gespeicherte Zeile für das laufende Jahr gilt der reine Kalenderzustand (Verhalten von T4a); dort steht vor `SALWEIDE` noch `WINTER` des Vorjahres. Seit T4b kommen gemeldete Phasen und die Grünlandtemperatursumme (GTS, Wetter) dazu; sie wirken über `SeasonService.statesFor`, die Aufrufer bleiben gleich. Der Zustand eines Stands steht in `ApiaryPhenology (apiaryId, year)`; der Job-Schritt `phenology` und jede Meldung oder Rücknahme berechnen ihn neu (`PhenologyStateService.recomputeIn`, unter Zeilensperre auf `Apiary`). Die Phase ist das Maximum aus eigener Meldung (Meldungen für alle Stände zählen für jeden Stand), GTS-Schätzung und Kalender. Sie läuft im Jahr nur vorwärts, außer nach einer Korrektur oder Rücknahme einer Meldung (`allowBackward`, gesetzt nur vom Handler bei `PhenologyCorrected`; eine neue Meldung schiebt die Phase nie zurück). GTS = Summe der positiven Tagesmittel ab 1. Januar bis gestern, Januar × 0,5, Februar × 0,75, ab März × 1 (nach DWD). Annahme: 200 °C gelten als `SALWEIDE` (Standardschwelle `PHENOLOGY.gtsThresholds`). Weitere Schwellen werden nie erfunden, sondern als Median der GTS am ersten Meldetag der letzten bis zu 3 Vorjahre kalibriert (Standard `KIRSCHE` und `APFEL`); ein einzelnes Vorjahr genügt und ist dann der Median. Ohne Vorjahresmeldung gilt für diese Phasen der Kalender. Fehlen mehr als `maxGapShare` (10 %) der Tage, steht „Wetterdaten lückenhaft“; ist der letzte Wettertag älter als 3 Tage, „Wetterdaten veraltet“; ohne Koordinaten „Keine Koordinaten“. In allen drei Fällen wird die GTS nicht benutzt und der Kalender entscheidet. Vor der ersten Phase des Jahres ist die gespeicherte Phase leer (Quelle Kalender), der Abschnitt `WINTERRUHE`. Ist die gespeicherte Zeile älter als 2 Tage (Job ausgefallen), gilt wieder der Kalenderzustand. `springSince` ist nicht gespeichert, sondern genähert: im Abschnitt `FRUEHJAHR` der Abschnittsbeginn, später im Jahr das Kalenderdatum von `SALWEIDE` (höchstens der Abschnittsbeginn). Weicht der GTS-Tag von `SALWEIDE` stark vom Kalender ab, verschiebt sich die Frist „Erste Durchsicht im Jahr“ um diese Differenz. Die Rückfall-Daten für Schwaben und die 200-°C-Schwelle sind Annahmen und noch nicht an regionalen Daten geprüft. Die Wetterquelle beschreibt Abschnitt 11.11. Meldungen und Rücknahmen stoßen die Neuberechnung über Ereignisse an, Saisonwechsel erscheinen nur in der Morgenübersicht (Annahme 47).
|
||||||
37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis).
|
37. **Alter der Königin**: Eine Königin gilt als gealtert, wenn aktuelles Jahr minus Schlupfjahr ≥ `queenMaxAgeYears` (Regel `SEASON_AUTOPILOT`, Standard 2). Die frühere getrennte Schwelle (3 im Dashboard, 2 im Ereignis) ist aufgehoben; `isQueenAged` (`src/season/queen-age.ts`) ist die einzige Stelle, ein Wächter-Test (`queen-age-threshold.spec.ts`) hält das fest. Das Dashboard-Fenster „alte Königinnen“ beginnt daher bei 2 Jahren und folgt der Regel. Der Wert 2 ist eine Annahme (Imkerpraxis).
|
||||||
38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft).
|
38. **Autopilot: Annahmen zu Ablauf und Alter**: Ein Vorschlag läuft `max(Fälligkeit, heute) + suggestionTtlDays` (Standard 30 Tage) ab, nicht schlicht Fälligkeit + 30: Sonst würde eine schon über 30 Tage überfällige Bedingung beim Anlegen abgelaufen, ihr Schlüssel freigegeben und sie täglich neu vorgeschlagen, ohne je sichtbar zu sein. Das Auffrischen behält `expiresAt`; besteht die Bedingung weiter, läuft der Vorschlag nach etwa 30 Tagen ab und kommt mit frischer Frist wieder. A3 beobachtet nur Behandlungen, die vor höchstens 60 Tagen endeten (`RECHECK_MAX_AGE_DAYS`); ältere Historie ohne spätere Messung erzeugt keinen Vorschlag. Beim Abgleich werden entfallene Vorschläge vor neuen Kandidaten aufgelöst (gleicher Endzustand, aber einen Abgleich früher, weil `proposeIn` Kandidaten bei offenem Vorschlag derselben Absicht verwirft).
|
||||||
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`.
|
39. **Benachrichtigungen je Nutzer**: Jede Meldung gehört genau einem Empfänger (`Notification.userId` Pflicht). Rundmeldungen gibt es nicht mehr: `NotificationsService.recordForPermission` und `recordForUsers` schreiben beim Anlegen je einen Eintrag für jeden aktiven Nutzer mit aktiver Rolle, der alle nötigen Rechte hält (Fan-out mit Rechteprüfung beim Schreiben, bewusste Abweichung von der Rückgrat-Spec §4). Lesen und Gelesen-Markieren ist so pro Person, und wer das Recht nicht hat, sieht den Inhalt nie. Dasselbe Recht gilt beim Lesen: `requiredPermissions` (Vereinigung aus Empfänger- und Inhaltsrechten) muss der Nutzer zum Lesezeitpunkt vollständig halten, sonst ist die Zeile für ihn unsichtbar (`GET /notifications`, `POST /notifications/:id/read` → 404, `read-all` markiert nur sichtbare). `dedupeKey = "<Präfix>:<userId>"` verhindert Doppelungen (`createManyAndReturn` mit `skipDuplicates`); `link` ist ein relativer App-Pfad (`assertAppLink`). Die Migration hat vorhandene Rundmeldungen für alle aktiven Nutzer kopiert (gleiche Sichtbarkeit wie zuvor; ohne aktiven Nutzer entfallen sie). `Notification.userId` und `NotificationPreference.userId` hängen mit `ON DELETE CASCADE` am Benutzer (Benutzer werden produktiv nur deaktiviert, `RESTRICT` schützte nichts und blockierte Aufräumen). Wächter: `no-broadcast-notifications.spec.ts`. `NotificationsService` liest die Empfänger und schreibt die Meldungen in einer Transaktion und sperrt die Nutzerzeilen dabei mit `FOR KEY SHARE`; ein gleichzeitiges Löschen eines Empfängers wartet, statt einen Fremdschlüsselfehler (P2003) im Handler auszulösen.
|
||||||
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
|
40. **Erinnerungen in der App und Morgenübersicht**: Siehe Abschnitt 11.9. In der App steuern die Morgenzeit (05:00–12:00), die Ruhezeit und die Umstellung Sommer-/Winterzeit nichts; die Übersicht entsteht immer um 04:30 mit dem Stand dieser Stunde. Sie wirken auf die Telegram-Zustellung (Annahme 49). Die Saison-Zeile der Übersicht nennt die Abschnittswechsel seit dem Zeitfenster der letzten Übersicht, nicht als Einzelmeldung (Annahme 47).
|
||||||
41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
|
41. **Berliner Kalendertage**: Tagesrechnung steckt in `src/common/dates.ts` (`berlinToday`, `berlinDayOf`, `dayToDate`, `addDays`, `daysBetween`; unmögliche Tage wie 30.02. werfen). Fachliche Tage sind Berliner Kalendertage, gespeichert als Mitternacht UTC dieses Tages: Varroa-Ampel, Behandlungs- und Wartezeitfenster, Erntetag, Fälligkeiten der Vorschläge, „heute“ im Job und im Autopilot. Für Werte um Mitternacht UTC ändert das nichts; ein Behandlungszeitpunkt am Abend (UTC) kann so auf den nächsten Berliner Tag fallen.
|
||||||
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
|
42. **Wetterquelle Open-Meteo (Entscheidung des Inhabers)**: Die Pflanzenentwicklung nutzt die freie Open-Meteo-API (Forecast-Endpunkt für die letzten 90 Tage, Archiv für ältere Lücken). Die nicht-kommerziellen Bedingungen und das damit verbundene Lizenzrisiko hat der Inhaber bewusst übernommen. Es wird kein API-Schlüssel verwendet (`OPEN_METEO_API_KEY` bleibt leer). Die Quelle liegt hinter der Schnittstelle `WeatherHistorySource` (`src/weather/history`); ein Wechsel geht über `WEATHER_HISTORY_SOURCE` und einen neuen Adapter. Koordinaten werden auf 0,01° gerundet. URL und Schlüssel erscheinen nie in Logs oder Fehlermeldungen. Details: Abschnitt 11.11.
|
||||||
@@ -221,6 +223,11 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
|||||||
98. **Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch**: Platz 1 ist links, Platz `frameSlots` rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13.
|
98. **Rähmchen-Plätze zählen von links, mit Blick auf das Flugloch**: Platz 1 ist links, Platz `frameSlots` rechts. Kosten bei falscher Annahme: Alle gespeicherten Platznummern sind gespiegelt; das lässt sich nur mit einer Datenmigration über alle Aufbauten beheben. Abschnitt 13.
|
||||||
99. **Den Aufbau ändert nur, wer `colonies:update` hat; den Katalog pflegt nur `hive-systems:manage` (ADMIN)**: STAFF hat `colonies:update` nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 `HIVE_FORBIDDEN`). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt `colonies:update` in der Rollenverwaltung, das ist kein Code. Abschnitt 13.
|
99. **Den Aufbau ändert nur, wer `colonies:update` hat; den Katalog pflegt nur `hive-systems:manage` (ADMIN)**: STAFF hat `colonies:update` nicht und kann die Beute daher nicht ändern, auch nicht in der Durchsicht (403 `HIVE_FORBIDDEN`). `hive-systems:manage` kommt nur über die Katalog-Synchronisierung zur Rolle ADMIN, ohne Rechte-Migration. Kosten bei falscher Annahme: Mitarbeiter müssen den Aufbau über einen Administrator ändern lassen; der Inhaber vergibt `colonies:update` in der Rollenverwaltung, das ist kein Code. Abschnitt 13.
|
||||||
100. **Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus**: Die Bausperre (`FLOOR_INSERT`) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen `addInsert` und `removePart`. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13.
|
100. **Die Bausperre ist eine Einlage ohne Zustand; „Winter“ ist kein eigener Modus**: Die Bausperre (`FLOOR_INSERT`) hat kein Zustandsfeld, Einlegen und Entfernen sind die Operationen `addInsert` und `removePart`. Kosten bei falscher Annahme: Wer einen Winter-Modus erwartet, muss Einlagen einzeln setzen; ein Modus wäre eine spätere Erweiterung des Katalogs. Abschnitt 13.
|
||||||
|
101. **Die Futtertasche misst 477 × 70 × 220 mm und fasst 2 l**: Der Hersteller (Holtermann) nennt Anschlussmaße, kein Volumen; Maße und `max: 2` im Zustandsfeld `syrupLiters` sind Annahmen der Startdaten (Migration `20261018100100_hive_catalog_frame_feeder`) und im Katalog editierbar (Einstellungen > Beutensysteme). Kosten bei falscher Annahme: Die Tasche sitzt im 3D-Modell zu groß oder zu klein und die Füllstandsgrenze stimmt nicht; der Inhaber misst nach, der Katalogwert wird mit `hive-systems:manage` geändert, kein Code. Abschnitt 13.
|
||||||
|
102. **Eine wartende Beute-Änderung wird nie überholt, auch eine abgelehnte nicht**: Ohne Netz oder mit offener Warteschlange reiht die Karte jede weitere Änderung desselben Volks hinten ein, statt sie online zu senden; ein abgelehnter Eintrag hält die späteren desselben Volks zurück, bis der Nutzer ihn in der Warteschlange klärt (Abschnitt 2). Kosten bei falscher Annahme: Nach einer abgelehnten Änderung sind neue Änderungen am Volk vorerst blockiert, bis der Nutzer entscheidet; die Alternative (überholen) könnte einen Aufbau erzeugen, den die Reihenfolge nie hatte. Abschnitt 13.1.
|
||||||
|
103. **Die Historienzeilen haben keine Folgeknöpfe**: Eine Zeile der Beute-Historie zeigt die Änderung und den Stand an diesem Tag, bietet aber keine Vorschläge wie „Futter nachlegen“ an, weil ein Eintrag den Zustand davor nicht trägt; die Vorschläge erscheinen nur als Hinweis (Toast) direkt nach dem Speichern. Kosten bei falscher Annahme: Ein weggewischter Hinweis ist aus der Historie nicht wiederzufinden; Abhilfe wäre, `stateBefore` im Protokoll zu speichern (Migration). Abschnitt 13.1.
|
||||||
|
104. **Der Editor hält den Stand fest, auf dem der Entwurf begann**: Beim ersten Schritt merkt sich `useHiveDraft` den geladenen Aufbau (`snapshot`) und zeigt die Vorschau darauf; ändert sich der Aufbau auf dem Server inzwischen, erscheint ein Hinweis, und beim Speichern wendet der Server die Operationen auf den aktuellen Stand an. Passen sie nicht mehr, öffnet sich das Konflikt-Sheet, es wird nichts gespeichert. Kosten bei falscher Annahme: Die Vorschau im Editor weicht nach einer gleichzeitigen Änderung vom gespeicherten Ergebnis ab; der Konflikt-Weg fängt Unverträgliches ab. Abschnitt 13.1.
|
||||||
|
105. **Der Zoom der 3D-Szene (0,6 bis 2,5) kommt mit dem Ziehen und Ablegen**: Die Szene hat Drehen und Fokus auf ein Teil, aber noch keinen frei wählbaren Zoom; der Bereich ist der Aufgabe für Ziehen und Ablegen zugeordnet (Task 18). Kosten bei falscher Annahme: Auf kleinen Bildschirmen fehlt zunächst ein Zoom; der Browser-Zoom und die Listenansicht (Gliederung) bleiben als Weg. Abschnitt 13.1.
|
||||||
|
|
||||||
## 7. Kubernetes
|
## 7. Kubernetes
|
||||||
|
|
||||||
@@ -975,16 +982,16 @@ Die Oberfläche des Compliance-Blocks entstand in Task 14 bis 19: Prüfbereitsch
|
|||||||
|
|
||||||
## 13. Beute (3D-Aufbau)
|
## 13. Beute (3D-Aufbau)
|
||||||
|
|
||||||
Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. Lieferung L1 ist das Backend ohne Oberfläche.
|
Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec: `docs/superpowers/specs/2026-10-15-beute-3d-design.md`. L1 ist das Backend (dieser Abschnitt), L2–L5 die Oberfläche (Abschnitt 13.1); Ziehen und Ablegen in 3D (Task 18) folgt.
|
||||||
|
|
||||||
**Modul.** `src/hive` (`HiveModule`): `hive.service.ts` (Aufbau je Volk), `hive-catalog.service.ts` (Katalog), `hive-ops.ts` (`applyHiveOps`), `hive-validate.ts` (Validator), `hive-facts.ts` (abgeleitete Fakten), `hive-state-schema.ts` (Zustandsfelder der Teile). Hive-Fakten schreiben nie in `Colony` oder `Inspection`; `Colony.occupiedFrames` bleibt die Volksstärke.
|
**Modul.** `src/hive` (`HiveModule`): `hive.service.ts` (Aufbau je Volk), `hive-catalog.service.ts` (Katalog), `hive-ops.ts` (`applyHiveOps`), `hive-validate.ts` (Validator), `hive-facts.ts` (abgeleitete Fakten), `hive-state-schema.ts` (Zustandsfelder der Teile). Hive-Fakten schreiben nie in `Colony` oder `Inspection`; `Colony.occupiedFrames` bleibt die Volksstärke.
|
||||||
|
|
||||||
**Speicherform.**
|
**Speicherform.**
|
||||||
- `HiveSystem` (Maße, `template`) und `HivePartType` (Art, Maße, `frameSlots`, `stateSchema`, `active`) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration `20261017100100_hive_catalog_zander_liebig` mit festen Ids; `test/fixtures/hive-ops/catalog.json` ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide.
|
- `HiveSystem` (Maße, `template`) und `HivePartType` (Art, Maße, `frameSlots`, `stateSchema`, `active`) bilden den Katalog. Die Startdaten (Zander nach Liebig, 10 Rähmchen, 14 Teile) kommen per Migration `20261017100100_hive_catalog_zander_liebig` mit festen Ids; `test/fixtures/hive-ops/catalog.json` ist dieselbe Quelle für das Frontend, ein e2e vergleicht beide. Die Futtertasche (`FRAME_FEEDER`, Migration `20261018100100_hive_catalog_frame_feeder`) ist ein Einsatz einer Zarge mit `slot` (erster belegter Rähmchenplatz) und der Katalogspalte `slotWidth` (Zahl belegter Plätze); die von ihr bedeckten Rähmchen sind `NONE`, Überschneidungen melden `RULE_FEEDER_SLOT` (Annahme 101).
|
||||||
- `HiveSetup` hält den aktuellen Aufbau eines Volks als JSON (Stand und `version`), `HiveChange` das Protokoll: je Änderung `occurredOn` (Berliner Tag), `source`, die Operationen und `stateAfter`. Der Stand an einem Tag kommt aus dem letzten `stateAfter` bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem `INIT`-Tag und nie in der Zukunft.
|
- `HiveSetup` hält den aktuellen Aufbau eines Volks als JSON (Stand und `version`), `HiveChange` das Protokoll: je Änderung `occurredOn` (Berliner Tag), `source`, die Operationen und `stateAfter`. Der Stand an einem Tag kommt aus dem letzten `stateAfter` bis zu diesem Tag. Nachträge sind möglich, aber nie vor dem `INIT`-Tag und nie in der Zukunft.
|
||||||
- Katalogteile werden nie gelöscht, nur `active: false`. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig.
|
- Katalogteile werden nie gelöscht, nur `active: false`. Bestehende Aufbauten mit einer inaktiven Art bleiben gültig.
|
||||||
|
|
||||||
**Operationen.** `addPart`, `addInsert`, `removePart`, `movePart`, `setState`, `setFrame`, `setFrames`, `setLabel`; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (`reason`-Codes wie `RULE_FLOOR_BOTTOM`, `STATE_OUT_OF_RANGE`, `UNKNOWN_FIELD`, `LIMIT_PARTS`) stehen im Kopf von `hive-validate.ts`; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 `HIVE_CONFLICT`).
|
**Operationen.** `addPart`, `addInsert`, `removePart`, `movePart`, `setState`, `setFrame`, `setFrames`, `setLabel`; höchstens 100 pro Anfrage, höchstens 30 Teile im Stapel, Beschriftungen und Rähmchen-Namen höchstens 40 Zeichen. Die Regeln und die Gründe (`reason`-Codes wie `RULE_FLOOR_BOTTOM`, `STATE_OUT_OF_RANGE`, `UNKNOWN_FIELD`, `LIMIT_PARTS`) stehen im Kopf von `hive-validate.ts`; die Prüfreihenfolge entscheidet, welcher Grund zuerst gemeldet wird, und das Frontend muss sie übernehmen: Wurzelform (`schemaVersion` 1, unbekannte Schlüssel), `LIMIT_PARTS`, dann je Stapelteil Id, unbekannte Felder, Art, Beschriftung, Zustand, Rähmchen und Einsätze (`RULE_INSERT_HOST`, `RULE_FEEDER_SLOT`), danach Boden (`RULE_FLOOR_BOTTOM`), zuletzt Dach (`RULE_ROOF_TOP`); Textlängen zählen UTF-16-Einheiten. `applyHiveOps` und `validateHiveState` sind reine Funktionen ohne Prisma; `applyHiveOps` prüft den Zustand nach jeder Operation, ein Fehler trägt Index und Grund. Scheitert eine Operation am aktuellen Stand, wird die ganze Änderung abgelehnt (409 `HIVE_CONFLICT`).
|
||||||
|
|
||||||
**Endpunkte** (`/colonies/:id/hive`): `GET` (Stand und Fakten), `POST` (Aufbau anlegen), `POST changes` (Änderung mit `Idempotency-Key`: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), `GET changes` (Historie mit Cursor), `GET at` (Stand an einem Tag). Katalog: `GET /hive-systems`, `GET /hive-systems/:id/template` (Vorlage mit frischen Ids je Aufruf), `POST` und `PATCH /hive-systems`, `POST /hive-systems/:id/part-types`, `PATCH /hive-part-types/:id`.
|
**Endpunkte** (`/colonies/:id/hive`): `GET` (Stand und Fakten), `POST` (Aufbau anlegen), `POST changes` (Änderung mit `Idempotency-Key`: dieselbe Wiederholung liefert dieselbe Antwort ohne zweite Änderung), `GET changes` (Historie mit Cursor), `GET at` (Stand an einem Tag). Katalog: `GET /hive-systems`, `GET /hive-systems/:id/template` (Vorlage mit frischen Ids je Aufruf), `POST` und `PATCH /hive-systems`, `POST /hive-systems/:id/part-types`, `PATCH /hive-part-types/:id`.
|
||||||
|
|
||||||
@@ -1011,3 +1018,12 @@ Der Aufbau einer Beute (Boden, Zargen, Rähmchen, Einlagen, Dach) je Volk. Spec:
|
|||||||
| `TEMPLATE_INVALID` | 400 | Die Vorlage besteht den Validator nicht |
|
| `TEMPLATE_INVALID` | 400 | Die Vorlage besteht den Validator nicht |
|
||||||
|
|
||||||
Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per `PATCH` gesetzt, sobald die Teile existieren.
|
Formfehler (Form der Anfrage, zukünftiger Tag, Tag vor `INIT`, mehr als 100 Operationen) sind 400. Ein neues System beginnt mit leerer Vorlage; sie wird per `PATCH` gesetzt, sobald die Teile existieren.
|
||||||
|
|
||||||
|
### 13.1 Frontend (Karte, Editor, Durchsicht, 3D)
|
||||||
|
|
||||||
|
- **Port von Regeln und Fixtures.** `src/lib/hive` spiegelt das Backend: `ops.ts` (`applyHiveOps`), `validate.ts`, `facts.ts`, `types.ts`. Ob beide Seiten gleich rechnen, sichern gemeinsame Fixtures: `ApiCore-Backend/test/fixtures/hive-ops/*.json` liest das Backend-Spec und das Frontend-Spec (`lib/hive/test-utils.ts`, `FIXTURE_DIR`); dazu `catalog.json` für den Katalog. Wer eine Regel ändert, ändert Backend, Frontend und Fixtures zusammen; die Prüfreihenfolge aus Abschnitt 13 gilt auch hier, `reason-text.ts` übersetzt die Gründe in Sätze.
|
||||||
|
- **Karte.** `components/hive/hive-card.tsx` auf der Volksseite (`mode` `view` oder `inspection`). Die Bühne (`HiveVisualSlot`) zeigt immer das SVG (`HiveSvg`, aus `lib/hive/layout.ts`), die 3D-Szene liegt darüber. Daneben stehen die Gliederung (`HiveOutline`, Baum aller Teile, per Tastatur bedienbar), Fakten und Fokusleiste. Teile und Rähmchen öffnen Sheets (`part-sheet`, `frame-sheet`); jede Änderung geht über `useHiveMutation` als `POST changes` mit eigenem `Idempotency-Key` je Versuch, einen Versand nach dem anderen, optimistisch auf dem Cache. Das Sheet zeigt bei `HIVE_CONFLICT` den aktuellen Stand aus der Antwort (`HiveConflictSheet`). Die Historie (`HiveHistory`) zeigt Änderungen und den Stand eines Tages (`GET at`).
|
||||||
|
- **Editor** `/colonies/[id]/hive` („Umbauen“): Mehrere Schritte werden lokal als Entwurf gesammelt (`useHiveDraft`: Schritte, Rückgängig und Wiederholen, `snapshot` des Ausgangsstands, Annahme 104) und mit einer Änderung gespeichert. Bis dahin berührt der Entwurf den Server nicht; Verlassen der Seite mit offenen Schritten fragt nach. Der Editor lädt den Aufbau neu, sobald das Fenster den Fokus bekommt, und alle 30 s, solange ein Entwurf besteht (`REFRESH_INTERVAL_MS`). Umbauen geht nur mit Netz.
|
||||||
|
- **Durchsicht.** `inspections/new` hat den Abschnitt „Beute“ (`hive-section.tsx`): die Karte im Modus `inspection` sammelt Operationen als Entwurf, sie gehen als `hiveChange` mit der Durchsicht in `POST /inspections` und in der Warteschlange mit dem Eintrag (Abschnitt 2). Die Sheets stoppen das `submit` an ihrem Formular (`stopPropagation`; sie liegen in einem Portal, doch React reicht Ereignisse im Komponentenbaum weiter), sonst würde „Speichern“ im Sheet die Durchsicht speichern; neue Formulare in Sheets müssen dasselbe tun. Zusätzlich stoppt der Abschnitt selbst `submit` an seinem Wrapper (`hive-section.tsx`).
|
||||||
|
- **3D-Szene.** `components/hive/hive-scene` (three, react-three-fiber, drei, postprocessing) wird nur über `next/dynamic` mit `ssr: false` geladen, sobald die Karte sichtbar wird (`IntersectionObserver`); andere Seiten laden nichts davon. Budget für den Chunk: höchstens 320 kB gzip (Spec 9.7); gemessen zuletzt etwa 296 kB. Die Szene misst die Bildrate (`quality.ts`): unter 45 fps fällt sie auf die Stufe `low` (kein Spiegelboden, kein MSAA, keine Tiefenunschärfe, keine Partikel), die Tiefenunschärfe läuft erst ab 55 fps. Bewegung folgt der Stufe `full`, `subtle` oder `off` (`useEffectiveMotionLevel`: Flüge, Überblendung 400, 150 oder 0 ms). **Rückfall auf das SVG** (`shouldUse3D`, `lib/hive/webgl.ts`): kein WebGL, erzwungene Farben, oder Bewegung `off` bei höchstens 2 Prozessorkernen; ein verlorener WebGL-Kontext baut die Szene einmal neu auf, der zweite lässt es beim SVG. Das SVG ist darunter immer gerendert und bleibt bedienbar, bis das erste 3D-Bild steht; im Druck entfällt die 3D-Ebene.
|
||||||
|
|
||||||
@@ -82,9 +82,12 @@ BACKUP_RETENTION_COUNT=14
|
|||||||
# MFA_ENCRYPTION_KEY=""
|
# MFA_ENCRYPTION_KEY=""
|
||||||
# WEBHOOK_ENCRYPTION_KEY=""
|
# WEBHOOK_ENCRYPTION_KEY=""
|
||||||
# BACKUP_ENCRYPTION_KEY=""
|
# BACKUP_ENCRYPTION_KEY=""
|
||||||
|
# Seals voice notes (audio, transcripts, suggestions); required in production.
|
||||||
|
# VOICE_ENCRYPTION_KEY=""
|
||||||
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
# MFA_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
# VOICE_ENCRYPTION_KEY_PREVIOUS=""
|
||||||
|
|
||||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"migrate:deploy": "prisma migrate deploy",
|
"migrate:deploy": "prisma migrate deploy",
|
||||||
"seed": "node dist/seed.js"
|
"seed": "node dist/seed.js",
|
||||||
|
"export:voice-vocabulary": "ts-node scripts/export-voice-vocabulary.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceCaptureSource" AS ENUM ('AUDIO', 'TEXT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "VoiceCaptureStatus" AS ENUM ('QUEUED', 'TRANSCRIBING', 'TRANSCRIBED', 'EXTRACTING', 'READY', 'CONFIRMED', 'DISCARDED', 'FAILED', 'EXPIRED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceCapture" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"ownerUserId" TEXT NOT NULL,
|
||||||
|
"colonyId" TEXT NOT NULL,
|
||||||
|
"inspectionDate" DATE NOT NULL,
|
||||||
|
"source" "VoiceCaptureSource" NOT NULL,
|
||||||
|
"status" "VoiceCaptureStatus" NOT NULL DEFAULT 'QUEUED',
|
||||||
|
"audioMime" TEXT,
|
||||||
|
"audioBytes" INTEGER,
|
||||||
|
"durationSec" INTEGER,
|
||||||
|
"transcriptEnc" TEXT,
|
||||||
|
"suggestionEnc" TEXT,
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"availableAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"claimedAt" TIMESTAMP(3),
|
||||||
|
"claimToken" TEXT,
|
||||||
|
"lastErrorCode" TEXT,
|
||||||
|
"model" TEXT,
|
||||||
|
"inputTokens" INTEGER,
|
||||||
|
"outputTokens" INTEGER,
|
||||||
|
"inspectionId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"readyAt" TIMESTAMP(3),
|
||||||
|
"contentExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"contentDeletedAt" TIMESTAMP(3),
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceCapture_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VoiceCaptureAudio" (
|
||||||
|
"captureId" TEXT NOT NULL,
|
||||||
|
"dataEnc" BYTEA NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "VoiceCaptureAudio_pkey" PRIMARY KEY ("captureId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_status_availableAt_idx" ON "VoiceCapture"("status", "availableAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_ownerUserId_status_idx" ON "VoiceCapture"("ownerUserId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "VoiceCapture_contentExpiresAt_idx" ON "VoiceCapture"("contentExpiresAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCapture" ADD CONSTRAINT "VoiceCapture_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VoiceCaptureAudio" ADD CONSTRAINT "VoiceCaptureAudio_captureId_fkey" FOREIGN KEY ("captureId") REFERENCES "VoiceCapture"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Constraints Prisma cannot express.
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_audioBytes_range"
|
||||||
|
CHECK ("audioBytes" IS NULL OR "audioBytes" BETWEEN 1 AND 2097152);
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_durationSec_range"
|
||||||
|
CHECK ("durationSec" IS NULL OR "durationSec" BETWEEN 1 AND 240);
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_confirmed_matches_inspection"
|
||||||
|
CHECK (("status" = 'CONFIRMED') = ("inspectionId" IS NOT NULL));
|
||||||
|
|
||||||
|
ALTER TABLE "VoiceCapture"
|
||||||
|
ADD CONSTRAINT "VoiceCapture_audioMime_only_for_audio"
|
||||||
|
CHECK ("source" = 'AUDIO' OR "audioMime" IS NULL);
|
||||||
|
|
||||||
|
-- Permission: the boot-time catalog sync only grants new permissions to
|
||||||
|
-- system roles (ADMIN), so the grants are made here (same as T6). READONLY
|
||||||
|
-- and API_CLIENT get nothing: voice notes are for people who inspect.
|
||||||
|
INSERT INTO "Permission" (id, key, resource, action, "labelDe", "groupDe", "sortOrder")
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'voice-captures:use', 'voice-captures', 'use', 'Benutzen', 'Sprache', 4000)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "RolePermission" ("roleId", "permissionId", "grantedAt")
|
||||||
|
SELECT r.id, p.id, now()
|
||||||
|
FROM "Role" r, "Permission" p
|
||||||
|
WHERE p.key = 'voice-captures:use'
|
||||||
|
AND r.key IN ('ADMIN', 'BEEKEEPER', 'STAFF')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -104,6 +104,7 @@ model User {
|
|||||||
refreshTokens RefreshToken[]
|
refreshTokens RefreshToken[]
|
||||||
|
|
||||||
inspections Inspection[]
|
inspections Inspection[]
|
||||||
|
voiceCaptures VoiceCapture[]
|
||||||
hiveChanges HiveChange[]
|
hiveChanges HiveChange[]
|
||||||
varroaMeasurements VarroaMeasurement[]
|
varroaMeasurements VarroaMeasurement[]
|
||||||
swarmControlChecks SwarmControlCheck[]
|
swarmControlChecks SwarmControlCheck[]
|
||||||
@@ -333,6 +334,7 @@ model Colony {
|
|||||||
queenEvaluations QueenEvaluation[]
|
queenEvaluations QueenEvaluation[]
|
||||||
suggestions Suggestion[]
|
suggestions Suggestion[]
|
||||||
hiveSetup HiveSetup?
|
hiveSetup HiveSetup?
|
||||||
|
voiceCaptures VoiceCapture[]
|
||||||
|
|
||||||
@@index([apiaryId])
|
@@index([apiaryId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -2285,3 +2287,73 @@ enum HiveChangeSource {
|
|||||||
INSPECTION
|
INSPECTION
|
||||||
AI_SUGGESTION
|
AI_SUGGESTION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VoiceCaptureSource {
|
||||||
|
AUDIO
|
||||||
|
TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VoiceCaptureStatus {
|
||||||
|
QUEUED // audio uploaded, waits for Whisper
|
||||||
|
TRANSCRIBING // claimed by the CronJob
|
||||||
|
TRANSCRIBED // text present, waits for Claude (also source TEXT)
|
||||||
|
EXTRACTING // claimed by the worker
|
||||||
|
READY // suggestions present, waits for the user
|
||||||
|
CONFIRMED // inspection saved, content deleted
|
||||||
|
DISCARDED // discarded by the user, content deleted
|
||||||
|
FAILED // finally failed, code in lastErrorCode
|
||||||
|
EXPIRED // deadline passed, content deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// A voice note. Audio and text are personal data: content lives here only
|
||||||
|
// encrypted and only until it is taken over, at most 7 days. The row itself
|
||||||
|
// holds no content and stays 90 days for the error and cost overview.
|
||||||
|
model VoiceCapture {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
ownerUserId String
|
||||||
|
owner User @relation(fields: [ownerUserId], references: [id])
|
||||||
|
colonyId String
|
||||||
|
colony Colony @relation(fields: [colonyId], references: [id])
|
||||||
|
inspectionDate DateTime @db.Date // date of the inspection per the form
|
||||||
|
source VoiceCaptureSource
|
||||||
|
status VoiceCaptureStatus @default(QUEUED)
|
||||||
|
|
||||||
|
audioMime String? // audio/webm, audio/ogg, audio/mp4
|
||||||
|
audioBytes Int?
|
||||||
|
durationSec Int? // reported by the device; Whisper measures again
|
||||||
|
|
||||||
|
transcriptEnc String? // key-ring ciphertext
|
||||||
|
suggestionEnc String? // key-ring ciphertext of the checked suggestion
|
||||||
|
|
||||||
|
attempts Int @default(0)
|
||||||
|
availableAt DateTime @default(now())
|
||||||
|
claimedAt DateTime?
|
||||||
|
claimToken String? // guards against completion by a process that lost the claim
|
||||||
|
lastErrorCode String? // fixed code, never content
|
||||||
|
|
||||||
|
model String? // e.g. "claude-sonnet-5-5"
|
||||||
|
inputTokens Int?
|
||||||
|
outputTokens Int?
|
||||||
|
|
||||||
|
inspectionId String? // set when CONFIRMED
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
readyAt DateTime?
|
||||||
|
contentExpiresAt DateTime // createdAt + 7 days
|
||||||
|
contentDeletedAt DateTime? // when audio/text/suggestion were deleted
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
audio VoiceCaptureAudio?
|
||||||
|
|
||||||
|
@@index([status, availableAt])
|
||||||
|
@@index([ownerUserId, status])
|
||||||
|
@@index([contentExpiresAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audio lives in its own table: deleting is a DELETE of the row (not
|
||||||
|
// nulling a big column), and the row is never loaded into lists.
|
||||||
|
model VoiceCaptureAudio {
|
||||||
|
captureId String @id
|
||||||
|
capture VoiceCapture @relation(fields: [captureId], references: [id], onDelete: Cascade)
|
||||||
|
dataEnc Bytes // key-ring ciphertext of the audio, at most 2 MB plaintext
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Writes the Whisper initial prompt to ApiCore-Whisper/vocabulary.txt, so the
|
||||||
|
* Python image uses the same word list as the backend (one source:
|
||||||
|
* src/voice/core/voice-vocabulary.ts). Run `npm run export:voice-vocabulary`
|
||||||
|
* after changing the list; voice-vocabulary-export.spec.ts fails until you do.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { whisperInitialPrompt } from '../src/voice/core/voice-vocabulary';
|
||||||
|
|
||||||
|
const target = join(__dirname, '..', '..', 'ApiCore-Whisper', 'vocabulary.txt');
|
||||||
|
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, `${whisperInitialPrompt()}\n`, 'utf8');
|
||||||
|
console.log(`Wrote ${target}`);
|
||||||
@@ -415,6 +415,8 @@ export const PERMISSION_CATALOG = [
|
|||||||
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
// Beute in 3D: hive systems and their part-type catalog. Reading the
|
||||||
// catalog and writing a colony's setup use the colonies permissions.
|
// catalog and writing a colony's setup use the colonies permissions.
|
||||||
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
...group('Beutensysteme', 'hive-systems', [['manage', 'Pflegen']], 3900),
|
||||||
|
// T7 Freihand: voice notes. Every endpoint also needs inspections:create.
|
||||||
|
...group('Sprache', 'voice-captures', [['use', 'Benutzen']], 4000),
|
||||||
] as const satisfies readonly PermissionCatalogEntry[];
|
] as const satisfies readonly PermissionCatalogEntry[];
|
||||||
|
|
||||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||||
@@ -1536,6 +1536,20 @@ describe('BackupService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('excludes VoiceCapture and VoiceCaptureAudio with a reason', async () => {
|
||||||
|
const bundle = await service.exportAll();
|
||||||
|
|
||||||
|
for (const [model, key] of [
|
||||||
|
['VoiceCapture', 'voiceCapture'],
|
||||||
|
['VoiceCaptureAudio', 'voiceCaptureAudio'],
|
||||||
|
] as const) {
|
||||||
|
expect(bundle.data).not.toHaveProperty(key);
|
||||||
|
expect(MODELS_EXCLUDED_FROM_BACKUP[model]).toMatch(
|
||||||
|
/ephemeral, personal data, not deliverable after a restore/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
it('restores phenology reports after the apiaries, remapping a user that does not exist', async () => {
|
||||||
await importData({
|
await importData({
|
||||||
apiary: [{ id: 'apiary-1' }],
|
apiary: [{ id: 'apiary-1' }],
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
|||||||
NotificationDelivery:
|
NotificationDelivery:
|
||||||
'Delivery queue - restoring it would resend old messages.',
|
'Delivery queue - restoring it would resend old messages.',
|
||||||
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
TelegramPollState: 'Update offset and poll lease of this deployment.',
|
||||||
|
VoiceCapture:
|
||||||
|
'Voice notes are ephemeral, personal data, not deliverable after a restore (content is deleted after at most 7 days).',
|
||||||
|
VoiceCaptureAudio:
|
||||||
|
'Encrypted audio of voice notes: ephemeral, personal data, not deliverable after a restore.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
|
* Secrets the server must read back (TOTP secrets, webhook signing secrets,
|
||||||
* backup files) are encrypted with a key of their own per purpose, never
|
* voice notes, backup files) are encrypted with a key of their own per purpose, never
|
||||||
* with a JWT secret. Each purpose has a key ring: the current key from
|
* with a JWT secret. Each purpose has a key ring: the current key from
|
||||||
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
|
* `<VARIABLE>` seals, the previous one from `<VARIABLE>_PREVIOUS` still
|
||||||
* opens, so a key can be rotated without losing data. A sealed value names
|
* opens, so a key can be rotated without losing data. A sealed value names
|
||||||
@@ -33,13 +33,14 @@ export const ENCRYPTION_KEY_VARIABLES = {
|
|||||||
mfa: 'MFA_ENCRYPTION_KEY',
|
mfa: 'MFA_ENCRYPTION_KEY',
|
||||||
webhook: 'WEBHOOK_ENCRYPTION_KEY',
|
webhook: 'WEBHOOK_ENCRYPTION_KEY',
|
||||||
backup: 'BACKUP_ENCRYPTION_KEY',
|
backup: 'BACKUP_ENCRYPTION_KEY',
|
||||||
|
voice: 'VOICE_ENCRYPTION_KEY',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type EncryptionKeyVariable =
|
export type EncryptionKeyVariable =
|
||||||
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
|
(typeof ENCRYPTION_KEY_VARIABLES)[keyof typeof ENCRYPTION_KEY_VARIABLES];
|
||||||
|
|
||||||
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
|
/** Purposes sealed in the `k…:` string format; backups have their own envelope (backup-crypto.ts). */
|
||||||
export type SecretPurpose = 'mfa' | 'webhook';
|
export type SecretPurpose = 'mfa' | 'webhook' | 'voice';
|
||||||
|
|
||||||
export interface RingKey {
|
export interface RingKey {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
|
DEFAULT_RETRY_POLICY,
|
||||||
MAX_ATTEMPTS,
|
MAX_ATTEMPTS,
|
||||||
RETRY_DELAYS_MS,
|
RETRY_DELAYS_MS,
|
||||||
retryDelayMs,
|
retryDelayMs,
|
||||||
|
retryDelayMsFor,
|
||||||
STALE_CLAIM_MS,
|
STALE_CLAIM_MS,
|
||||||
} from './retry-policy';
|
} from './retry-policy';
|
||||||
|
|
||||||
@@ -26,4 +28,29 @@ describe('retry policy', () => {
|
|||||||
expect(() => retryDelayMs(attempts)).toThrow(RangeError);
|
expect(() => retryDelayMs(attempts)).toThrow(RangeError);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('keeps retryDelayMs identical to the default policy', () => {
|
||||||
|
expect(DEFAULT_RETRY_POLICY).toEqual({
|
||||||
|
delaysMs: RETRY_DELAYS_MS,
|
||||||
|
maxAttempts: MAX_ATTEMPTS,
|
||||||
|
staleClaimMs: STALE_CLAIM_MS,
|
||||||
|
});
|
||||||
|
for (const attempts of [1, 2, 3, 4, 5, 6, 9]) {
|
||||||
|
expect(retryDelayMsFor(DEFAULT_RETRY_POLICY, attempts)).toBe(
|
||||||
|
retryDelayMs(attempts),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retryDelayMsFor uses the policy's delays and repeats the last one", () => {
|
||||||
|
const policy = {
|
||||||
|
delaysMs: [1_000, 5_000],
|
||||||
|
maxAttempts: 4,
|
||||||
|
staleClaimMs: 60_000,
|
||||||
|
};
|
||||||
|
expect([1, 2, 3].map((n) => retryDelayMsFor(policy, n))).toEqual([
|
||||||
|
1_000, 5_000, 5_000,
|
||||||
|
]);
|
||||||
|
expect(() => retryDelayMsFor(policy, 0)).toThrow(RangeError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -12,12 +12,36 @@ export const RETRY_DELAYS_MS = [
|
|||||||
export const MAX_ATTEMPTS = 8;
|
export const MAX_ATTEMPTS = 8;
|
||||||
export const STALE_CLAIM_MS = 300_000;
|
export const STALE_CLAIM_MS = 300_000;
|
||||||
|
|
||||||
/** Delay after `attempts` failed attempts (1 = the first failure). */
|
/**
|
||||||
export function retryDelayMs(attempts: number): number {
|
* A retry rule as data, for queues with their own pace (voice notes: 3
|
||||||
|
* attempts, minutes instead of hours). `delaysMs[n - 1]` is the wait after
|
||||||
|
* the n-th failure, the last entry repeats; the `maxAttempts`-th failure
|
||||||
|
* ends in FAILED; a claim older than `staleClaimMs` belongs to a dead worker.
|
||||||
|
*/
|
||||||
|
export interface RetryPolicy {
|
||||||
|
readonly delaysMs: readonly number[];
|
||||||
|
readonly maxAttempts: number;
|
||||||
|
readonly staleClaimMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rule of the outboxes (the constants above). */
|
||||||
|
export const DEFAULT_RETRY_POLICY: RetryPolicy = {
|
||||||
|
delaysMs: RETRY_DELAYS_MS,
|
||||||
|
maxAttempts: MAX_ATTEMPTS,
|
||||||
|
staleClaimMs: STALE_CLAIM_MS,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Delay after `attempts` failed attempts (1 = the first failure) under `policy`. */
|
||||||
|
export function retryDelayMsFor(policy: RetryPolicy, attempts: number): number {
|
||||||
if (!Number.isInteger(attempts) || attempts < 1) {
|
if (!Number.isInteger(attempts) || attempts < 1) {
|
||||||
throw new RangeError(
|
throw new RangeError(
|
||||||
`Retry delay needs a positive number of failed attempts, got ${attempts}`,
|
`Retry delay needs a positive number of failed attempts, got ${attempts}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RETRY_DELAYS_MS[Math.min(attempts, RETRY_DELAYS_MS.length) - 1];
|
return policy.delaysMs[Math.min(attempts, policy.delaysMs.length) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delay after `attempts` failed attempts (1 = the first failure). */
|
||||||
|
export function retryDelayMs(attempts: number): number {
|
||||||
|
return retryDelayMsFor(DEFAULT_RETRY_POLICY, attempts);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,14 @@ import {
|
|||||||
openWebhookSecret,
|
openWebhookSecret,
|
||||||
sealWebhookSecret,
|
sealWebhookSecret,
|
||||||
} from '../webhooks/webhook-secret';
|
} from '../webhooks/webhook-secret';
|
||||||
|
import {
|
||||||
|
isVoiceSealedWithCurrentKey,
|
||||||
|
openVoiceBytes,
|
||||||
|
openVoiceText,
|
||||||
|
sealVoiceBytes,
|
||||||
|
sealVoiceText,
|
||||||
|
voiceAad,
|
||||||
|
} from '../voice/core/voice-crypto';
|
||||||
import { RekeyJob } from './rekey.job';
|
import { RekeyJob } from './rekey.job';
|
||||||
|
|
||||||
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
|
// Written by the code before the key ring, with JWT_ACCESS_SECRET = 'x' * 40
|
||||||
@@ -67,11 +75,139 @@ function sealedWithPrevious(variable: string, seal: () => string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CaptureRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
transcriptEnc: string | null;
|
||||||
|
suggestionEnc: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioRow {
|
||||||
|
captureId: string;
|
||||||
|
dataEnc: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceFixture {
|
||||||
|
captures: CaptureRow[];
|
||||||
|
audio: AudioRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusFilter {
|
||||||
|
status?: { notIn: string[] };
|
||||||
|
capture?: { status: { notIn: string[] } };
|
||||||
|
transcriptEnc?: { not: null } | string;
|
||||||
|
suggestionEnc?: { not: null } | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaptureUpdate {
|
||||||
|
where: { id: string; status: { notIn: string[] } } & Record<string, unknown>;
|
||||||
|
data: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioUpdate {
|
||||||
|
where: {
|
||||||
|
captureId: string;
|
||||||
|
dataEnc: Buffer;
|
||||||
|
capture: { status: { notIn: string[] } };
|
||||||
|
};
|
||||||
|
data: { dataEnc: Buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The voice tables, in memory, with the where-shapes the job uses. */
|
||||||
|
function fakeVoice({ captures, audio }: VoiceFixture) {
|
||||||
|
const captureRows = new Map(captures.map((row) => [row.id, { ...row }]));
|
||||||
|
const audioRows = new Map(audio.map((row) => [row.captureId, { ...row }]));
|
||||||
|
const open = (id: string, notIn: string[]): boolean => {
|
||||||
|
const row = captureRows.get(id);
|
||||||
|
return row !== undefined && !notIn.includes(row.status);
|
||||||
|
};
|
||||||
|
const voiceCapture = {
|
||||||
|
findMany: jest.fn(({ where }: { where: StatusFilter }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
[...captureRows.values()].filter(
|
||||||
|
(row) =>
|
||||||
|
open(row.id, where.status?.notIn ?? []) &&
|
||||||
|
(where.transcriptEnc === undefined || row.transcriptEnc !== null) &&
|
||||||
|
(where.suggestionEnc === undefined || row.suggestionEnc !== null),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
updateMany: jest.fn<Promise<UpdateResult>, [CaptureUpdate]>(
|
||||||
|
({ where, data }) => {
|
||||||
|
const row = captureRows.get(where.id);
|
||||||
|
const column =
|
||||||
|
'transcriptEnc' in where ? 'transcriptEnc' : 'suggestionEnc';
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
!open(where.id, where.status.notIn) ||
|
||||||
|
row[column] !== where[column]
|
||||||
|
) {
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
captureRows.set(where.id, { ...row, [column]: data[column] });
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const voiceCaptureAudio = {
|
||||||
|
findMany: jest.fn(({ where }: { where: StatusFilter }) =>
|
||||||
|
Promise.resolve(
|
||||||
|
[...audioRows.values()]
|
||||||
|
.filter((row) =>
|
||||||
|
open(row.captureId, where.capture?.status.notIn ?? []),
|
||||||
|
)
|
||||||
|
.map((row) => ({ captureId: row.captureId })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
findFirst: jest.fn(
|
||||||
|
({ where }: { where: { captureId: string } & StatusFilter }) => {
|
||||||
|
const row = audioRows.get(where.captureId);
|
||||||
|
return Promise.resolve(
|
||||||
|
row && open(row.captureId, where.capture?.status.notIn ?? [])
|
||||||
|
? { ...row }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
updateMany: jest.fn<Promise<UpdateResult>, [AudioUpdate]>(
|
||||||
|
({ where, data }) => {
|
||||||
|
const row = audioRows.get(where.captureId);
|
||||||
|
if (
|
||||||
|
!row ||
|
||||||
|
!open(where.captureId, where.capture.status.notIn) ||
|
||||||
|
!row.dataEnc.equals(where.dataEnc)
|
||||||
|
) {
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
audioRows.set(where.captureId, { ...row, dataEnc: data.dataEnc });
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
voiceCapture,
|
||||||
|
voiceCaptureAudio,
|
||||||
|
transcript: (id: string): string =>
|
||||||
|
captureRows.get(id)?.transcriptEnc ?? '',
|
||||||
|
suggestion: (id: string): string =>
|
||||||
|
captureRows.get(id)?.suggestionEnc ?? '',
|
||||||
|
audio: (id: string): Buffer =>
|
||||||
|
audioRows.get(id)?.dataEnc ?? Buffer.alloc(0),
|
||||||
|
captureRows,
|
||||||
|
audioRows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two tables, in memory. updateMany behaves like the SQL compare-and-
|
* The tables, in memory. updateMany behaves like the SQL compare-and-
|
||||||
* swap: it writes only while id and old value still match.
|
* swap: it writes only while id and old value still match.
|
||||||
*/
|
*/
|
||||||
function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
function fakeDb(
|
||||||
|
users: UserRow[],
|
||||||
|
subscriptions: SubscriptionRow[],
|
||||||
|
voiceFixture: VoiceFixture = { captures: [], audio: [] },
|
||||||
|
) {
|
||||||
|
const voice = fakeVoice(voiceFixture);
|
||||||
const userRows = new Map(users.map((row) => [row.id, row]));
|
const userRows = new Map(users.map((row) => [row.id, row]));
|
||||||
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
|
const subscriptionRows = new Map(subscriptions.map((row) => [row.id, row]));
|
||||||
const user = {
|
const user = {
|
||||||
@@ -105,9 +241,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
prisma: { user, webhookSubscription } as unknown as PrismaService,
|
prisma: {
|
||||||
user,
|
user,
|
||||||
webhookSubscription,
|
webhookSubscription,
|
||||||
|
voiceCapture: voice.voiceCapture,
|
||||||
|
voiceCaptureAudio: voice.voiceCaptureAudio,
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
user,
|
||||||
|
webhookSubscription,
|
||||||
|
voice,
|
||||||
userRows,
|
userRows,
|
||||||
subscriptionRows,
|
subscriptionRows,
|
||||||
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
|
mfaSecret: (id: string): string => userRows.get(id)?.mfaSecret ?? '',
|
||||||
@@ -118,6 +260,15 @@ function fakeDb(users: UserRow[], subscriptions: SubscriptionRow[]) {
|
|||||||
|
|
||||||
const keyIdOf = (stored: string): string => stored.split(':')[0];
|
const keyIdOf = (stored: string): string => stored.split(':')[0];
|
||||||
|
|
||||||
|
const NO_VOICE = {
|
||||||
|
'voice.total': 0,
|
||||||
|
'voice.rekeyed': 0,
|
||||||
|
'voice.alreadyCurrent': 0,
|
||||||
|
'voice.plaintextSealed': 0,
|
||||||
|
'voice.changedMeanwhile': 0,
|
||||||
|
'voice.failed': 0,
|
||||||
|
};
|
||||||
|
|
||||||
describe('RekeyJob', () => {
|
describe('RekeyJob', () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
let warn: jest.SpyInstance<void, unknown[]>;
|
let warn: jest.SpyInstance<void, unknown[]>;
|
||||||
@@ -129,6 +280,8 @@ describe('RekeyJob', () => {
|
|||||||
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
|
process.env.MFA_ENCRYPTION_KEY_PREVIOUS = 'n'.repeat(40);
|
||||||
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
|
process.env.WEBHOOK_ENCRYPTION_KEY = 'w'.repeat(40);
|
||||||
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
|
process.env.WEBHOOK_ENCRYPTION_KEY_PREVIOUS = 'v'.repeat(40);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = 'c'.repeat(40);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = 'p'.repeat(40);
|
||||||
warn = jest
|
warn = jest
|
||||||
.spyOn(Logger.prototype, 'warn')
|
.spyOn(Logger.prototype, 'warn')
|
||||||
.mockImplementation(() => undefined);
|
.mockImplementation(() => undefined);
|
||||||
@@ -192,6 +345,7 @@ describe('RekeyJob', () => {
|
|||||||
'webhook.plaintextSealed': 1,
|
'webhook.plaintextSealed': 1,
|
||||||
'webhook.changedMeanwhile': 0,
|
'webhook.changedMeanwhile': 0,
|
||||||
'webhook.failed': 0,
|
'webhook.failed': 0,
|
||||||
|
...NO_VOICE,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
|
it('rekeys legacy MFA and webhook secrets and seals legacy plaintext webhook secrets', async () => {
|
||||||
@@ -411,7 +565,7 @@ describe('RekeyJob', () => {
|
|||||||
|
|
||||||
it('dry-run opens them and fails the run on a value that does not open', async () => {
|
it('dry-run opens them and fails the run on a value that does not open', async () => {
|
||||||
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
||||||
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1$/,
|
/^rekey: 2 secret\(s\) could not be rekeyed - dryRun=true, mfa\.total=2, mfa\.rekeyed=0, mfa\.alreadyCurrent=1, .*mfa\.failed=1, webhook\.total=2, webhook\.rekeyed=0, webhook\.alreadyCurrent=1, .*webhook\.failed=1, voice\..*$/,
|
||||||
);
|
);
|
||||||
expect(db.user.updateMany).not.toHaveBeenCalled();
|
expect(db.user.updateMany).not.toHaveBeenCalled();
|
||||||
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
||||||
@@ -490,7 +644,7 @@ describe('RekeyJob', () => {
|
|||||||
|
|
||||||
it('dry-run reports them too and writes nothing', async () => {
|
it('dry-run reports them too and writes nothing', async () => {
|
||||||
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
await expect(new RekeyJob(db.prisma).run(['--dry-run'])).rejects.toThrow(
|
||||||
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1$/,
|
/^rekey: 3 secret\(s\) could not be rekeyed - dryRun=true, .*mfa\.rekeyed=1, .*mfa\.failed=2, .*webhook\.plaintextSealed=1, .*webhook\.failed=1, voice\..*$/,
|
||||||
);
|
);
|
||||||
expect(db.user.updateMany).not.toHaveBeenCalled();
|
expect(db.user.updateMany).not.toHaveBeenCalled();
|
||||||
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
expect(db.webhookSubscription.updateMany).not.toHaveBeenCalled();
|
||||||
@@ -566,4 +720,253 @@ describe('RekeyJob', () => {
|
|||||||
expect(db.user.findMany).not.toHaveBeenCalled();
|
expect(db.user.findMany).not.toHaveBeenCalled();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
describe('voice content', () => {
|
||||||
|
const TRANSCRIPT = 'Koenigin gesehen, acht Waben besetzt';
|
||||||
|
const SUGGESTION = '{"suggestions":[{"field":"queenSeen"}]}';
|
||||||
|
const AUDIO = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 9, 8, 7]);
|
||||||
|
|
||||||
|
/** Seals with the key that VOICE_ENCRYPTION_KEY_PREVIOUS names, as before a rotation. */
|
||||||
|
function underPrevious<T>(seal: () => T): T {
|
||||||
|
const current = process.env.VOICE_ENCRYPTION_KEY;
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY =
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
try {
|
||||||
|
return seal();
|
||||||
|
} finally {
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFixture(): ReturnType<typeof fakeDb> {
|
||||||
|
return fakeDb([], [], {
|
||||||
|
captures: [
|
||||||
|
{
|
||||||
|
// READY, everything under the previous key.
|
||||||
|
id: 'c1',
|
||||||
|
status: 'READY',
|
||||||
|
transcriptEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(TRANSCRIPT, voiceAad('c1', 'transcript')),
|
||||||
|
),
|
||||||
|
suggestionEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(SUGGESTION, voiceAad('c1', 'suggestion')),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// QUEUED, audio under the previous key, no text yet.
|
||||||
|
id: 'c2',
|
||||||
|
status: 'QUEUED',
|
||||||
|
transcriptEnc: null,
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// TRANSCRIBED, already current.
|
||||||
|
id: 'c3',
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
transcriptEnc: sealVoiceText(
|
||||||
|
TRANSCRIPT,
|
||||||
|
voiceAad('c3', 'transcript'),
|
||||||
|
),
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// CONFIRMED rows hold no content any more; a leftover is not touched.
|
||||||
|
id: 'c4',
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
transcriptEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(TRANSCRIPT, voiceAad('c4', 'transcript')),
|
||||||
|
),
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audio: [
|
||||||
|
{
|
||||||
|
captureId: 'c2',
|
||||||
|
dataEnc: underPrevious(() =>
|
||||||
|
sealVoiceBytes(AUDIO, voiceAad('c2', 'audio')),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
captureId: 'c4',
|
||||||
|
dataEnc: underPrevious(() =>
|
||||||
|
sealVoiceBytes(AUDIO, voiceAad('c4', 'audio')),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rekey reseals open voice content and skips confirmed rows', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const confirmedTranscript = db.voice.transcript('c4');
|
||||||
|
const confirmedAudio = db.voice.audio('c4');
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
// c1: transcript + suggestion, c2: audio, c3: already current.
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
'voice.total': 4,
|
||||||
|
'voice.rekeyed': 3,
|
||||||
|
'voice.alreadyCurrent': 1,
|
||||||
|
'voice.plaintextSealed': 0,
|
||||||
|
'voice.changedMeanwhile': 0,
|
||||||
|
'voice.failed': 0,
|
||||||
|
});
|
||||||
|
expect(isVoiceSealedWithCurrentKey(db.voice.transcript('c1'))).toBe(true);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(db.voice.suggestion('c1'))).toBe(true);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(db.voice.audio('c2'))).toBe(true);
|
||||||
|
expect(db.voice.transcript('c4')).toBe(confirmedTranscript);
|
||||||
|
expect(db.voice.audio('c4').equals(confirmedAudio)).toBe(true);
|
||||||
|
// Every filter excludes the three finished states.
|
||||||
|
expect(db.voice.voiceCapture.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] },
|
||||||
|
}) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves voice content that opens to the original after the previous key is gone', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
await new RekeyJob(db.prisma).run([]);
|
||||||
|
delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
openVoiceText(db.voice.transcript('c1'), voiceAad('c1', 'transcript')),
|
||||||
|
).toBe(TRANSCRIPT);
|
||||||
|
expect(
|
||||||
|
openVoiceText(db.voice.suggestion('c1'), voiceAad('c1', 'suggestion')),
|
||||||
|
).toBe(SUGGESTION);
|
||||||
|
expect(
|
||||||
|
openVoiceBytes(db.voice.audio('c2'), voiceAad('c2', 'audio')).equals(
|
||||||
|
AUDIO,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dry-run counts voice content and writes nothing', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const before = db.voice.transcript('c1');
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run(['--dry-run']);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
dryRun: true,
|
||||||
|
'voice.total': 4,
|
||||||
|
'voice.rekeyed': 3,
|
||||||
|
'voice.alreadyCurrent': 1,
|
||||||
|
});
|
||||||
|
expect(db.voice.voiceCapture.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(db.voice.voiceCaptureAudio.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(db.voice.transcript('c1')).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite voice content that changed since it was read', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const newer = sealVoiceText('neu', voiceAad('c1', 'transcript'));
|
||||||
|
db.voice.voiceCapture.findMany.mockImplementationOnce(() => {
|
||||||
|
const read = [...db.voice.captureRows.values()]
|
||||||
|
.filter(
|
||||||
|
(row) => row.transcriptEnc !== null && row.status !== 'CONFIRMED',
|
||||||
|
)
|
||||||
|
.map((row) => ({ ...row }));
|
||||||
|
const row = db.voice.captureRows.get('c1');
|
||||||
|
if (row) {
|
||||||
|
db.voice.captureRows.set('c1', { ...row, transcriptEnc: newer });
|
||||||
|
}
|
||||||
|
return Promise.resolve(read);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 });
|
||||||
|
expect(db.voice.transcript('c1')).toBe(newer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the run on a voice value that cannot be opened and never logs content', async () => {
|
||||||
|
const db = fakeDb([], [], {
|
||||||
|
captures: [
|
||||||
|
{
|
||||||
|
id: 'c1',
|
||||||
|
status: 'READY',
|
||||||
|
// Bound to another capture: does not authenticate here.
|
||||||
|
transcriptEnc: underPrevious(() =>
|
||||||
|
sealVoiceText(TRANSCRIPT, voiceAad('other', 'transcript')),
|
||||||
|
),
|
||||||
|
suggestionEnc: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audio: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = await new RekeyJob(db.prisma).run([]).then(
|
||||||
|
() => '',
|
||||||
|
(failure: Error) => failure.message,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(message).toContain('voice.failed=1');
|
||||||
|
expect(logged()).toContainEqual(
|
||||||
|
expect.stringContaining(
|
||||||
|
'VoiceCapture c1: voice secret not rekeyed: ciphertext failed authentication',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const text of [message, ...logged()]) {
|
||||||
|
expect(text).not.toContain('Koenigin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads audio one blob at a time and never selects the data in the id listing', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
|
||||||
|
await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
expect(db.voice.voiceCaptureAudio.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ select: { captureId: true } }),
|
||||||
|
);
|
||||||
|
expect(db.voice.voiceCaptureAudio.findFirst).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write audio of a capture that was confirmed since it was read', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
const before = db.voice.audio('c2');
|
||||||
|
db.voice.voiceCaptureAudio.findFirst.mockImplementationOnce(
|
||||||
|
({ where }: { where: { captureId: string } }) => {
|
||||||
|
const read = { captureId: where.captureId, dataEnc: before };
|
||||||
|
const row = db.voice.captureRows.get(where.captureId);
|
||||||
|
if (row) {
|
||||||
|
db.voice.captureRows.set(where.captureId, {
|
||||||
|
...row,
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(read);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await new RekeyJob(db.prisma).run([]);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ 'voice.changedMeanwhile': 1 });
|
||||||
|
expect(db.voice.audio('c2').equals(before)).toBe(true);
|
||||||
|
expect(db.voice.voiceCaptureAudio.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
capture: {
|
||||||
|
status: { notIn: ['CONFIRMED', 'DISCARDED', 'EXPIRED'] },
|
||||||
|
},
|
||||||
|
}) as unknown,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails before reading any row when the voice key is unusable', async () => {
|
||||||
|
const db = openFixture();
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = 'too-short';
|
||||||
|
|
||||||
|
await expect(new RekeyJob(db.prisma).run([])).rejects.toThrow(
|
||||||
|
'VOICE_ENCRYPTION_KEY must be at least 32 characters',
|
||||||
|
);
|
||||||
|
expect(db.voice.voiceCapture.findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -13,13 +13,29 @@ import {
|
|||||||
sealWebhookSecret,
|
sealWebhookSecret,
|
||||||
webhookSecretNeedsRekey,
|
webhookSecretNeedsRekey,
|
||||||
} from '../webhooks/webhook-secret';
|
} from '../webhooks/webhook-secret';
|
||||||
|
import { VoiceCaptureStatus } from '../../generated/prisma';
|
||||||
|
import {
|
||||||
|
isVoiceSealedWithCurrentKey,
|
||||||
|
openVoiceBytes,
|
||||||
|
openVoiceText,
|
||||||
|
sealVoiceBytes,
|
||||||
|
sealVoiceText,
|
||||||
|
voiceAad,
|
||||||
|
} from '../voice/core/voice-crypto';
|
||||||
import { Job, JobResult } from './job';
|
import { Job, JobResult } from './job';
|
||||||
import { JobName } from './job-names';
|
import { JobName } from './job-names';
|
||||||
|
|
||||||
/** `rekey --dry-run`: open and count every secret, write none. */
|
/** `rekey --dry-run`: open and count every secret, write none. */
|
||||||
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
export const REKEY_DRY_RUN_ARG = '--dry-run';
|
||||||
|
|
||||||
/** What one run did with the secrets of one purpose (mfa, webhook). */
|
/** Voice content of these states is already deleted (or about to be) and is not resealed. */
|
||||||
|
const FINISHED_VOICE_STATUSES: VoiceCaptureStatus[] = [
|
||||||
|
VoiceCaptureStatus.CONFIRMED,
|
||||||
|
VoiceCaptureStatus.DISCARDED,
|
||||||
|
VoiceCaptureStatus.EXPIRED,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** What one run did with the secrets of one purpose (mfa, webhook, voice). */
|
||||||
export interface RekeyCounts {
|
export interface RekeyCounts {
|
||||||
/** Rows with a stored secret. */
|
/** Rows with a stored secret. */
|
||||||
total: number;
|
total: number;
|
||||||
@@ -51,6 +67,13 @@ interface SecretColumn {
|
|||||||
/** How log lines name a row: "<entity> <id>". */
|
/** How log lines name a row: "<entity> <id>". */
|
||||||
entity: string;
|
entity: string;
|
||||||
read(): Promise<StoredSecret[]>;
|
read(): Promise<StoredSecret[]>;
|
||||||
|
/**
|
||||||
|
* Instead of read(): the ids first, then one row at a time with readOne(),
|
||||||
|
* so large values (audio) are never held all at once. readOne gives null
|
||||||
|
* for a row that is gone or no longer eligible.
|
||||||
|
*/
|
||||||
|
readIds?(): Promise<string[]>;
|
||||||
|
readOne?(id: string): Promise<StoredSecret | null>;
|
||||||
/**
|
/**
|
||||||
* Writes `next` only while the row still holds `previous` - one UPDATE
|
* Writes `next` only while the row still holds `previous` - one UPDATE
|
||||||
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
* with both in its WHERE, so a value written meanwhile (a new MFA setup,
|
||||||
@@ -59,8 +82,9 @@ interface SecretColumn {
|
|||||||
swap(id: string, previous: string, next: string): Promise<boolean>;
|
swap(id: string, previous: string, next: string): Promise<boolean>;
|
||||||
needsRekey(stored: string): boolean;
|
needsRekey(stored: string): boolean;
|
||||||
isPlaintext(stored: string): boolean;
|
isPlaintext(stored: string): boolean;
|
||||||
open(stored: string): string;
|
/** `id` binds voice content to its row (associated data); the other purposes ignore it. */
|
||||||
seal(plaintext: string): string;
|
open(stored: string, id: string): string;
|
||||||
|
seal(plaintext: string, id: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
/** Refuses anything but --dry-run: a typo must never start the real run. */
|
||||||
@@ -87,6 +111,20 @@ function countOutcomes(outcomes: readonly RowOutcome[]): RekeyCounts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sums the counts of columns that share one purpose (voice has three). */
|
||||||
|
function sumCounts(all: readonly RekeyCounts[]): RekeyCounts {
|
||||||
|
const sum = (key: keyof RekeyCounts): number =>
|
||||||
|
all.reduce((total, each) => total + each[key], 0);
|
||||||
|
return {
|
||||||
|
total: sum('total'),
|
||||||
|
rekeyed: sum('rekeyed'),
|
||||||
|
alreadyCurrent: sum('alreadyCurrent'),
|
||||||
|
plaintextSealed: sum('plaintextSealed'),
|
||||||
|
changedMeanwhile: sum('changedMeanwhile'),
|
||||||
|
failed: sum('failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
/** `{ dryRun, 'mfa.total', …, 'webhook.failed' }` - counts only, never values. */
|
||||||
function toJobResult(
|
function toJobResult(
|
||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
@@ -112,7 +150,8 @@ function describeResult(result: JobResult): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reseals every stored MFA (TOTP) and webhook signing secret with the
|
* Reseals every stored MFA (TOTP) and webhook signing secret, and the
|
||||||
|
* content of open voice notes (transcript, suggestion, audio), with the
|
||||||
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
* current key of its purpose (common/crypto/key-ring.ts): values sealed
|
||||||
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
* with `*_PREVIOUS` or before the key ring (MFA without key id, webhook
|
||||||
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
* `enc:v1:`, both under sha256(JWT_ACCESS_SECRET)), and webhook secrets
|
||||||
@@ -135,15 +174,30 @@ export class RekeyJob implements Job {
|
|||||||
|
|
||||||
async run(args: readonly string[]): Promise<JobResult> {
|
async run(args: readonly string[]): Promise<JobResult> {
|
||||||
const dryRun = isDryRun(args);
|
const dryRun = isDryRun(args);
|
||||||
const columns = [this.mfaColumn(), this.webhookColumn()];
|
const columns = [
|
||||||
|
this.mfaColumn(),
|
||||||
|
this.webhookColumn(),
|
||||||
|
...this.voiceColumns(),
|
||||||
|
];
|
||||||
// A key that is set but unusable fails the run before any row is read.
|
// A key that is set but unusable fails the run before any row is read.
|
||||||
for (const column of columns) {
|
for (const purpose of new Set(columns.map((column) => column.purpose))) {
|
||||||
loadKeyRing(column.purpose);
|
loadKeyRing(purpose);
|
||||||
}
|
}
|
||||||
const counts: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
const perColumn: (readonly [SecretPurpose, RekeyCounts])[] = [];
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
counts.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
perColumn.push([column.purpose, await this.rekeyColumn(column, dryRun)]);
|
||||||
}
|
}
|
||||||
|
const counts = (['mfa', 'webhook', 'voice'] as const).map(
|
||||||
|
(purpose) =>
|
||||||
|
[
|
||||||
|
purpose,
|
||||||
|
sumCounts(
|
||||||
|
perColumn
|
||||||
|
.filter(([each]) => each === purpose)
|
||||||
|
.map(([, each]) => each),
|
||||||
|
),
|
||||||
|
] as const,
|
||||||
|
);
|
||||||
const result = toJobResult(dryRun, counts);
|
const result = toJobResult(dryRun, counts);
|
||||||
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
const failed = counts.reduce((sum, [, each]) => sum + each.failed, 0);
|
||||||
if (failed > 0) {
|
if (failed > 0) {
|
||||||
@@ -158,9 +212,17 @@ export class RekeyJob implements Job {
|
|||||||
column: SecretColumn,
|
column: SecretColumn,
|
||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
): Promise<RekeyCounts> {
|
): Promise<RekeyCounts> {
|
||||||
const rows = await column.read();
|
|
||||||
const outcomes: RowOutcome[] = [];
|
const outcomes: RowOutcome[] = [];
|
||||||
for (const row of rows) {
|
if (column.readIds && column.readOne) {
|
||||||
|
for (const id of await column.readIds()) {
|
||||||
|
const row = await column.readOne(id);
|
||||||
|
if (row !== null) {
|
||||||
|
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return countOutcomes(outcomes);
|
||||||
|
}
|
||||||
|
for (const row of await column.read()) {
|
||||||
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
outcomes.push(await this.rekeyRow(column, row, dryRun));
|
||||||
}
|
}
|
||||||
return countOutcomes(outcomes);
|
return countOutcomes(outcomes);
|
||||||
@@ -185,13 +247,13 @@ export class RekeyJob implements Job {
|
|||||||
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
const subject = `${column.entity} ${row.id}: ${column.purpose} secret`;
|
||||||
try {
|
try {
|
||||||
// Opened in a dry-run too: it reports what a real run could not reseal.
|
// Opened in a dry-run too: it reports what a real run could not reseal.
|
||||||
const plaintext = column.open(row.stored);
|
const plaintext = column.open(row.stored, row.id);
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
return outcome;
|
return outcome;
|
||||||
}
|
}
|
||||||
const resealed = column.seal(plaintext);
|
const resealed = column.seal(plaintext, row.id);
|
||||||
// The swap replaces the only copy of the old value.
|
// The swap replaces the only copy of the old value.
|
||||||
if (column.open(resealed) !== plaintext) {
|
if (column.open(resealed, row.id) !== plaintext) {
|
||||||
throw new Error('resealed secret does not open to the same value');
|
throw new Error('resealed secret does not open to the same value');
|
||||||
}
|
}
|
||||||
if (await column.swap(row.id, row.stored, resealed)) {
|
if (await column.swap(row.id, row.stored, resealed)) {
|
||||||
@@ -262,4 +324,96 @@ export class RekeyJob implements Job {
|
|||||||
seal: sealWebhookSecret,
|
seal: sealWebhookSecret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transcript, suggestion and audio of open voice notes. Audio is handled
|
||||||
|
* as base64 text so it shares the row logic; it is read only for open
|
||||||
|
* notes (at most a few small files).
|
||||||
|
*/
|
||||||
|
private voiceColumns(): SecretColumn[] {
|
||||||
|
const openStatus = { notIn: FINISHED_VOICE_STATUSES };
|
||||||
|
const textColumn = (
|
||||||
|
field: 'transcriptEnc' | 'suggestionEnc',
|
||||||
|
kind: 'transcript' | 'suggestion',
|
||||||
|
): SecretColumn => ({
|
||||||
|
purpose: 'voice',
|
||||||
|
entity: 'VoiceCapture',
|
||||||
|
read: async () => {
|
||||||
|
const rows = await this.prisma.voiceCapture.findMany({
|
||||||
|
where: { status: openStatus, [field]: { not: null } },
|
||||||
|
select: { id: true, [field]: true },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.flatMap((row) => {
|
||||||
|
const stored = row[field];
|
||||||
|
return typeof stored === 'string' ? [{ id: row.id, stored }] : [];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
swap: async (id, previous, next) => {
|
||||||
|
const { count } = await this.prisma.voiceCapture.updateMany({
|
||||||
|
where: { id, status: openStatus, [field]: previous },
|
||||||
|
data: { [field]: next },
|
||||||
|
});
|
||||||
|
return count === 1;
|
||||||
|
},
|
||||||
|
needsRekey: (stored) => !isVoiceSealedWithCurrentKey(stored),
|
||||||
|
isPlaintext: () => false,
|
||||||
|
open: (stored, id) => openVoiceText(stored, voiceAad(id, kind)),
|
||||||
|
seal: (plaintext, id) => sealVoiceText(plaintext, voiceAad(id, kind)),
|
||||||
|
});
|
||||||
|
const audioColumn: SecretColumn = {
|
||||||
|
purpose: 'voice',
|
||||||
|
entity: 'VoiceCapture',
|
||||||
|
read: () => Promise.resolve([]),
|
||||||
|
readIds: async () => {
|
||||||
|
const rows = await this.prisma.voiceCaptureAudio.findMany({
|
||||||
|
where: { capture: { status: openStatus } },
|
||||||
|
select: { captureId: true },
|
||||||
|
orderBy: { captureId: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.captureId);
|
||||||
|
},
|
||||||
|
readOne: async (id) => {
|
||||||
|
const row = await this.prisma.voiceCaptureAudio.findFirst({
|
||||||
|
where: { captureId: id, capture: { status: openStatus } },
|
||||||
|
select: { captureId: true, dataEnc: true },
|
||||||
|
});
|
||||||
|
return row
|
||||||
|
? {
|
||||||
|
id: row.captureId,
|
||||||
|
stored: Buffer.from(row.dataEnc).toString('base64'),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
},
|
||||||
|
swap: async (id, previous, next) => {
|
||||||
|
const { count } = await this.prisma.voiceCaptureAudio.updateMany({
|
||||||
|
where: {
|
||||||
|
captureId: id,
|
||||||
|
dataEnc: Buffer.from(previous, 'base64'),
|
||||||
|
capture: { status: openStatus },
|
||||||
|
},
|
||||||
|
data: { dataEnc: Buffer.from(next, 'base64') },
|
||||||
|
});
|
||||||
|
return count === 1;
|
||||||
|
},
|
||||||
|
needsRekey: (stored) =>
|
||||||
|
!isVoiceSealedWithCurrentKey(Buffer.from(stored, 'base64')),
|
||||||
|
isPlaintext: () => false,
|
||||||
|
open: (stored, id) =>
|
||||||
|
openVoiceBytes(
|
||||||
|
Buffer.from(stored, 'base64'),
|
||||||
|
voiceAad(id, 'audio'),
|
||||||
|
).toString('base64'),
|
||||||
|
seal: (plaintext, id) =>
|
||||||
|
sealVoiceBytes(
|
||||||
|
Buffer.from(plaintext, 'base64'),
|
||||||
|
voiceAad(id, 'audio'),
|
||||||
|
).toString('base64'),
|
||||||
|
};
|
||||||
|
return [
|
||||||
|
textColumn('transcriptEnc', 'transcript'),
|
||||||
|
textColumn('suggestionEnc', 'suggestion'),
|
||||||
|
audioColumn,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +259,7 @@ describe('lock keys per table', () => {
|
|||||||
'AuthorityNotice',
|
'AuthorityNotice',
|
||||||
'ApiaryRestriction',
|
'ApiaryRestriction',
|
||||||
'ComplianceAcknowledgement',
|
'ComplianceAcknowledgement',
|
||||||
|
'VoiceCapture',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const LOCKABLE_TABLES = {
|
|||||||
AuthorityNotice: lockable('AuthorityNotice'),
|
AuthorityNotice: lockable('AuthorityNotice'),
|
||||||
ApiaryRestriction: lockable('ApiaryRestriction'),
|
ApiaryRestriction: lockable('ApiaryRestriction'),
|
||||||
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
|
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
|
||||||
|
VoiceCapture: lockable('VoiceCapture'),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
export type LockableTable = keyof typeof LOCKABLE_TABLES;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const KEYS = {
|
|||||||
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
|
MFA_ENCRYPTION_KEY: 'm'.repeat(32),
|
||||||
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
|
WEBHOOK_ENCRYPTION_KEY: 'w'.repeat(32),
|
||||||
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
|
BACKUP_ENCRYPTION_KEY: 'b'.repeat(32),
|
||||||
|
VOICE_ENCRYPTION_KEY: 'v'.repeat(32),
|
||||||
};
|
};
|
||||||
const PROD_ENV = {
|
const PROD_ENV = {
|
||||||
...BASE_ENV,
|
...BASE_ENV,
|
||||||
@@ -81,16 +82,17 @@ describe('assertStartupEnvironment', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('encryption keys', () => {
|
describe('encryption keys', () => {
|
||||||
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY', () => {
|
it('production refuses to start without MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('worker', {
|
assertStartupEnvironment('worker', {
|
||||||
...PROD_ENV,
|
...PROD_ENV,
|
||||||
MFA_ENCRYPTION_KEY: undefined,
|
MFA_ENCRYPTION_KEY: undefined,
|
||||||
WEBHOOK_ENCRYPTION_KEY: undefined,
|
WEBHOOK_ENCRYPTION_KEY: undefined,
|
||||||
BACKUP_ENCRYPTION_KEY: undefined,
|
BACKUP_ENCRYPTION_KEY: undefined,
|
||||||
|
VOICE_ENCRYPTION_KEY: undefined,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY',
|
'Missing required environment variable(s): MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY, VOICE_ENCRYPTION_KEY',
|
||||||
);
|
);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('job', {
|
assertStartupEnvironment('job', {
|
||||||
@@ -126,7 +128,7 @@ describe('assertStartupEnvironment', () => {
|
|||||||
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
|
WEBHOOK_ENCRYPTION_KEY: KEYS.MFA_ENCRYPTION_KEY,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY and BACKUP_ENCRYPTION_KEY must be pairwise different',
|
'MFA_ENCRYPTION_KEY, WEBHOOK_ENCRYPTION_KEY, BACKUP_ENCRYPTION_KEY and VOICE_ENCRYPTION_KEY must be pairwise different',
|
||||||
);
|
);
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('api', {
|
assertStartupEnvironment('api', {
|
||||||
@@ -198,9 +200,11 @@ describe('assertStartupEnvironment', () => {
|
|||||||
'MFA_ENCRYPTION_KEY',
|
'MFA_ENCRYPTION_KEY',
|
||||||
'WEBHOOK_ENCRYPTION_KEY',
|
'WEBHOOK_ENCRYPTION_KEY',
|
||||||
'BACKUP_ENCRYPTION_KEY',
|
'BACKUP_ENCRYPTION_KEY',
|
||||||
|
'VOICE_ENCRYPTION_KEY',
|
||||||
'MFA_ENCRYPTION_KEY_PREVIOUS',
|
'MFA_ENCRYPTION_KEY_PREVIOUS',
|
||||||
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
|
'WEBHOOK_ENCRYPTION_KEY_PREVIOUS',
|
||||||
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
|
'BACKUP_ENCRYPTION_KEY_PREVIOUS',
|
||||||
|
'VOICE_ENCRYPTION_KEY_PREVIOUS',
|
||||||
])('production refuses a change-me placeholder in %s', (name) => {
|
])('production refuses a change-me placeholder in %s', (name) => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
|
assertStartupEnvironment('api', { ...PROD_ENV, [name]: PLACEHOLDER }),
|
||||||
@@ -360,4 +364,70 @@ describe('assertStartupEnvironment', () => {
|
|||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('voice', () => {
|
||||||
|
it('production requires VOICE_ENCRYPTION_KEY distinct from the others', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('api', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: undefined,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'Missing required environment variable(s): VOICE_ENCRYPTION_KEY',
|
||||||
|
);
|
||||||
|
for (const other of [
|
||||||
|
'MFA_ENCRYPTION_KEY',
|
||||||
|
'WEBHOOK_ENCRYPTION_KEY',
|
||||||
|
'BACKUP_ENCRYPTION_KEY',
|
||||||
|
] as const) {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('job', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: KEYS[other],
|
||||||
|
}),
|
||||||
|
).toThrow('must be pairwise different');
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('worker', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: PROD_ENV.JWT_REFRESH_SECRET,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'VOICE_ENCRYPTION_KEY must differ from JWT_ACCESS_SECRET and JWT_REFRESH_SECRET',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a change-me VOICE_ENCRYPTION_KEY in production', () => {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment('api', {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_ENCRYPTION_KEY: 'change-me-to-a-fifth-random-value-32-chars',
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
'Secret(s) still set to an example placeholder: VOICE_ENCRYPTION_KEY',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unknown VOICE_MODEL in every role', () => {
|
||||||
|
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment(role, {
|
||||||
|
...PROD_ENV,
|
||||||
|
VOICE_MODEL: 'claude-unknown',
|
||||||
|
}),
|
||||||
|
).toThrow('VOICE_MODEL must be one of');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a bad ANTHROPIC_API_KEY never stops a pod', () => {
|
||||||
|
for (const role of ['api', 'worker', 'job', 'all'] as const) {
|
||||||
|
expect(() =>
|
||||||
|
assertStartupEnvironment(role, {
|
||||||
|
...PROD_ENV,
|
||||||
|
ANTHROPIC_API_KEY: 'change-me-anthropic',
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
readRingSecrets,
|
readRingSecrets,
|
||||||
} from '../common/crypto/key-ring';
|
} from '../common/crypto/key-ring';
|
||||||
import { assertTelegramToken } from '../telegram/telegram-config';
|
import { assertTelegramToken } from '../telegram/telegram-config';
|
||||||
|
import { assertAnthropicKey } from '../voice/anthropic-config';
|
||||||
|
import { voiceConfig } from '../voice/voice-config';
|
||||||
import type { ApicoreRole } from './role';
|
import type { ApicoreRole } from './role';
|
||||||
|
|
||||||
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
const REQUIRED_ENV_VARS = ['DATABASE_URL', 'JWT_ACCESS_SECRET'] as const;
|
||||||
@@ -132,8 +134,9 @@ function assertEncryptionKeys(env: NodeJS.ProcessEnv): void {
|
|||||||
/**
|
/**
|
||||||
* Startup checks shared by every entry point (API, worker, CLI job). All
|
* Startup checks shared by every entry point (API, worker, CLI job). All
|
||||||
* roles load the same modules and secrets today, so the checks hardly differ
|
* roles load the same modules and secrets today, so the checks hardly differ
|
||||||
* per role; the Telegram bot token is the first role-specific one (only the
|
* per role; the Telegram bot token and the Anthropic key are role-specific
|
||||||
* worker holds it).
|
* (only the worker holds them, both optional). The voice settings are
|
||||||
|
* validated in every role: a mistyped model id stops the pod, not a night job.
|
||||||
*/
|
*/
|
||||||
export function assertStartupEnvironment(
|
export function assertStartupEnvironment(
|
||||||
role: ApicoreRole,
|
role: ApicoreRole,
|
||||||
@@ -144,4 +147,6 @@ export function assertStartupEnvironment(
|
|||||||
assertNoPlaceholderSecrets(env);
|
assertNoPlaceholderSecrets(env);
|
||||||
assertEncryptionKeys(env);
|
assertEncryptionKeys(env);
|
||||||
assertTelegramToken(role, env);
|
assertTelegramToken(role, env);
|
||||||
|
voiceConfig(env);
|
||||||
|
assertAnthropicKey(role, env);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { anthropicApiKey, assertAnthropicKey } from './anthropic-config';
|
||||||
|
|
||||||
|
const KEY = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
|
||||||
|
function fakeLogger() {
|
||||||
|
return { warn: jest.fn(), error: jest.fn() } satisfies Pick<
|
||||||
|
Logger,
|
||||||
|
'warn' | 'error'
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('anthropicApiKey', () => {
|
||||||
|
it('returns a well-formed key, trimmed', () => {
|
||||||
|
expect(anthropicApiKey({ ANTHROPIC_API_KEY: ` ${KEY} ` })).toBe(KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['unset', {}],
|
||||||
|
['empty', { ANTHROPIC_API_KEY: ' ' }],
|
||||||
|
['a placeholder', { ANTHROPIC_API_KEY: 'change-me-anthropic-key' }],
|
||||||
|
['the wrong format', { ANTHROPIC_API_KEY: 'sk-ant-short' }],
|
||||||
|
['dummy', { ANTHROPIC_API_KEY: 'dummy' }],
|
||||||
|
])('returns null for %s', (_label, env) => {
|
||||||
|
expect(anthropicApiKey(env)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertAnthropicKey', () => {
|
||||||
|
it('assertAnthropicKey never throws and disables on a placeholder', () => {
|
||||||
|
for (const value of ['change-me-anthropic-key', 'dummy', '', 'sk-ant-x']) {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
expect(() =>
|
||||||
|
assertAnthropicKey(
|
||||||
|
'worker',
|
||||||
|
{ NODE_ENV: 'production', ANTHROPIC_API_KEY: value },
|
||||||
|
logger,
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(
|
||||||
|
'worker',
|
||||||
|
{ NODE_ENV: 'production', ANTHROPIC_API_KEY: 'change-me-x' },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
expect(anthropicApiKey({ ANTHROPIC_API_KEY: 'change-me-x' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs the variable name once, never the value', () => {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(
|
||||||
|
'worker',
|
||||||
|
{ ANTHROPIC_API_KEY: 'sk-ant-short-secret' },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const output = JSON.stringify([
|
||||||
|
logger.warn.mock.calls,
|
||||||
|
logger.error.mock.calls,
|
||||||
|
]);
|
||||||
|
expect(output).toContain('ANTHROPIC_API_KEY');
|
||||||
|
expect(output).not.toContain('short-secret');
|
||||||
|
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('api and job roles skip the Anthropic check', () => {
|
||||||
|
for (const role of ['api', 'job'] as const) {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(role, { ANTHROPIC_API_KEY: 'garbage' }, logger);
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a good key silently in the worker and in all-in-one mode', () => {
|
||||||
|
for (const role of ['worker', 'all'] as const) {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey(role, { ANTHROPIC_API_KEY: KEY }, logger);
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
expect(logger.warn).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns that extraction stays off when the worker has no key', () => {
|
||||||
|
const logger = fakeLogger();
|
||||||
|
assertAnthropicKey('worker', {}, logger);
|
||||||
|
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { ApicoreRole } from '../runtime/role';
|
||||||
|
|
||||||
|
/** Key format issued by Anthropic: "sk-ant-" followed by URL-safe characters. */
|
||||||
|
export const ANTHROPIC_KEY_PATTERN = /^sk-ant-[A-Za-z0-9_-]{20,}$/;
|
||||||
|
|
||||||
|
const PLACEHOLDER_PATTERN = /^change-me/i;
|
||||||
|
const WORKER_ROLES: readonly ApicoreRole[] = ['worker', 'all'];
|
||||||
|
|
||||||
|
function trimmed(value: string | undefined): string | null {
|
||||||
|
const text = value?.trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API key for voice extraction, or null if it is unset, a placeholder or
|
||||||
|
* malformed - extraction then stays off (code EXTRACT_DISABLED). Only the
|
||||||
|
* worker holds the key (Secret apicore-anthropic, optional).
|
||||||
|
*/
|
||||||
|
export function anthropicApiKey(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string | null {
|
||||||
|
const key = trimmed(env.ANTHROPIC_API_KEY);
|
||||||
|
return key !== null && ANTHROPIC_KEY_PATTERN.test(key) ? key : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startup check, same rules as the Telegram token: optional, a bad value
|
||||||
|
* never stops a pod. In the worker, a missing key is a warning and a
|
||||||
|
* placeholder or malformed key an error, once, naming the variable and
|
||||||
|
* never its value. API and job pods never hold the key and skip the check.
|
||||||
|
*/
|
||||||
|
export function assertAnthropicKey(
|
||||||
|
role: ApicoreRole,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
logger: Pick<Logger, 'warn' | 'error'> = new Logger('AnthropicStartup'),
|
||||||
|
): void {
|
||||||
|
if (!WORKER_ROLES.includes(role)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = trimmed(env.ANTHROPIC_API_KEY);
|
||||||
|
if (raw === null) {
|
||||||
|
logger.warn(
|
||||||
|
'ANTHROPIC_API_KEY is not set: voice extraction stays off (EXTRACT_DISABLED).',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (PLACEHOLDER_PATTERN.test(raw)) {
|
||||||
|
logger.error(
|
||||||
|
'ANTHROPIC_API_KEY is still set to an example placeholder: voice extraction stays off - set the real key or remove it.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ANTHROPIC_KEY_PATTERN.test(raw)) {
|
||||||
|
logger.error(
|
||||||
|
'ANTHROPIC_API_KEY is malformed (expected "sk-ant-..."): voice extraction stays off.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
claimVoiceCaptures,
|
||||||
|
deferVoiceClaim,
|
||||||
|
releaseStaleVoiceClaims,
|
||||||
|
settleVoiceFailure,
|
||||||
|
settleVoiceSuccess,
|
||||||
|
VoiceClaim,
|
||||||
|
} from './voice-claims';
|
||||||
|
|
||||||
|
function fakeDb() {
|
||||||
|
const queryRaw = jest.fn().mockResolvedValue([]);
|
||||||
|
const executeRaw = jest.fn().mockResolvedValue(1);
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
$executeRaw: executeRaw,
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
queryRaw,
|
||||||
|
executeRaw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim: VoiceClaim = {
|
||||||
|
id: 'c1',
|
||||||
|
claimToken: 't1',
|
||||||
|
attempts: 0,
|
||||||
|
ownerUserId: 'u1',
|
||||||
|
colonyId: 'k1',
|
||||||
|
audioMime: 'audio/webm',
|
||||||
|
durationSec: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sqlOf(call: unknown): Prisma.Sql {
|
||||||
|
return (call as unknown[])[0] as Prisma.Sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('voice claims (SQL shape)', () => {
|
||||||
|
it('claims with SKIP LOCKED against the database clock', async () => {
|
||||||
|
const { db, queryRaw } = fakeDb();
|
||||||
|
await claimVoiceCaptures(db, 'transcribe', { limit: 5, maxTotalSec: 600 });
|
||||||
|
const text = sqlOf(queryRaw.mock.calls[0]).sql;
|
||||||
|
expect(text).toContain('FOR UPDATE SKIP LOCKED');
|
||||||
|
expect(text).toContain('now()');
|
||||||
|
expect(text).toContain('gen_random_uuid()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, -1, 1.5])('refuses the claim limit %p', async (limit) => {
|
||||||
|
const { db } = fakeDb();
|
||||||
|
await expect(claimVoiceCaptures(db, 'extract', { limit })).rejects.toThrow(
|
||||||
|
RangeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unknown phase', async () => {
|
||||||
|
const { db } = fakeDb();
|
||||||
|
await expect(
|
||||||
|
claimVoiceCaptures(db, 'drop' as never, { limit: 1 }),
|
||||||
|
).rejects.toThrow(/Unknown voice phase/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles by compare-and-set on status and claim token', async () => {
|
||||||
|
const { db, executeRaw } = fakeDb();
|
||||||
|
await expect(settleVoiceSuccess(db, 'transcribe', claim)).resolves.toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const { sql, values } = sqlOf(executeRaw.mock.calls[0]);
|
||||||
|
expect(sql).toContain('"claimToken"');
|
||||||
|
expect(values).toEqual(
|
||||||
|
expect.arrayContaining(['c1', 't1', 'TRANSCRIBING']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a lost claim when no row changed', async () => {
|
||||||
|
const { db, executeRaw } = fakeDb();
|
||||||
|
executeRaw.mockResolvedValue(0);
|
||||||
|
await expect(settleVoiceSuccess(db, 'extract', claim)).resolves.toBe(false);
|
||||||
|
await expect(deferVoiceClaim(db, 'extract', claim, 1000)).resolves.toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decides retry or failed from the attempts before', async () => {
|
||||||
|
const { db } = fakeDb();
|
||||||
|
await expect(
|
||||||
|
settleVoiceFailure(
|
||||||
|
db,
|
||||||
|
'transcribe',
|
||||||
|
{ ...claim, attempts: 1 },
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
),
|
||||||
|
).resolves.toBe('retry');
|
||||||
|
await expect(
|
||||||
|
settleVoiceFailure(
|
||||||
|
db,
|
||||||
|
'transcribe',
|
||||||
|
{ ...claim, attempts: 2 },
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
),
|
||||||
|
).resolves.toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a lost failure settle', async () => {
|
||||||
|
const { db, executeRaw } = fakeDb();
|
||||||
|
executeRaw.mockResolvedValue(0);
|
||||||
|
await expect(
|
||||||
|
settleVoiceFailure(db, 'extract', claim, 'EXTRACT_RETRY'),
|
||||||
|
).resolves.toBe('lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the last EXTRACT_RETRY into EXTRACT_FAILED', async () => {
|
||||||
|
const { db, executeRaw } = fakeDb();
|
||||||
|
await settleVoiceFailure(
|
||||||
|
db,
|
||||||
|
'extract',
|
||||||
|
{ ...claim, attempts: 2 },
|
||||||
|
'EXTRACT_RETRY',
|
||||||
|
);
|
||||||
|
const { values } = sqlOf(executeRaw.mock.calls[0]);
|
||||||
|
expect(values).toContain('EXTRACT_FAILED');
|
||||||
|
expect(values).not.toContain('EXTRACT_RETRY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([-1, 1.5])('refuses the defer delay %p', async (delay) => {
|
||||||
|
const { db } = fakeDb();
|
||||||
|
await expect(deferVoiceClaim(db, 'extract', claim, delay)).rejects.toThrow(
|
||||||
|
RangeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases stale claims with the phase stale window and attempt limit', async () => {
|
||||||
|
const { db, queryRaw } = fakeDb();
|
||||||
|
await releaseStaleVoiceClaims(db, 'extract');
|
||||||
|
const { values } = sqlOf(queryRaw.mock.calls[0]);
|
||||||
|
expect(values).toContain(300_000);
|
||||||
|
expect(values).toContain(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { Prisma } from '../../../generated/prisma';
|
||||||
|
import { retryDelayMsFor } from '../../common/retry-policy';
|
||||||
|
import { DbClient } from '../../prisma/db-client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { VoiceErrorCode } from '../voice-error-codes';
|
||||||
|
import { VOICE_PHASES, VoicePhase } from './voice-phases';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim, settle and stale release for voice notes - the same SQL form as
|
||||||
|
* outbox-claims.ts (SKIP LOCKED, database clock, closed table map), but with
|
||||||
|
* two phases that have their own states and retry rule, and a claimToken
|
||||||
|
* instead of claimedAt as the compare-and-set key: the claim travels across
|
||||||
|
* processes (fetch -> whisper -> ingest), so the key must be a value both
|
||||||
|
* halves hold. Settling is a compare-and-set on status + claimToken; a note
|
||||||
|
* that was confirmed, discarded or released in the meantime is not touched.
|
||||||
|
* Part 4 adds `VoiceUtterance` to VOICE_CLAIM_TABLES.
|
||||||
|
*/
|
||||||
|
export type VoiceClaimTable = 'VoiceCapture';
|
||||||
|
|
||||||
|
interface VoiceClaimTableSql {
|
||||||
|
table: Prisma.Sql;
|
||||||
|
statusType: Prisma.Sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table and enum names cannot be bound as parameters. They come only from
|
||||||
|
* this closed map - never from caller input - which is what makes
|
||||||
|
* Prisma.raw safe here.
|
||||||
|
*/
|
||||||
|
export const VOICE_CLAIM_TABLES: Readonly<
|
||||||
|
Record<VoiceClaimTable, VoiceClaimTableSql>
|
||||||
|
> = {
|
||||||
|
VoiceCapture: {
|
||||||
|
table: Prisma.raw('"VoiceCapture"'),
|
||||||
|
statusType: Prisma.raw('"VoiceCaptureStatus"'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABLE = VOICE_CLAIM_TABLES.VoiceCapture;
|
||||||
|
|
||||||
|
/** Longest wait deferVoiceClaim allows: a day. */
|
||||||
|
const MAX_DEFER_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** What a claimed note gives its worker: no content, only what the job needs. */
|
||||||
|
export interface VoiceClaim {
|
||||||
|
id: string;
|
||||||
|
claimToken: string;
|
||||||
|
/** Attempts before this claim (0 on the first). */
|
||||||
|
attempts: number;
|
||||||
|
ownerUserId: string;
|
||||||
|
colonyId: string;
|
||||||
|
audioMime: string | null;
|
||||||
|
durationSec: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceClaimOptions {
|
||||||
|
limit: number;
|
||||||
|
/** Total reported seconds of audio per call; the first note is always claimed. */
|
||||||
|
maxTotalSec?: number;
|
||||||
|
/**
|
||||||
|
* Extra condition, ANDed to the claim (e.g. an owner filter in tests).
|
||||||
|
* Trusted SQL only: never build it with Prisma.raw from input.
|
||||||
|
*/
|
||||||
|
filter?: Prisma.Sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codes a job may report for a failed attempt. */
|
||||||
|
export type VoiceFailureCode =
|
||||||
|
'TRANSCRIBE_FAILED' | 'EXTRACT_RETRY' | 'EXTRACT_INVALID';
|
||||||
|
|
||||||
|
/** Codes shown while a note waits without having failed. */
|
||||||
|
export type VoiceDeferCode = 'EXTRACT_DISABLED' | 'DAILY_LIMIT';
|
||||||
|
|
||||||
|
/** A note a stale release touched; `code` is set when it ended FAILED. */
|
||||||
|
export interface ReleasedVoiceClaim {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
code: VoiceErrorCode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseOf(phase: VoicePhase) {
|
||||||
|
if (!Object.hasOwn(VOICE_PHASES, phase)) {
|
||||||
|
throw new Error(`Unknown voice phase "${String(phase)}"`);
|
||||||
|
}
|
||||||
|
return VOICE_PHASES[phase];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toClaim(row: VoiceClaim): VoiceClaim {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
claimToken: row.claimToken,
|
||||||
|
attempts: row.attempts,
|
||||||
|
ownerUserId: row.ownerUserId,
|
||||||
|
colonyId: row.colonyId,
|
||||||
|
audioMime: row.audioMime,
|
||||||
|
durationSec: row.durationSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claims up to `limit` due notes of the phase whose content has not
|
||||||
|
* expired, oldest first, each with its own fresh claimToken, and - with
|
||||||
|
* maxTotalSec - only as many as fit the audio budget (a running sum over
|
||||||
|
* the locked rows; the first note is always taken so a long note cannot
|
||||||
|
* starve). "availableAt" is TIMESTAMP(3): compare against now() rounded the
|
||||||
|
* same way, see claimBatch. Uses the root client: a claim must commit on
|
||||||
|
* its own.
|
||||||
|
*/
|
||||||
|
export async function claimVoiceCaptures(
|
||||||
|
db: PrismaService,
|
||||||
|
phase: VoicePhase,
|
||||||
|
options: VoiceClaimOptions,
|
||||||
|
): Promise<VoiceClaim[]> {
|
||||||
|
const { from, working } = phaseOf(phase);
|
||||||
|
if (!Number.isInteger(options.limit) || options.limit < 1) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Claim limit must be a positive integer, got ${options.limit}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const budget = options.maxTotalSec;
|
||||||
|
if (budget !== undefined && (!Number.isInteger(budget) || budget < 1)) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Claim audio budget must be a positive integer, got ${budget}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const filter = options.filter
|
||||||
|
? Prisma.sql`AND ${options.filter}`
|
||||||
|
: Prisma.empty;
|
||||||
|
const fits =
|
||||||
|
budget === undefined
|
||||||
|
? Prisma.empty
|
||||||
|
: Prisma.sql`WHERE "position" = 1 OR "runningSec" <= ${budget}`;
|
||||||
|
const rows = await db.$queryRaw<VoiceClaim[]>(Prisma.sql`
|
||||||
|
WITH locked AS (
|
||||||
|
SELECT "id", "createdAt", COALESCE("durationSec", 0) AS "sec"
|
||||||
|
FROM ${TABLE.table}
|
||||||
|
WHERE "status" = CAST(${from} AS ${TABLE.statusType})
|
||||||
|
AND "availableAt" <= CAST(now() AS timestamp(3))
|
||||||
|
AND "contentExpiresAt" > now() ${filter}
|
||||||
|
ORDER BY "createdAt", "id"
|
||||||
|
LIMIT ${options.limit}
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
), ranked AS (
|
||||||
|
SELECT "id",
|
||||||
|
row_number() OVER (ORDER BY "createdAt", "id") AS "position",
|
||||||
|
sum("sec") OVER (ORDER BY "createdAt", "id") AS "runningSec"
|
||||||
|
FROM locked
|
||||||
|
), claimed AS (
|
||||||
|
UPDATE ${TABLE.table}
|
||||||
|
SET "status" = CAST(${working} AS ${TABLE.statusType}),
|
||||||
|
"claimedAt" = now(),
|
||||||
|
"updatedAt" = now(),
|
||||||
|
"claimToken" = gen_random_uuid()::text
|
||||||
|
WHERE "id" IN (SELECT "id" FROM ranked ${fits})
|
||||||
|
RETURNING "id", "claimToken", "attempts", "ownerUserId", "colonyId",
|
||||||
|
"audioMime", "durationSec", "createdAt"
|
||||||
|
)
|
||||||
|
SELECT "id", "claimToken", "attempts", "ownerUserId", "colonyId",
|
||||||
|
"audioMime", "durationSec"
|
||||||
|
FROM claimed ORDER BY "createdAt", "id"`);
|
||||||
|
return rows.map(toClaim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles a claimed note as done: sets the phase's next status
|
||||||
|
* (TRANSCRIBED or READY), releases the claim and - after transcribing -
|
||||||
|
* resets attempts to 0 (Ruling 6), so no caller has to remember it. `set`
|
||||||
|
* holds only the result columns (e.g. `"transcriptEnc" = ...`); it must not
|
||||||
|
* name status, attempts, claimedAt, claimToken or updatedAt. Pass the
|
||||||
|
* transaction client to settle together with the result's other writes.
|
||||||
|
* Returns false if the claim was lost (released as stale, claimed again,
|
||||||
|
* confirmed or discarded): the current state wins and nothing changed.
|
||||||
|
*/
|
||||||
|
export async function settleVoiceSuccess(
|
||||||
|
tx: DbClient,
|
||||||
|
phase: VoicePhase,
|
||||||
|
claim: Pick<VoiceClaim, 'id' | 'claimToken'>,
|
||||||
|
set?: Prisma.Sql,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { working, done } = phaseOf(phase);
|
||||||
|
const result = set ? Prisma.sql`${set},` : Prisma.empty;
|
||||||
|
const attempts =
|
||||||
|
phase === 'transcribe' ? Prisma.sql`"attempts" = 0,` : Prisma.empty;
|
||||||
|
const count = await tx.$executeRaw(Prisma.sql`
|
||||||
|
UPDATE ${TABLE.table}
|
||||||
|
SET ${result} ${attempts}
|
||||||
|
"status" = CAST(${done} AS ${TABLE.statusType}),
|
||||||
|
"updatedAt" = now(),
|
||||||
|
"claimedAt" = NULL,
|
||||||
|
"claimToken" = NULL
|
||||||
|
WHERE "id" = ${claim.id}
|
||||||
|
AND "status" = CAST(${working} AS ${TABLE.statusType})
|
||||||
|
AND "claimToken" = ${claim.claimToken}`);
|
||||||
|
return count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles a claimed note after a failed attempt: back to the phase's
|
||||||
|
* waiting state after the policy delay (database clock), or FAILED once the
|
||||||
|
* policy's maxAttempts is reached. A last EXTRACT_RETRY ends as
|
||||||
|
* EXTRACT_FAILED. Returns 'lost' (nothing changed) if the claim is gone: the
|
||||||
|
* note was confirmed or discarded meanwhile, or a stale release already
|
||||||
|
* counted this attempt and decided its fate.
|
||||||
|
*/
|
||||||
|
export async function settleVoiceFailure(
|
||||||
|
db: DbClient,
|
||||||
|
phase: VoicePhase,
|
||||||
|
claim: VoiceClaim,
|
||||||
|
code: VoiceFailureCode,
|
||||||
|
): Promise<'retry' | 'failed' | 'lost'> {
|
||||||
|
const { from, working, failureCode, policy } = phaseOf(phase);
|
||||||
|
const attempts = claim.attempts + 1;
|
||||||
|
const failed = attempts >= policy.maxAttempts;
|
||||||
|
const storedCode = failed && code === 'EXTRACT_RETRY' ? failureCode : code;
|
||||||
|
const next = failed
|
||||||
|
? Prisma.sql`"status" = CAST('FAILED' AS ${TABLE.statusType})`
|
||||||
|
: Prisma.sql`"status" = CAST(${from} AS ${TABLE.statusType}),
|
||||||
|
"availableAt" = now() + (${retryDelayMsFor(policy, attempts)} * interval '1 millisecond')`;
|
||||||
|
const count = await db.$executeRaw(Prisma.sql`
|
||||||
|
UPDATE ${TABLE.table}
|
||||||
|
SET ${next},
|
||||||
|
"attempts" = "attempts" + 1,
|
||||||
|
"updatedAt" = now(),
|
||||||
|
"claimedAt" = NULL,
|
||||||
|
"claimToken" = NULL,
|
||||||
|
"lastErrorCode" = ${storedCode}
|
||||||
|
WHERE "id" = ${claim.id}
|
||||||
|
AND "status" = CAST(${working} AS ${TABLE.statusType})
|
||||||
|
AND "claimToken" = ${claim.claimToken}`);
|
||||||
|
if (count !== 1) {
|
||||||
|
return 'lost';
|
||||||
|
}
|
||||||
|
return failed ? 'failed' : 'retry';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a claimed note back to the phase's waiting state, due after
|
||||||
|
* `delayMs` by the database clock, without counting an attempt: it did not
|
||||||
|
* fail, it has to wait (daily limit, key missing). `code` is stored in
|
||||||
|
* lastErrorCode so the user sees why meanwhile. Capped at 24 hours. Returns
|
||||||
|
* false if the claim was lost.
|
||||||
|
*/
|
||||||
|
export async function deferVoiceClaim(
|
||||||
|
db: DbClient,
|
||||||
|
phase: VoicePhase,
|
||||||
|
claim: Pick<VoiceClaim, 'id' | 'claimToken'>,
|
||||||
|
delayMs: number,
|
||||||
|
code?: VoiceDeferCode,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { from, working } = phaseOf(phase);
|
||||||
|
if (!Number.isInteger(delayMs) || delayMs < 0) {
|
||||||
|
throw new RangeError(
|
||||||
|
`Defer delay must be a non-negative integer, got ${delayMs}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lastError =
|
||||||
|
code === undefined ? Prisma.empty : Prisma.sql`"lastErrorCode" = ${code},`;
|
||||||
|
const count = await db.$executeRaw(Prisma.sql`
|
||||||
|
UPDATE ${TABLE.table}
|
||||||
|
SET ${lastError}
|
||||||
|
"status" = CAST(${from} AS ${TABLE.statusType}),
|
||||||
|
"updatedAt" = now(),
|
||||||
|
"claimedAt" = NULL,
|
||||||
|
"claimToken" = NULL,
|
||||||
|
"availableAt" = now() + (${Math.min(delayMs, MAX_DEFER_MS)} * interval '1 millisecond')
|
||||||
|
WHERE "id" = ${claim.id}
|
||||||
|
AND "status" = CAST(${working} AS ${TABLE.statusType})
|
||||||
|
AND "claimToken" = ${claim.claimToken}`);
|
||||||
|
return count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts notes of the phase whose claim is older than the phase's stale
|
||||||
|
* window (their worker died) back to the waiting state right away with
|
||||||
|
* attempts + 1 - or to FAILED (phase failure code) once that reaches
|
||||||
|
* maxAttempts, so a note that kills its worker cannot loop. Takes any
|
||||||
|
* client so the release and the VoiceCaptureFailed events of the FAILED
|
||||||
|
* ones (status FAILED, `code` set) can share one transaction.
|
||||||
|
*/
|
||||||
|
export async function releaseStaleVoiceClaims(
|
||||||
|
db: DbClient,
|
||||||
|
phase: VoicePhase,
|
||||||
|
options: { filter?: Prisma.Sql } = {},
|
||||||
|
): Promise<ReleasedVoiceClaim[]> {
|
||||||
|
const { from, working, failureCode, policy } = phaseOf(phase);
|
||||||
|
const filter = options.filter
|
||||||
|
? Prisma.sql`AND ${options.filter}`
|
||||||
|
: Prisma.empty;
|
||||||
|
return db.$queryRaw<ReleasedVoiceClaim[]>(Prisma.sql`
|
||||||
|
UPDATE ${TABLE.table}
|
||||||
|
SET "status" = CAST(
|
||||||
|
CASE WHEN "attempts" + 1 >= ${policy.maxAttempts} THEN 'FAILED' ELSE ${from} END
|
||||||
|
AS ${TABLE.statusType}),
|
||||||
|
"attempts" = "attempts" + 1,
|
||||||
|
"updatedAt" = now(),
|
||||||
|
"claimedAt" = NULL,
|
||||||
|
"claimToken" = NULL,
|
||||||
|
"availableAt" = now(),
|
||||||
|
"lastErrorCode" = CASE WHEN "attempts" + 1 >= ${policy.maxAttempts} THEN ${failureCode} ELSE "lastErrorCode" END
|
||||||
|
WHERE "status" = CAST(${working} AS ${TABLE.statusType})
|
||||||
|
AND "claimedAt" < now() - (${policy.staleClaimMs} * interval '1 millisecond') ${filter}
|
||||||
|
RETURNING "id", "status"::text AS "status",
|
||||||
|
CASE WHEN "status" = 'FAILED' THEN "lastErrorCode" END AS "code"`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { retryDelayMsFor } from '../../common/retry-policy';
|
||||||
|
import { VOICE_PHASES } from './voice-phases';
|
||||||
|
|
||||||
|
describe('voice phases', () => {
|
||||||
|
it('transcribes QUEUED to TRANSCRIBING with 3 attempts, 5 min / 15 min and a 30 min stale claim', () => {
|
||||||
|
const phase = VOICE_PHASES.transcribe;
|
||||||
|
expect([phase.from, phase.working]).toEqual(['QUEUED', 'TRANSCRIBING']);
|
||||||
|
expect(phase.policy).toEqual({
|
||||||
|
delaysMs: [300_000, 900_000],
|
||||||
|
maxAttempts: 3,
|
||||||
|
staleClaimMs: 1_800_000,
|
||||||
|
});
|
||||||
|
expect([1, 2].map((n) => retryDelayMsFor(phase.policy, n))).toEqual([
|
||||||
|
300_000, 900_000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts TRANSCRIBED to EXTRACTING with 3 attempts, 30 s / 2 min and a 5 min stale claim', () => {
|
||||||
|
const phase = VOICE_PHASES.extract;
|
||||||
|
expect([phase.from, phase.working]).toEqual(['TRANSCRIBED', 'EXTRACTING']);
|
||||||
|
expect(phase.policy).toEqual({
|
||||||
|
delaysMs: [30_000, 120_000],
|
||||||
|
maxAttempts: 3,
|
||||||
|
staleClaimMs: 300_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { RetryPolicy } from '../../common/retry-policy';
|
||||||
|
import { VoiceErrorCode } from '../voice-error-codes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two job phases of a voice note. Each has its own states, its own
|
||||||
|
* retry rule and its own code when the last attempt fails. Both phases share
|
||||||
|
* one `attempts` counter; settling the transcribe phase resets it to 0
|
||||||
|
* (Ruling 6), so the extract phase starts with its own 3 attempts.
|
||||||
|
*/
|
||||||
|
export const VOICE_PHASES = {
|
||||||
|
transcribe: {
|
||||||
|
from: 'QUEUED',
|
||||||
|
working: 'TRANSCRIBING',
|
||||||
|
done: 'TRANSCRIBED',
|
||||||
|
failureCode: 'TRANSCRIBE_FAILED',
|
||||||
|
policy: {
|
||||||
|
delaysMs: [300_000, 900_000],
|
||||||
|
maxAttempts: 3,
|
||||||
|
staleClaimMs: 1_800_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extract: {
|
||||||
|
from: 'TRANSCRIBED',
|
||||||
|
working: 'EXTRACTING',
|
||||||
|
done: 'READY',
|
||||||
|
failureCode: 'EXTRACT_FAILED',
|
||||||
|
policy: {
|
||||||
|
delaysMs: [30_000, 120_000],
|
||||||
|
maxAttempts: 3,
|
||||||
|
staleClaimMs: 300_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
from: string;
|
||||||
|
working: string;
|
||||||
|
done: string;
|
||||||
|
failureCode: VoiceErrorCode;
|
||||||
|
policy: RetryPolicy;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type VoicePhase = keyof typeof VOICE_PHASES;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { getMetadataStorage } from 'class-validator';
|
||||||
|
import { ColonyCondition } from '../../../generated/prisma';
|
||||||
|
import { CreateInspectionDto } from '../../inspections/dto/create-inspection.dto';
|
||||||
|
import {
|
||||||
|
FIELD_SPECS,
|
||||||
|
INSPECTION_VOICE_FIELDS,
|
||||||
|
VOICE_EXCLUDED_INSPECTION_FIELDS,
|
||||||
|
checkFieldValue,
|
||||||
|
} from './inspection-field-specs';
|
||||||
|
|
||||||
|
interface DtoRule {
|
||||||
|
name: string;
|
||||||
|
constraints: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The class-validator rules of every property of the DTO. */
|
||||||
|
function dtoRules(): Map<string, DtoRule[]> {
|
||||||
|
const rules = new Map<string, DtoRule[]>();
|
||||||
|
for (const meta of getMetadataStorage().getTargetValidationMetadatas(
|
||||||
|
CreateInspectionDto,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)) {
|
||||||
|
const list = rules.get(meta.propertyName) ?? [];
|
||||||
|
list.push({
|
||||||
|
name: meta.name ?? meta.type,
|
||||||
|
constraints: meta.constraints ?? [],
|
||||||
|
});
|
||||||
|
rules.set(meta.propertyName, list);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FIELD_SPECS', () => {
|
||||||
|
it('FIELD_SPECS covers exactly the DTO fields minus the excluded list', () => {
|
||||||
|
const dtoFields = [...dtoRules().keys()];
|
||||||
|
const covered = dtoFields
|
||||||
|
.filter((field) => !(field in VOICE_EXCLUDED_INSPECTION_FIELDS))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
expect(covered).toEqual([...INSPECTION_VOICE_FIELDS].sort());
|
||||||
|
expect(Object.keys(FIELD_SPECS).sort()).toEqual(covered);
|
||||||
|
expect(INSPECTION_VOICE_FIELDS).toHaveLength(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every excluded field a reason', () => {
|
||||||
|
expect(Object.keys(VOICE_EXCLUDED_INSPECTION_FIELDS).sort()).toEqual(
|
||||||
|
[
|
||||||
|
'applyToColony',
|
||||||
|
'broodAmount',
|
||||||
|
'colonyId',
|
||||||
|
'date',
|
||||||
|
'hiveChange',
|
||||||
|
'temperature',
|
||||||
|
'voiceCaptureId',
|
||||||
|
'weather',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
for (const reason of Object.values(VOICE_EXCLUDED_INSPECTION_FIELDS)) {
|
||||||
|
expect(reason.length).toBeGreaterThan(10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FIELD_SPECS bounds lie within CreateInspectionDto', () => {
|
||||||
|
const rules = dtoRules();
|
||||||
|
for (const field of INSPECTION_VOICE_FIELDS) {
|
||||||
|
const spec = FIELD_SPECS[field];
|
||||||
|
const fieldRules = rules.get(field) ?? [];
|
||||||
|
if (spec.kind === 'int') {
|
||||||
|
const min = fieldRules.find((rule) => rule.name === 'min');
|
||||||
|
const max = fieldRules.find((rule) => rule.name === 'max');
|
||||||
|
if (min) {
|
||||||
|
expect(spec.min).toBeGreaterThanOrEqual(min.constraints[0] as number);
|
||||||
|
}
|
||||||
|
if (max) {
|
||||||
|
expect(spec.max).toBeLessThanOrEqual(max.constraints[0] as number);
|
||||||
|
}
|
||||||
|
expect(fieldRules.map((rule) => rule.name)).toContain('isInt');
|
||||||
|
}
|
||||||
|
if (spec.kind === 'bool') {
|
||||||
|
expect(fieldRules.map((rule) => rule.name)).toContain('isBoolean');
|
||||||
|
}
|
||||||
|
if (spec.kind === 'text') {
|
||||||
|
expect(fieldRules.map((rule) => rule.name)).toContain('isString');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The DTO has no upper bound for broodFrames; the voice path adds one.
|
||||||
|
expect(FIELD_SPECS.broodFrames).toEqual({ kind: 'int', min: 0, max: 40 });
|
||||||
|
expect(FIELD_SPECS.colonyStrength).toEqual({
|
||||||
|
kind: 'int',
|
||||||
|
min: 0,
|
||||||
|
max: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feedStatus accepts every ColonyCondition and nothing else', () => {
|
||||||
|
for (const value of Object.values(ColonyCondition)) {
|
||||||
|
expect(checkFieldValue('feedStatus', value)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const value of ['ausreichend', 'good', 'OK', '', 3, null]) {
|
||||||
|
expect(checkFieldValue('feedStatus', value)).toEqual({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkFieldValue', () => {
|
||||||
|
it('accepts integers within the bounds and rejects the rest', () => {
|
||||||
|
expect(checkFieldValue('colonyStrength', 40)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: 40,
|
||||||
|
});
|
||||||
|
expect(checkFieldValue('colonyStrength', 0)).toMatchObject({ ok: true });
|
||||||
|
for (const value of [41, -1, 7.5, '7', true, null, Number.NaN]) {
|
||||||
|
expect(checkFieldValue('colonyStrength', value)).toEqual({ ok: false });
|
||||||
|
}
|
||||||
|
expect(checkFieldValue('broodPattern', 1)).toMatchObject({ ok: true });
|
||||||
|
expect(checkFieldValue('broodPattern', 5)).toMatchObject({ ok: true });
|
||||||
|
expect(checkFieldValue('broodPattern', 0)).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('broodPattern', 6)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts booleans only as booleans', () => {
|
||||||
|
expect(checkFieldValue('queenSeen', false)).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
expect(checkFieldValue('queenSeen', 'true')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('queenSeen', 1)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes text and reports shortening', () => {
|
||||||
|
expect(checkFieldValue('notes', 'Ruhig <b>und</b> sanft')).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: 'Ruhig bund/b sanft',
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
const long = checkFieldValue('healthIssues', 'x'.repeat(700));
|
||||||
|
expect(long).toMatchObject({ ok: true, truncated: true });
|
||||||
|
expect(long.ok && String(long.value)).toHaveLength(500);
|
||||||
|
expect(checkFieldValue('measures', ' <> ')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('measures', 5)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects weather and temperature and unknown fields', () => {
|
||||||
|
expect(checkFieldValue('weather', 'sonnig')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('temperature', 21)).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('colonyId', 'abc')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('constructor', 'x')).toEqual({ ok: false });
|
||||||
|
expect(checkFieldValue('__proto__', 'x')).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { ColonyCondition } from '../../../generated/prisma';
|
||||||
|
import { sanitizeFreeText } from './voice-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inspection fields a voice note may suggest. The limits follow the
|
||||||
|
* existing form and CreateInspectionDto (ruling 2): strength up to 40, five
|
||||||
|
* feed levels. broodFrames has no upper bound in the DTO; the voice path
|
||||||
|
* adds 40 so a misheard number cannot reach the form.
|
||||||
|
*/
|
||||||
|
export const INSPECTION_VOICE_FIELDS = [
|
||||||
|
'colonyStrength',
|
||||||
|
'broodFrames',
|
||||||
|
'broodPattern',
|
||||||
|
'openBroodPresent',
|
||||||
|
'cappedBroodPresent',
|
||||||
|
'eggsPresent',
|
||||||
|
'queenSeen',
|
||||||
|
'queenCellsPresent',
|
||||||
|
'swarmMood',
|
||||||
|
'gentleness',
|
||||||
|
'combBehavior',
|
||||||
|
'feedStatus',
|
||||||
|
'honeySuperStatus',
|
||||||
|
'healthIssues',
|
||||||
|
'varroaIssues',
|
||||||
|
'measures',
|
||||||
|
'notes',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type InspectionVoiceField = (typeof INSPECTION_VOICE_FIELDS)[number];
|
||||||
|
|
||||||
|
export type FieldSpec =
|
||||||
|
| { kind: 'int'; min: number; max: number }
|
||||||
|
| { kind: 'bool' }
|
||||||
|
| { kind: 'enum'; values: readonly string[] }
|
||||||
|
| { kind: 'text'; max: 500 };
|
||||||
|
|
||||||
|
const TEXT_MAX = 500;
|
||||||
|
const FRAME_MAX = 40;
|
||||||
|
const SCALE: FieldSpec = { kind: 'int', min: 1, max: 5 };
|
||||||
|
const BOOL: FieldSpec = { kind: 'bool' };
|
||||||
|
const TEXT: FieldSpec = { kind: 'text', max: TEXT_MAX };
|
||||||
|
|
||||||
|
export const FIELD_SPECS: Readonly<Record<InspectionVoiceField, FieldSpec>> = {
|
||||||
|
colonyStrength: { kind: 'int', min: 0, max: FRAME_MAX },
|
||||||
|
broodFrames: { kind: 'int', min: 0, max: FRAME_MAX },
|
||||||
|
broodPattern: SCALE,
|
||||||
|
openBroodPresent: BOOL,
|
||||||
|
cappedBroodPresent: BOOL,
|
||||||
|
eggsPresent: BOOL,
|
||||||
|
queenSeen: BOOL,
|
||||||
|
queenCellsPresent: BOOL,
|
||||||
|
swarmMood: SCALE,
|
||||||
|
gentleness: SCALE,
|
||||||
|
combBehavior: SCALE,
|
||||||
|
feedStatus: { kind: 'enum', values: Object.values(ColonyCondition) },
|
||||||
|
honeySuperStatus: TEXT,
|
||||||
|
healthIssues: TEXT,
|
||||||
|
varroaIssues: TEXT,
|
||||||
|
measures: TEXT,
|
||||||
|
notes: TEXT,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** DTO properties a voice note never fills, each with the reason. */
|
||||||
|
export const VOICE_EXCLUDED_INSPECTION_FIELDS: Readonly<
|
||||||
|
Record<string, string>
|
||||||
|
> = {
|
||||||
|
colonyId: 'The colony comes from the voice note itself, never from speech.',
|
||||||
|
date: 'The date comes from the voice note (inspectionDate), not from speech.',
|
||||||
|
weather: 'Weather is fetched from the weather service, not spoken.',
|
||||||
|
temperature: 'Temperature is fetched from the weather service, not spoken.',
|
||||||
|
broodAmount: 'Legacy free text; broodFrames and broodPattern replace it.',
|
||||||
|
applyToColony: 'Taking values over to the colony card is a separate click.',
|
||||||
|
hiveChange: 'Hive operations are never created from speech.',
|
||||||
|
voiceCaptureId: 'Links the voice note; it is not an observation.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CheckedFieldValue =
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
field: InspectionVoiceField;
|
||||||
|
value: number | boolean | string;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
| { ok: false };
|
||||||
|
|
||||||
|
const REJECTED: CheckedFieldValue = { ok: false };
|
||||||
|
|
||||||
|
function accepted(
|
||||||
|
field: InspectionVoiceField,
|
||||||
|
value: number | boolean | string,
|
||||||
|
truncated = false,
|
||||||
|
): CheckedFieldValue {
|
||||||
|
return { ok: true, field, value, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVoiceField(field: string): field is InspectionVoiceField {
|
||||||
|
return Object.hasOwn(FIELD_SPECS, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks one suggested value against its limits; text is cleaned and shortened. */
|
||||||
|
export function checkFieldValue(
|
||||||
|
field: string,
|
||||||
|
value: unknown,
|
||||||
|
): CheckedFieldValue {
|
||||||
|
if (!isVoiceField(field)) {
|
||||||
|
return REJECTED;
|
||||||
|
}
|
||||||
|
const spec = FIELD_SPECS[field];
|
||||||
|
switch (spec.kind) {
|
||||||
|
case 'int':
|
||||||
|
return typeof value === 'number' &&
|
||||||
|
Number.isInteger(value) &&
|
||||||
|
value >= spec.min &&
|
||||||
|
value <= spec.max
|
||||||
|
? accepted(field, value)
|
||||||
|
: REJECTED;
|
||||||
|
case 'bool':
|
||||||
|
return typeof value === 'boolean' ? accepted(field, value) : REJECTED;
|
||||||
|
case 'enum':
|
||||||
|
return typeof value === 'string' && spec.values.includes(value)
|
||||||
|
? accepted(field, value)
|
||||||
|
: REJECTED;
|
||||||
|
case 'text': {
|
||||||
|
const text =
|
||||||
|
typeof value === 'string' ? sanitizeFreeText(value, spec.max) : null;
|
||||||
|
return text ? accepted(field, text.text, text.truncated) : REJECTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* The four 1-5 scales of the inspection form: what the STORED ends mean
|
||||||
|
* (the form shows swarm mood mirrored, but stores 1 = ruhig) and the words
|
||||||
|
* a beekeeper says for them. The same data is in
|
||||||
|
* test/fixtures/voice/inspection-scales.json for the frontend guard; a test
|
||||||
|
* keeps both equal (the TS module cannot import the JSON: it lies outside
|
||||||
|
* src, and the build's root directory must stay src).
|
||||||
|
*/
|
||||||
|
export interface ScaleSpec {
|
||||||
|
field: 'broodPattern' | 'swarmMood' | 'gentleness' | 'combBehavior';
|
||||||
|
/** Meaning of the stored value 1. */
|
||||||
|
storedOne: string;
|
||||||
|
/** Meaning of the stored value 5. */
|
||||||
|
storedFive: string;
|
||||||
|
/** Spoken word -> stored value. */
|
||||||
|
anchors: Record<string, 1 | 2 | 3 | 4 | 5>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INSPECTION_SCALES: readonly ScaleSpec[] = [
|
||||||
|
{
|
||||||
|
field: 'broodPattern',
|
||||||
|
storedOne: 'lückenhaft',
|
||||||
|
storedFive: 'geschlossen',
|
||||||
|
anchors: { lückenhaft: 1, mittel: 3, geschlossen: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'swarmMood',
|
||||||
|
storedOne: 'ruhig',
|
||||||
|
storedFive: 'stark',
|
||||||
|
anchors: { ruhig: 1, mittel: 3, stark: 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'gentleness',
|
||||||
|
storedOne: 'stechlustig',
|
||||||
|
storedFive: 'sehr sanft',
|
||||||
|
anchors: { stechlustig: 1, normal: 3, 'sehr sanft': 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'combBehavior',
|
||||||
|
storedOne: 'läuft weg',
|
||||||
|
storedFive: 'sitzt ruhig',
|
||||||
|
anchors: { 'läuft weg': 1, mittel: 3, 'sitzt ruhig': 5 },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { sealVoiceText, voiceAad } from './voice-crypto';
|
||||||
|
import {
|
||||||
|
MAX_TRANSCRIPT_CHARS,
|
||||||
|
type TranscriptPayload,
|
||||||
|
openTranscript,
|
||||||
|
sealTranscript,
|
||||||
|
} from './transcript';
|
||||||
|
|
||||||
|
describe('transcript', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'j'.repeat(40);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = 'a'.repeat(40);
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transcript payload round-trips with segments', () => {
|
||||||
|
const payload: TranscriptPayload = {
|
||||||
|
v: 1,
|
||||||
|
text: 'Königin gesehen. Acht Waben.',
|
||||||
|
durationSec: 12,
|
||||||
|
segments: [
|
||||||
|
{ start: 0, end: 3.2, text: 'Königin gesehen.' },
|
||||||
|
{ start: 3.2, end: 6, text: 'Acht Waben.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sealed = sealTranscript('c1', payload);
|
||||||
|
|
||||||
|
expect(sealed).toMatch(/^k[0-9a-f]{8}:/);
|
||||||
|
expect(sealed).not.toContain('Königin');
|
||||||
|
expect(openTranscript('c1', sealed)).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a text-only payload', () => {
|
||||||
|
const payload: TranscriptPayload = { v: 1, text: 'Nur Text' };
|
||||||
|
expect(openTranscript('c1', sealTranscript('c1', payload))).toEqual(
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is bound to its capture', () => {
|
||||||
|
const sealed = sealTranscript('c1', { v: 1, text: 'x' });
|
||||||
|
expect(() => openTranscript('c2', sealed)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a payload with another version', () => {
|
||||||
|
const sealed = sealVoiceText(
|
||||||
|
JSON.stringify({ v: 2, text: 'geheim' }),
|
||||||
|
voiceAad('c1', 'transcript'),
|
||||||
|
);
|
||||||
|
expect(() => openTranscript('c1', sealed)).toThrow(
|
||||||
|
'unsupported transcript payload',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['not json at all geheim', 'unsupported transcript payload'],
|
||||||
|
[JSON.stringify({ v: 1, text: 5 }), 'unsupported transcript payload'],
|
||||||
|
[
|
||||||
|
JSON.stringify({ v: 1, text: 'a', segments: [{ start: 'x' }] }),
|
||||||
|
'unsupported transcript payload',
|
||||||
|
],
|
||||||
|
[JSON.stringify(null), 'unsupported transcript payload'],
|
||||||
|
])('rejects a malformed payload without echoing it', (body, message) => {
|
||||||
|
const sealed = sealVoiceText(body, voiceAad('c1', 'transcript'));
|
||||||
|
let thrown = '';
|
||||||
|
try {
|
||||||
|
openTranscript('c1', sealed);
|
||||||
|
} catch (error) {
|
||||||
|
thrown = (error as Error).message;
|
||||||
|
}
|
||||||
|
expect(thrown).toBe(message);
|
||||||
|
expect(thrown).not.toContain('geheim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-finite numbers and oversized text', () => {
|
||||||
|
const open = (body: string): unknown =>
|
||||||
|
openTranscript('c1', sealVoiceText(body, voiceAad('c1', 'transcript')));
|
||||||
|
const bad = 'unsupported transcript payload';
|
||||||
|
expect(() => open('{"v":1,"text":"a","durationSec":1e999}')).toThrow(bad);
|
||||||
|
expect(() =>
|
||||||
|
open(
|
||||||
|
'{"v":1,"text":"a","segments":[{"start":1e999,"end":2,"text":"b"}]}',
|
||||||
|
),
|
||||||
|
).toThrow(bad);
|
||||||
|
expect(() =>
|
||||||
|
open(
|
||||||
|
JSON.stringify({ v: 1, text: 'x'.repeat(MAX_TRANSCRIPT_CHARS + 1) }),
|
||||||
|
),
|
||||||
|
).toThrow(bad);
|
||||||
|
expect(
|
||||||
|
open(JSON.stringify({ v: 1, text: 'x'.repeat(MAX_TRANSCRIPT_CHARS) })),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only the known keys', () => {
|
||||||
|
const sealed = sealVoiceText(
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
text: 'a',
|
||||||
|
extra: 'x',
|
||||||
|
segments: [{ start: 0, end: 1, text: 'b', more: 1 }],
|
||||||
|
}),
|
||||||
|
voiceAad('c1', 'transcript'),
|
||||||
|
);
|
||||||
|
expect(openTranscript('c1', sealed)).toEqual({
|
||||||
|
v: 1,
|
||||||
|
text: 'a',
|
||||||
|
segments: [{ start: 0, end: 1, text: 'b' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { openVoiceText, sealVoiceText, voiceAad } from './voice-crypto';
|
||||||
|
|
||||||
|
export interface TranscriptSegment {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `transcriptEnc` holds once opened. Segments let part 4 join chunks. */
|
||||||
|
export interface TranscriptPayload {
|
||||||
|
v: 1;
|
||||||
|
text: string;
|
||||||
|
durationSec?: number;
|
||||||
|
segments?: TranscriptSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound for the text of one transcript. Whisper gets at most 240 s of
|
||||||
|
* speech (a few thousand characters); this leaves room and stops a forged
|
||||||
|
* or damaged payload from growing without limit.
|
||||||
|
*/
|
||||||
|
export const MAX_TRANSCRIPT_CHARS = 50000;
|
||||||
|
const MAX_SEGMENTS = 5000;
|
||||||
|
|
||||||
|
const UNSUPPORTED = 'unsupported transcript payload';
|
||||||
|
|
||||||
|
function isText(value: unknown): value is string {
|
||||||
|
return typeof value === 'string' && value.length <= MAX_TRANSCRIPT_CHARS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the known keys survive. */
|
||||||
|
function toSegment(value: unknown): TranscriptSegment | null {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { start, end, text } = value as Record<string, unknown>;
|
||||||
|
return Number.isFinite(start) && Number.isFinite(end) && isText(text)
|
||||||
|
? { start: start as number, end: end as number, text }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(value: unknown): TranscriptPayload | null {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { v, text, durationSec, segments } = value as Record<string, unknown>;
|
||||||
|
if (v !== 1 || !isText(text)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const payload: TranscriptPayload = { v: 1, text };
|
||||||
|
if (durationSec !== undefined) {
|
||||||
|
if (!Number.isFinite(durationSec)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
payload.durationSec = durationSec as number;
|
||||||
|
}
|
||||||
|
if (segments !== undefined) {
|
||||||
|
if (!Array.isArray(segments) || segments.length > MAX_SEGMENTS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const checked = segments.map(toSegment);
|
||||||
|
if (checked.some((each) => each === null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
payload.segments = checked as TranscriptSegment[];
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealTranscript(
|
||||||
|
captureId: string,
|
||||||
|
payload: TranscriptPayload,
|
||||||
|
): string {
|
||||||
|
return sealVoiceText(
|
||||||
|
JSON.stringify(payload),
|
||||||
|
voiceAad(captureId, 'transcript'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens and checks a transcript. A broken payload throws a fixed message:
|
||||||
|
* JSON.parse errors quote the input, so they are never passed on.
|
||||||
|
*/
|
||||||
|
export function openTranscript(
|
||||||
|
captureId: string,
|
||||||
|
stored: string,
|
||||||
|
): TranscriptPayload {
|
||||||
|
const json = openVoiceText(stored, voiceAad(captureId, 'transcript'));
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
throw new Error(UNSUPPORTED);
|
||||||
|
}
|
||||||
|
const payload = toPayload(parsed);
|
||||||
|
if (payload === null) {
|
||||||
|
throw new Error(UNSUPPORTED);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { createHash, hkdfSync } from 'node:crypto';
|
||||||
|
import { loadKeyRing } from '../../common/crypto/key-ring';
|
||||||
|
import {
|
||||||
|
isVoiceSealedWithCurrentKey,
|
||||||
|
openVoiceBytes,
|
||||||
|
openVoiceText,
|
||||||
|
sealVoiceBytes,
|
||||||
|
sealVoiceText,
|
||||||
|
voiceAad,
|
||||||
|
} from './voice-crypto';
|
||||||
|
|
||||||
|
const JWT_SECRET = 'j'.repeat(40);
|
||||||
|
const KEY_A = 'a'.repeat(40);
|
||||||
|
const KEY_B = 'b'.repeat(40);
|
||||||
|
const AAD = 'capture:c1:transcript';
|
||||||
|
|
||||||
|
describe('voice-crypto', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.JWT_ACCESS_SECRET = JWT_SECRET;
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = KEY_A;
|
||||||
|
delete process.env.VOICE_ENCRYPTION_KEY_PREVIOUS;
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('audio and transcript round-trip through the key ring', () => {
|
||||||
|
const text = 'Königin gesehen, 8 Waben besetzt.';
|
||||||
|
const audio = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 1, 2, 3, 255, 0]);
|
||||||
|
|
||||||
|
const sealedText = sealVoiceText(text, AAD);
|
||||||
|
const sealedAudio = sealVoiceBytes(audio, 'capture:c1:audio');
|
||||||
|
|
||||||
|
expect(sealedText).toMatch(/^k[0-9a-f]{8}:/);
|
||||||
|
expect(sealedText).not.toContain('Königin');
|
||||||
|
expect(openVoiceText(sealedText, AAD)).toBe(text);
|
||||||
|
expect(openVoiceBytes(sealedAudio, 'capture:c1:audio').equals(audio)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(sealedAudio.length).toBe(1 + 8 + 12 + 16 + audio.length);
|
||||||
|
expect(sealedAudio[0]).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seals the same text differently every time', () => {
|
||||||
|
expect(sealVoiceText('x', AAD)).not.toBe(sealVoiceText('x', AAD));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail with the wrong key', () => {
|
||||||
|
const sealedText = sealVoiceText('geheim', AAD);
|
||||||
|
const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = KEY_B;
|
||||||
|
|
||||||
|
expect(() => openVoiceText(sealedText, AAD)).toThrow('unknown key id');
|
||||||
|
expect(() => openVoiceBytes(sealedAudio, AAD)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens values sealed with the previous key', () => {
|
||||||
|
const sealedText = sealVoiceText('geheim', AAD);
|
||||||
|
const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY = KEY_B;
|
||||||
|
process.env.VOICE_ENCRYPTION_KEY_PREVIOUS = KEY_A;
|
||||||
|
|
||||||
|
expect(openVoiceText(sealedText, AAD)).toBe('geheim');
|
||||||
|
expect(openVoiceBytes(sealedAudio, AAD).toString()).toBe('geheim');
|
||||||
|
expect(isVoiceSealedWithCurrentKey(sealedText)).toBe(false);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(sealedAudio)).toBe(false);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(sealVoiceText('neu', AAD))).toBe(true);
|
||||||
|
expect(
|
||||||
|
isVoiceSealedWithCurrentKey(sealVoiceBytes(Buffer.from('neu'), AAD)),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail when the aad names another capture', () => {
|
||||||
|
const sealedText = sealVoiceText('geheim', voiceAad('c1', 'transcript'));
|
||||||
|
const sealedAudio = sealVoiceBytes(
|
||||||
|
Buffer.from('geheim'),
|
||||||
|
voiceAad('c1', 'audio'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
openVoiceText(sealedText, voiceAad('c2', 'transcript')),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
|
expect(() =>
|
||||||
|
openVoiceText(sealedText, voiceAad('c1', 'suggestion')),
|
||||||
|
).toThrow('ciphertext failed authentication');
|
||||||
|
expect(() => openVoiceBytes(sealedAudio, voiceAad('c2', 'audio'))).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fail on changed bytes, a short value and an unknown format', () => {
|
||||||
|
const sealedAudio = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
const changed = Buffer.from(sealedAudio);
|
||||||
|
changed[changed.length - 1] ^= 0xff;
|
||||||
|
|
||||||
|
expect(() => openVoiceBytes(changed, AAD)).toThrow(
|
||||||
|
'ciphertext failed authentication',
|
||||||
|
);
|
||||||
|
expect(() => openVoiceBytes(sealedAudio.subarray(0, 20), AAD)).toThrow(
|
||||||
|
'malformed ciphertext',
|
||||||
|
);
|
||||||
|
const otherVersion = Buffer.from(sealedAudio);
|
||||||
|
otherVersion[0] = 2;
|
||||||
|
expect(() => openVoiceBytes(otherVersion, AAD)).toThrow(
|
||||||
|
'unsupported ciphertext version',
|
||||||
|
);
|
||||||
|
expect(() => openVoiceText('plain text', AAD)).toThrow('missing key id');
|
||||||
|
expect(() => openVoiceText('k00000000:a.b', AAD)).toThrow('unknown key id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use the voice purpose key, not the JWT secret', () => {
|
||||||
|
const key = Buffer.from(
|
||||||
|
hkdfSync('sha256', KEY_A, 'apicore-key-ring', 'apicore-voice-v1', 32),
|
||||||
|
);
|
||||||
|
const keyId = `k${createHash('sha256').update(key).digest('hex').slice(0, 8)}`;
|
||||||
|
|
||||||
|
expect(loadKeyRing('voice').current.id).toBe(keyId);
|
||||||
|
expect(sealVoiceText('x', AAD).startsWith(`${keyId}:`)).toBe(true);
|
||||||
|
expect(
|
||||||
|
sealVoiceBytes(Buffer.from('x'), AAD).subarray(1, 9).toString(),
|
||||||
|
).toBe(keyId.slice(1));
|
||||||
|
// Rotating the JWT secret changes nothing for stored voice content.
|
||||||
|
const sealed = sealVoiceText('geheim', AAD);
|
||||||
|
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||||
|
expect(openVoiceText(sealed, AAD)).toBe('geheim');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the aad per capture and kind', () => {
|
||||||
|
expect(voiceAad('abc', 'transcript')).toBe('capture:abc:transcript');
|
||||||
|
expect(voiceAad('abc', 'suggestion')).toBe('capture:abc:suggestion');
|
||||||
|
expect(voiceAad('abc', 'audio')).toBe('capture:abc:audio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a key id that is not eight hex characters', () => {
|
||||||
|
const sealed = sealVoiceBytes(Buffer.from('geheim'), AAD);
|
||||||
|
const bad = [
|
||||||
|
Buffer.from('ZZZZZZZZ'),
|
||||||
|
Buffer.from([0xe1, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68]),
|
||||||
|
];
|
||||||
|
for (const id of bad) {
|
||||||
|
const changed = Buffer.from(sealed);
|
||||||
|
id.copy(changed, 1);
|
||||||
|
expect(() => openVoiceBytes(changed, AAD)).toThrow(
|
||||||
|
'malformed ciphertext',
|
||||||
|
);
|
||||||
|
expect(isVoiceSealedWithCurrentKey(changed)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import {
|
||||||
|
KEY_ID_PATTERN,
|
||||||
|
findRingKey,
|
||||||
|
gcmOpen,
|
||||||
|
gcmSeal,
|
||||||
|
loadKeyRing,
|
||||||
|
} from '../../common/crypto/key-ring';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voice content (audio, transcript, suggestions) is sealed with the key ring
|
||||||
|
* of the purpose `voice` (VOICE_ENCRYPTION_KEY). Every value is bound to its
|
||||||
|
* row through the GCM associated data, so a ciphertext moved to another
|
||||||
|
* capture, or to another kind of content, no longer opens.
|
||||||
|
*
|
||||||
|
* Text: `k<8 hex>:<iv>.<tag>.<data>` (base64 parts), like the other purposes.
|
||||||
|
* Bytes: `[1 byte version][8 byte key id (ASCII hex)][12 iv][16 tag][data]`.
|
||||||
|
* Errors carry no input.
|
||||||
|
*/
|
||||||
|
export type VoiceContentKind = 'transcript' | 'suggestion' | 'audio';
|
||||||
|
|
||||||
|
const BYTES_VERSION = 1;
|
||||||
|
const KEY_ID_BYTES = 8;
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
const TAG_BYTES = 16;
|
||||||
|
const KEY_ID_HEX = /^[0-9a-f]{8}$/;
|
||||||
|
const BYTES_HEADER = 1 + KEY_ID_BYTES + IV_BYTES + TAG_BYTES;
|
||||||
|
|
||||||
|
/** The associated data of one kind of content of one capture. */
|
||||||
|
export function voiceAad(captureId: string, kind: VoiceContentKind): string {
|
||||||
|
return `capture:${captureId}:${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aadBuffer(aad: string): Buffer {
|
||||||
|
return Buffer.from(aad, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealVoiceText(plaintext: string, aad: string): string {
|
||||||
|
const { current } = loadKeyRing('voice');
|
||||||
|
const sealed = gcmSeal(
|
||||||
|
current.key,
|
||||||
|
Buffer.from(plaintext, 'utf8'),
|
||||||
|
aadBuffer(aad),
|
||||||
|
);
|
||||||
|
const body = [sealed.iv, sealed.tag, sealed.ciphertext]
|
||||||
|
.map((part) => part.toString('base64'))
|
||||||
|
.join('.');
|
||||||
|
return `${current.id}:${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openVoiceText(stored: string, aad: string): string {
|
||||||
|
if (!KEY_ID_PATTERN.test(stored)) {
|
||||||
|
throw new Error('missing key id');
|
||||||
|
}
|
||||||
|
const separator = stored.indexOf(':');
|
||||||
|
const ringKey = findRingKey(loadKeyRing('voice'), stored.slice(0, separator));
|
||||||
|
if (!ringKey) {
|
||||||
|
throw new Error('unknown key id');
|
||||||
|
}
|
||||||
|
const parts = stored.slice(separator + 1).split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('malformed ciphertext');
|
||||||
|
}
|
||||||
|
const [iv, tag, ciphertext] = parts.map((part) =>
|
||||||
|
Buffer.from(part, 'base64'),
|
||||||
|
);
|
||||||
|
return gcmOpen(ringKey.key, { iv, tag, ciphertext }, aadBuffer(aad)).toString(
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealVoiceBytes(plaintext: Buffer, aad: string): Buffer {
|
||||||
|
const { current } = loadKeyRing('voice');
|
||||||
|
const sealed = gcmSeal(current.key, plaintext, aadBuffer(aad));
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([BYTES_VERSION]),
|
||||||
|
Buffer.from(current.id.slice(1), 'ascii'),
|
||||||
|
sealed.iv,
|
||||||
|
sealed.tag,
|
||||||
|
sealed.ciphertext,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 8 hex characters after the version byte, or null for anything else. */
|
||||||
|
function storedKeyId(stored: Buffer): string | null {
|
||||||
|
const id = stored.subarray(1, 1 + KEY_ID_BYTES).toString('latin1');
|
||||||
|
return KEY_ID_HEX.test(id) ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openVoiceBytes(stored: Buffer, aad: string): Buffer {
|
||||||
|
if (stored.length < BYTES_HEADER) {
|
||||||
|
throw new Error('malformed ciphertext');
|
||||||
|
}
|
||||||
|
if (stored[0] !== BYTES_VERSION) {
|
||||||
|
throw new Error('unsupported ciphertext version');
|
||||||
|
}
|
||||||
|
const keyId = storedKeyId(stored);
|
||||||
|
if (keyId === null) {
|
||||||
|
throw new Error('malformed ciphertext');
|
||||||
|
}
|
||||||
|
const ringKey = findRingKey(loadKeyRing('voice'), `k${keyId}`);
|
||||||
|
if (!ringKey) {
|
||||||
|
throw new Error('unknown key id');
|
||||||
|
}
|
||||||
|
const ivStart = 1 + KEY_ID_BYTES;
|
||||||
|
const tagStart = ivStart + IV_BYTES;
|
||||||
|
return gcmOpen(
|
||||||
|
ringKey.key,
|
||||||
|
{
|
||||||
|
iv: stored.subarray(ivStart, tagStart),
|
||||||
|
tag: stored.subarray(tagStart, BYTES_HEADER),
|
||||||
|
ciphertext: stored.subarray(BYTES_HEADER),
|
||||||
|
},
|
||||||
|
aadBuffer(aad),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** False for anything the rekey job should reseal (previous key, other formats). */
|
||||||
|
export function isVoiceSealedWithCurrentKey(stored: string | Buffer): boolean {
|
||||||
|
const { current } = loadKeyRing('voice');
|
||||||
|
if (typeof stored === 'string') {
|
||||||
|
return stored.startsWith(`${current.id}:`);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
stored.length >= BYTES_HEADER &&
|
||||||
|
stored[0] === BYTES_VERSION &&
|
||||||
|
storedKeyId(stored) === current.id.slice(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// One source of truth: the list lives in ../voice-error-codes.ts (created with
|
||||||
|
// the claims); the core exports it under the name the plan gives it.
|
||||||
|
export { VOICE_ERROR_CODES } from '../voice-error-codes';
|
||||||
|
export type { VoiceErrorCode } from '../voice-error-codes';
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
MAX_QUOTE_LENGTH,
|
||||||
|
normalizeForQuote,
|
||||||
|
normalizeInputText,
|
||||||
|
quoteInText,
|
||||||
|
sanitizeFreeText,
|
||||||
|
} from './voice-text';
|
||||||
|
|
||||||
|
describe('normalizeInputText', () => {
|
||||||
|
it('applies NFKC, keeps newlines, drops other control characters and trims', () => {
|
||||||
|
expect(normalizeInputText(' ① Wabe\u0000\u0007\r\nZweite Zeile\t ')).toBe(
|
||||||
|
'1 Wabe\nZweite Zeile',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeForQuote', () => {
|
||||||
|
it('lowercases, turns non-letters into spaces and collapses them', () => {
|
||||||
|
expect(normalizeForQuote(' Königin, GESEHEN!! 8 Waben… ')).toBe(
|
||||||
|
'königin gesehen 8 waben',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds compatibility characters', () => {
|
||||||
|
expect(normalizeForQuote('ABC 12')).toBe('abc 12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('quoteInText', () => {
|
||||||
|
const transcript = 'Also, die Königin habe ich gesehen. Acht Waben besetzt!';
|
||||||
|
|
||||||
|
it('matches the quote across punctuation and case', () => {
|
||||||
|
expect(quoteInText('KÖNIGIN habe ich gesehen', transcript)).toBe(true);
|
||||||
|
expect(quoteInText('acht waben besetzt', transcript)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a quote with a changed number', () => {
|
||||||
|
expect(quoteInText('neun Waben besetzt', transcript)).toBe(false);
|
||||||
|
// "12" must not match inside "112".
|
||||||
|
expect(quoteInText('12 Waben', '112 Waben besetzt')).toBe(false);
|
||||||
|
expect(quoteInText('12 Waben', 'Es sind 12 Waben besetzt')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, punctuation-only and overlong quotes', () => {
|
||||||
|
expect(quoteInText('', transcript)).toBe(false);
|
||||||
|
expect(quoteInText('...', transcript)).toBe(false);
|
||||||
|
const long = 'a '.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
expect(quoteInText(long, long)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeFreeText', () => {
|
||||||
|
it('sanitizeFreeText strips control characters and angle brackets and shortens to 500', () => {
|
||||||
|
expect(sanitizeFreeText('a\u0000b<script>c</script>', 500)).toEqual({
|
||||||
|
text: 'abscriptc/script',
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
const result = sanitizeFreeText('x'.repeat(600), 500);
|
||||||
|
expect(result?.text).toHaveLength(500);
|
||||||
|
expect(result?.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns line breaks into single spaces', () => {
|
||||||
|
expect(sanitizeFreeText('Zeile eins\n\nZeile\tzwei', 500)?.text).toBe(
|
||||||
|
'Zeile eins Zeile zwei',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when nothing is left', () => {
|
||||||
|
expect(sanitizeFreeText(' \u0000<> \n', 500)).toBeNull();
|
||||||
|
expect(sanitizeFreeText('', 500)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cut a character in half', () => {
|
||||||
|
const result = sanitizeFreeText('\u{1F41D}'.repeat(10), 4);
|
||||||
|
expect(result?.text).toBe('\u{1F41D}'.repeat(4));
|
||||||
|
expect(result?.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decimal numbers in quotes', () => {
|
||||||
|
it('keeps a decimal number as one token', () => {
|
||||||
|
expect(quoteInText('5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(false);
|
||||||
|
expect(quoteInText('3,5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(true);
|
||||||
|
expect(quoteInText('3.5 Waben', 'ungefaehr 3,5 Waben besetzt')).toBe(true);
|
||||||
|
expect(quoteInText('3,5 Waben', 'ungefaehr 3.5 Waben besetzt')).toBe(true);
|
||||||
|
expect(normalizeForQuote('Es sind 8. Waben, 5')).toBe('es sind 8 waben 5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hidden characters and limits', () => {
|
||||||
|
it('maps tabs and lone carriage returns to spaces in normalizeInputText', () => {
|
||||||
|
expect(normalizeInputText('a\tb\rc\r\nd')).toBe('a b c\nd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips format characters and line separators', () => {
|
||||||
|
const hidden = 'a\u200Bb\u202Ec\u2028d\u2029e\uFEFFf';
|
||||||
|
expect(normalizeInputText(hidden)).toBe('abc d ef');
|
||||||
|
expect(sanitizeFreeText(hidden, 500)?.text).toBe('abc d ef');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the quote length in code points', () => {
|
||||||
|
const word = 'a'.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
expect(quoteInText(word, word)).toBe(true);
|
||||||
|
expect(quoteInText(word + 'a', word + 'a')).toBe(false);
|
||||||
|
const emoji = '\u{1F41D}'.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
// 120 code points are 240 UTF-16 units: still within the limit.
|
||||||
|
expect(quoteInText(emoji, emoji)).toBe(false); // emoji are not letters or digits
|
||||||
|
const letters = '\u{1D400}'.repeat(MAX_QUOTE_LENGTH);
|
||||||
|
expect(quoteInText(letters, letters)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when a cut leaves nothing', () => {
|
||||||
|
expect(sanitizeFreeText('abc', 0)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** A quote longer than this is not a quote of one remark (spec 6.5). */
|
||||||
|
export const MAX_QUOTE_LENGTH = 120;
|
||||||
|
|
||||||
|
const CONTROL_CHARACTERS = /\p{Cc}/gu;
|
||||||
|
// Format characters (bidi marks, zero-width, BOM) change how text looks, not what it says.
|
||||||
|
const FORMAT_CHARACTERS = /\p{Cf}/gu;
|
||||||
|
const LINE_BREAKS = /[\r\n\t\u2028\u2029]+/g;
|
||||||
|
// A decimal separator between digits is kept as part of the number.
|
||||||
|
const DECIMAL_SEPARATOR = /(\d)[.,](?=\d)/g;
|
||||||
|
const DECIMAL_MARK = '\uE000';
|
||||||
|
const ANGLE_BRACKETS = /[<>]/g;
|
||||||
|
const NOT_LETTER_OR_DIGIT_OR_MARK = /[^\p{L}\p{N}\uE000]+/gu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NFKC; tabs, lone carriage returns and the Unicode line separators become
|
||||||
|
* spaces; other control and format characters are removed; trimmed. Line
|
||||||
|
* feeds stay.
|
||||||
|
*/
|
||||||
|
export function normalizeInputText(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/\r(?!\n)|[\t\u2028\u2029]/g, ' ')
|
||||||
|
.replace(/[^\P{Cc}\n]/gu, '')
|
||||||
|
.replace(FORMAT_CHARACTERS, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form quotes are compared in: NFKC, lower case, only letters and
|
||||||
|
* digits, single spaces. "3,5" and "3.5" stay one token ("3.5"), so a quote
|
||||||
|
* "5 Waben" is not found in "3,5 Waben".
|
||||||
|
*/
|
||||||
|
export function normalizeForQuote(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(DECIMAL_SEPARATOR, `$1${DECIMAL_MARK}`)
|
||||||
|
.replace(NOT_LETTER_OR_DIGIT_OR_MARK, ' ')
|
||||||
|
.replaceAll(DECIMAL_MARK, '.')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the quote stands in the text, whole words only ("12" is not in
|
||||||
|
* "112"). This is the barrier against values the model made up.
|
||||||
|
*/
|
||||||
|
export function quoteInText(quote: string, text: string): boolean {
|
||||||
|
if (Array.from(quote).length > MAX_QUOTE_LENGTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const wanted = normalizeForQuote(quote);
|
||||||
|
if (wanted === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ` ${normalizeForQuote(text)} `.includes(` ${wanted} `);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free text from the model: line breaks become spaces, other control
|
||||||
|
* characters and angle brackets disappear, at most `max` characters
|
||||||
|
* (code points). Null if nothing is left.
|
||||||
|
*/
|
||||||
|
export function sanitizeFreeText(
|
||||||
|
text: string,
|
||||||
|
max: number,
|
||||||
|
): { text: string; truncated: boolean } | null {
|
||||||
|
const cleaned = text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(LINE_BREAKS, ' ')
|
||||||
|
.replace(CONTROL_CHARACTERS, '')
|
||||||
|
.replace(FORMAT_CHARACTERS, '')
|
||||||
|
.replace(ANGLE_BRACKETS, '')
|
||||||
|
.replace(/ {2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (cleaned === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const characters = Array.from(cleaned);
|
||||||
|
if (characters.length <= max) {
|
||||||
|
return { text: cleaned, truncated: false };
|
||||||
|
}
|
||||||
|
const cut = characters.slice(0, max).join('').trimEnd();
|
||||||
|
return cut === '' ? null : { text: cut, truncated: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { whisperInitialPrompt } from './voice-vocabulary';
|
||||||
|
|
||||||
|
const MONOREPO_ROOT = join(__dirname, '..', '..', '..', '..');
|
||||||
|
const VOCABULARY_FILE = join(
|
||||||
|
MONOREPO_ROOT,
|
||||||
|
'ApiCore-Whisper',
|
||||||
|
'vocabulary.txt',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The backend image build only sees ApiCore-Backend, so the comparison runs in
|
||||||
|
// the monorepo checkout and is skipped inside that build.
|
||||||
|
const inMonorepo = existsSync(join(MONOREPO_ROOT, 'ApiCore-Frontend'));
|
||||||
|
const describeInMonorepo = inMonorepo ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeInMonorepo('whisper vocabulary export', () => {
|
||||||
|
it('the whisper vocabulary file equals whisperInitialPrompt()', () => {
|
||||||
|
// Windows checkouts may turn LF into CRLF; the content is what counts.
|
||||||
|
const onDisk = readFileSync(VOCABULARY_FILE, 'utf8')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\n$/, '');
|
||||||
|
|
||||||
|
expect(onDisk).toBe(whisperInitialPrompt());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { INSPECTION_SCALES } from './inspection-scales';
|
||||||
|
import { VOICE_ERROR_CODES } from './voice-errors';
|
||||||
|
import { VOICE_VOCABULARY, whisperInitialPrompt } from './voice-vocabulary';
|
||||||
|
|
||||||
|
const FIXTURES = join(__dirname, '..', '..', '..', 'test', 'fixtures', 'voice');
|
||||||
|
|
||||||
|
function fixture(name: string): unknown {
|
||||||
|
return JSON.parse(readFileSync(join(FIXTURES, name), 'utf8')) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('voice vocabulary', () => {
|
||||||
|
it('VOICE_VOCABULARY equals the shared fixture', () => {
|
||||||
|
expect([...VOICE_VOCABULARY]).toEqual(fixture('vocabulary.json'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no duplicates or blanks', () => {
|
||||||
|
expect(new Set(VOICE_VOCABULARY).size).toBe(VOICE_VOCABULARY.length);
|
||||||
|
for (const word of VOICE_VOCABULARY) {
|
||||||
|
expect(word.trim()).toBe(word);
|
||||||
|
expect(word.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('whisperInitialPrompt stays below 800 characters', () => {
|
||||||
|
const prompt = whisperInitialPrompt();
|
||||||
|
expect(prompt.length).toBeLessThan(800);
|
||||||
|
for (const word of VOICE_VOCABULARY) {
|
||||||
|
expect(prompt).toContain(word);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inspection scales', () => {
|
||||||
|
it('INSPECTION_SCALES equals the shared fixture', () => {
|
||||||
|
expect(JSON.parse(JSON.stringify(INSPECTION_SCALES))).toEqual(
|
||||||
|
fixture('inspection-scales.json'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes the four 1-5 scales of the form, with the stored ends', () => {
|
||||||
|
expect(INSPECTION_SCALES.map((scale) => scale.field)).toEqual([
|
||||||
|
'broodPattern',
|
||||||
|
'swarmMood',
|
||||||
|
'gentleness',
|
||||||
|
'combBehavior',
|
||||||
|
]);
|
||||||
|
const swarm = INSPECTION_SCALES.find(
|
||||||
|
(scale) => scale.field === 'swarmMood',
|
||||||
|
);
|
||||||
|
// Stored 1 is the calm end for swarm mood, unlike the other scales.
|
||||||
|
expect(swarm).toMatchObject({ storedOne: 'ruhig', storedFive: 'stark' });
|
||||||
|
for (const scale of INSPECTION_SCALES) {
|
||||||
|
expect(scale.anchors[scale.storedOne]).toBe(1);
|
||||||
|
expect(scale.anchors[scale.storedFive]).toBe(5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('voice error codes', () => {
|
||||||
|
it('lists the fourteen fixed codes of the plan', () => {
|
||||||
|
expect([...VOICE_ERROR_CODES]).toEqual([
|
||||||
|
'UNSUPPORTED_AUDIO',
|
||||||
|
'TOO_LARGE',
|
||||||
|
'TOO_MANY_OPEN',
|
||||||
|
'NO_SPEECH',
|
||||||
|
'TOO_LONG',
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
'AUDIO_EXPIRED',
|
||||||
|
'EXTRACT_DISABLED',
|
||||||
|
'EXTRACT_RETRY',
|
||||||
|
'EXTRACT_FAILED',
|
||||||
|
'DAILY_LIMIT',
|
||||||
|
'EXTRACT_INVALID',
|
||||||
|
'REFUSED',
|
||||||
|
'VOICE_CAPTURE_GONE',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Beekeeping words that speech recognition tends to mishear. One source for
|
||||||
|
* Whisper (initial prompt), the Claude prompt and the browser phrases. The
|
||||||
|
* frontend reads the same list from test/fixtures/voice/vocabulary.json; a
|
||||||
|
* test keeps both equal.
|
||||||
|
*/
|
||||||
|
export const VOICE_VOCABULARY: readonly string[] = [
|
||||||
|
'Königin',
|
||||||
|
'Stifte',
|
||||||
|
'Weiselzellen',
|
||||||
|
'Brutwaben',
|
||||||
|
'Futterkranz',
|
||||||
|
'Honigraum',
|
||||||
|
'Varroa',
|
||||||
|
'Schwarmstimmung',
|
||||||
|
'sanftmütig',
|
||||||
|
'Wabensitz',
|
||||||
|
'Drohnenbrut',
|
||||||
|
'Brutnest',
|
||||||
|
'Brutfläche',
|
||||||
|
'Spielnäpfchen',
|
||||||
|
'Schwarmzellen',
|
||||||
|
'Absperrgitter',
|
||||||
|
'Futterteig',
|
||||||
|
'Ableger',
|
||||||
|
'Jungvolk',
|
||||||
|
'Rähmchen',
|
||||||
|
'Mittelwand',
|
||||||
|
'Flugloch',
|
||||||
|
'Beute',
|
||||||
|
'Zarge',
|
||||||
|
'Ameisensäure',
|
||||||
|
'Oxalsäure',
|
||||||
|
'Windel',
|
||||||
|
'Faulbrut',
|
||||||
|
'Kalkbrut',
|
||||||
|
'Nosema',
|
||||||
|
'Wachsmotten',
|
||||||
|
'Pollen',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Whisper reads about 224 tokens of prompt; 800 characters stay safely below. */
|
||||||
|
const PROMPT_PREFIX = 'Durchsicht am Bienenvolk: ';
|
||||||
|
|
||||||
|
export function whisperInitialPrompt(): string {
|
||||||
|
return `${PROMPT_PREFIX}${VOICE_VOCABULARY.join(', ')}.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { DEFAULT_VOICE_MODEL, VOICE_MODELS, voiceConfig } from './voice-config';
|
||||||
|
|
||||||
|
describe('voiceConfig', () => {
|
||||||
|
it('uses the defaults for an empty environment', () => {
|
||||||
|
expect(voiceConfig({})).toEqual({
|
||||||
|
model: 'claude-sonnet-5-5',
|
||||||
|
dailyExtractions: 60,
|
||||||
|
recordingEnabled: false,
|
||||||
|
whisperModel: 'small',
|
||||||
|
});
|
||||||
|
expect(DEFAULT_VOICE_MODEL).toBe('claude-sonnet-5-5');
|
||||||
|
expect(VOICE_MODELS).toEqual([
|
||||||
|
'claude-sonnet-5-5',
|
||||||
|
'claude-haiku-5-5',
|
||||||
|
'claude-opus-5-5',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads every setting, trimmed', () => {
|
||||||
|
expect(
|
||||||
|
voiceConfig({
|
||||||
|
VOICE_MODEL: ' claude-haiku-5-5 ',
|
||||||
|
VOICE_DAILY_EXTRACTIONS: '120',
|
||||||
|
VOICE_RECORDING_ENABLED: 'true',
|
||||||
|
WHISPER_MODEL: 'medium',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
model: 'claude-haiku-5-5',
|
||||||
|
dailyExtractions: 120,
|
||||||
|
recordingEnabled: true,
|
||||||
|
whisperModel: 'medium',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats blank values as unset', () => {
|
||||||
|
expect(
|
||||||
|
voiceConfig({ VOICE_MODEL: ' ', VOICE_DAILY_EXTRACTIONS: '' }),
|
||||||
|
).toMatchObject({ model: DEFAULT_VOICE_MODEL, dailyExtractions: 60 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('voiceConfig rejects an unknown model id', () => {
|
||||||
|
expect(() => voiceConfig({ VOICE_MODEL: 'gpt-5' })).toThrow(
|
||||||
|
/VOICE_MODEL must be one of claude-sonnet-5-5, claude-haiku-5-5, claude-opus-5-5/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['0', '10001', '-1', '1.5', 'many'])(
|
||||||
|
'rejects %s as VOICE_DAILY_EXTRACTIONS',
|
||||||
|
(value) => {
|
||||||
|
expect(() => voiceConfig({ VOICE_DAILY_EXTRACTIONS: value })).toThrow(
|
||||||
|
'VOICE_DAILY_EXTRACTIONS must be a whole number from 1 to 10000',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('rejects unknown switch and Whisper model values', () => {
|
||||||
|
expect(() => voiceConfig({ VOICE_RECORDING_ENABLED: 'yes' })).toThrow(
|
||||||
|
'VOICE_RECORDING_ENABLED must be "true" or "false"',
|
||||||
|
);
|
||||||
|
expect(() => voiceConfig({ WHISPER_MODEL: 'large' })).toThrow(
|
||||||
|
'WHISPER_MODEL must be one of small, medium',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads VOICE_RECORDING_ENABLED case-insensitively', () => {
|
||||||
|
expect(
|
||||||
|
voiceConfig({ VOICE_RECORDING_ENABLED: 'TRUE' }).recordingEnabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
voiceConfig({ VOICE_RECORDING_ENABLED: ' False ' }).recordingEnabled,
|
||||||
|
).toBe(false);
|
||||||
|
expect(() => voiceConfig({ VOICE_RECORDING_ENABLED: '1' })).toThrow(
|
||||||
|
'must be "true" or "false"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Voice settings from the environment. Model ids live here and nowhere else
|
||||||
|
* (spec 7.1): a change of model is a change of this list and of VOICE_MODEL.
|
||||||
|
*/
|
||||||
|
export const VOICE_MODELS = [
|
||||||
|
'claude-sonnet-5-5',
|
||||||
|
'claude-haiku-5-5',
|
||||||
|
'claude-opus-5-5',
|
||||||
|
] as const;
|
||||||
|
export type VoiceModel = (typeof VOICE_MODELS)[number];
|
||||||
|
|
||||||
|
export const DEFAULT_VOICE_MODEL: VoiceModel = 'claude-sonnet-5-5';
|
||||||
|
|
||||||
|
export const WHISPER_MODELS = ['small', 'medium'] as const;
|
||||||
|
export type WhisperModel = (typeof WHISPER_MODELS)[number];
|
||||||
|
|
||||||
|
const DEFAULT_DAILY_EXTRACTIONS = 60;
|
||||||
|
const MIN_DAILY_EXTRACTIONS = 1;
|
||||||
|
const MAX_DAILY_EXTRACTIONS = 10000;
|
||||||
|
|
||||||
|
export interface VoiceConfig {
|
||||||
|
model: VoiceModel;
|
||||||
|
/** Operation-wide limit of Claude calls per UTC day. */
|
||||||
|
dailyExtractions: number;
|
||||||
|
/** Switch that shows or hides audio recording in the app (ConfigMap). */
|
||||||
|
recordingEnabled: boolean;
|
||||||
|
whisperModel: WhisperModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimmed(value: string | undefined): string | null {
|
||||||
|
const text = value?.trim();
|
||||||
|
return text ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends string>(
|
||||||
|
name: string,
|
||||||
|
raw: string | undefined,
|
||||||
|
allowed: readonly T[],
|
||||||
|
fallback: T,
|
||||||
|
): T {
|
||||||
|
const value = trimmed(raw);
|
||||||
|
if (value === null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const match = allowed.find((each) => each === value);
|
||||||
|
if (match === undefined) {
|
||||||
|
throw new Error(`${name} must be one of ${allowed.join(', ')}`);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyExtractions(raw: string | undefined): number {
|
||||||
|
const value = trimmed(raw);
|
||||||
|
if (value === null) {
|
||||||
|
return DEFAULT_DAILY_EXTRACTIONS;
|
||||||
|
}
|
||||||
|
const number = /^\d+$/.test(value) ? Number(value) : NaN;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(number) ||
|
||||||
|
number < MIN_DAILY_EXTRACTIONS ||
|
||||||
|
number > MAX_DAILY_EXTRACTIONS
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`VOICE_DAILY_EXTRACTIONS must be a whole number from ${MIN_DAILY_EXTRACTIONS} to ${MAX_DAILY_EXTRACTIONS}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordingEnabled(raw: string | undefined): boolean {
|
||||||
|
const value = trimmed(raw)?.toLowerCase() ?? null;
|
||||||
|
if (value === null || value === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error('VOICE_RECORDING_ENABLED must be "true" or "false"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invalid values fail with the variable name (never a secret): startup refuses them. */
|
||||||
|
export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig {
|
||||||
|
return {
|
||||||
|
model: oneOf(
|
||||||
|
'VOICE_MODEL',
|
||||||
|
env.VOICE_MODEL,
|
||||||
|
VOICE_MODELS,
|
||||||
|
DEFAULT_VOICE_MODEL,
|
||||||
|
),
|
||||||
|
dailyExtractions: dailyExtractions(env.VOICE_DAILY_EXTRACTIONS),
|
||||||
|
recordingEnabled: recordingEnabled(env.VOICE_RECORDING_ENABLED),
|
||||||
|
whisperModel: oneOf(
|
||||||
|
'WHISPER_MODEL',
|
||||||
|
env.WHISPER_MODEL,
|
||||||
|
WHISPER_MODELS,
|
||||||
|
'small',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* The fixed error codes of a voice note. They land in lastErrorCode, in
|
||||||
|
* events and in API answers - never content, only one of these.
|
||||||
|
*/
|
||||||
|
export const VOICE_ERROR_CODES = [
|
||||||
|
'UNSUPPORTED_AUDIO',
|
||||||
|
'TOO_LARGE',
|
||||||
|
'TOO_MANY_OPEN',
|
||||||
|
'NO_SPEECH',
|
||||||
|
'TOO_LONG',
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
'AUDIO_EXPIRED',
|
||||||
|
'EXTRACT_DISABLED',
|
||||||
|
'EXTRACT_RETRY',
|
||||||
|
'EXTRACT_FAILED',
|
||||||
|
'DAILY_LIMIT',
|
||||||
|
'EXTRACT_INVALID',
|
||||||
|
'REFUSED',
|
||||||
|
'VOICE_CAPTURE_GONE',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type VoiceErrorCode = (typeof VOICE_ERROR_CODES)[number];
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"field": "broodPattern",
|
||||||
|
"storedOne": "lückenhaft",
|
||||||
|
"storedFive": "geschlossen",
|
||||||
|
"anchors": { "lückenhaft": 1, "mittel": 3, "geschlossen": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "swarmMood",
|
||||||
|
"storedOne": "ruhig",
|
||||||
|
"storedFive": "stark",
|
||||||
|
"anchors": { "ruhig": 1, "mittel": 3, "stark": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "gentleness",
|
||||||
|
"storedOne": "stechlustig",
|
||||||
|
"storedFive": "sehr sanft",
|
||||||
|
"anchors": { "stechlustig": 1, "normal": 3, "sehr sanft": 5 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "combBehavior",
|
||||||
|
"storedOne": "läuft weg",
|
||||||
|
"storedFive": "sitzt ruhig",
|
||||||
|
"anchors": { "läuft weg": 1, "mittel": 3, "sitzt ruhig": 5 }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -314,6 +314,8 @@ describe('rekey job (e2e)', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
webhookSubscription: prisma.webhookSubscription,
|
webhookSubscription: prisma.webhookSubscription,
|
||||||
|
voiceCapture: prisma.voiceCapture,
|
||||||
|
voiceCaptureAudio: prisma.voiceCaptureAudio,
|
||||||
} as unknown as PrismaService;
|
} as unknown as PrismaService;
|
||||||
|
|
||||||
const result = await new RekeyJob(racing).run([]);
|
const result = await new RekeyJob(racing).run([]);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication } from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
@@ -95,3 +97,33 @@ describe('Search and filters (e2e)', () => {
|
|||||||
expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]);
|
expect(colonies.map((entry) => entry.id)).toEqual([dissolvedColonyId]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Voice capture content guard', () => {
|
||||||
|
// VoiceCapture holds personal data that is deleted after at most 7 days. It
|
||||||
|
// must never be reachable through search or any export/report builder.
|
||||||
|
const sourceRoots = ['search', 'export'].map((dir) =>
|
||||||
|
path.resolve(__dirname, '../src', dir),
|
||||||
|
);
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) return sourceFiles(full);
|
||||||
|
return entry.name.endsWith('.ts') ? [full] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('VoiceCapture appears in no search provider and no export', () => {
|
||||||
|
const files = sourceRoots.flatMap(sourceFiles);
|
||||||
|
|
||||||
|
expect(files.length).toBeGreaterThan(0);
|
||||||
|
for (const file of files) {
|
||||||
|
expect({
|
||||||
|
file: path.basename(file),
|
||||||
|
mentions: /voiceCapture|VoiceCapture/.test(
|
||||||
|
fs.readFileSync(file, 'utf8'),
|
||||||
|
),
|
||||||
|
}).toEqual({ file: path.basename(file), mentions: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema-level guarantees of the T7 migration that the Prisma schema alone
|
||||||
|
* cannot express (CHECK constraints, cascade) against a real PostgreSQL. The
|
||||||
|
* test database is shared and not empty: every row here is created by this
|
||||||
|
* file and removed again.
|
||||||
|
*/
|
||||||
|
describe('T7 schema (e2e)', () => {
|
||||||
|
let prisma: PrismaService;
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
const CHECK_VIOLATION = /check constraint|violates/i;
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
let apiaryId: string;
|
||||||
|
let colonyId: string;
|
||||||
|
let inspectionId: string;
|
||||||
|
|
||||||
|
function capture(extra: Record<string, unknown> = {}) {
|
||||||
|
return prisma.voiceCapture.create({
|
||||||
|
data: {
|
||||||
|
ownerUserId: userId,
|
||||||
|
colonyId,
|
||||||
|
inspectionDate: new Date('2026-10-16'),
|
||||||
|
source: 'AUDIO',
|
||||||
|
contentExpiresAt: new Date('2026-10-23T00:00:00Z'),
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
prisma = new PrismaService();
|
||||||
|
const role = await prisma.role.findUniqueOrThrow({
|
||||||
|
where: { key: 'READONLY' },
|
||||||
|
});
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `t7-schema-${suffix}@example.com`,
|
||||||
|
passwordHash: 'x',
|
||||||
|
name: 'T7 schema',
|
||||||
|
roleId: role.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
const apiary = await prisma.apiary.create({
|
||||||
|
data: { name: `T7 Stand ${suffix}` },
|
||||||
|
});
|
||||||
|
apiaryId = apiary.id;
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: { code: `T7-${suffix}`, apiaryId, colonyType: 'PRODUCTION_COLONY' },
|
||||||
|
});
|
||||||
|
colonyId = colony.id;
|
||||||
|
const inspection = await prisma.inspection.create({
|
||||||
|
data: { colonyId, userId, date: new Date('2026-10-16') },
|
||||||
|
});
|
||||||
|
inspectionId = inspection.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
|
||||||
|
await prisma.inspection.deleteMany({ where: { id: inspectionId } });
|
||||||
|
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: userId } });
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a plain queued audio capture with defaults', async () => {
|
||||||
|
const row = await capture({
|
||||||
|
audioMime: 'audio/webm',
|
||||||
|
audioBytes: 2097152,
|
||||||
|
durationSec: 240,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(row.status).toBe('QUEUED');
|
||||||
|
expect(row.attempts).toBe(0);
|
||||||
|
expect(row.claimToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects audioBytes 0 and 2097153', async () => {
|
||||||
|
await expect(capture({ audioBytes: 0 })).rejects.toThrow(CHECK_VIOLATION);
|
||||||
|
await expect(capture({ audioBytes: 2097153 })).rejects.toThrow(
|
||||||
|
CHECK_VIOLATION,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects durationSec 241', async () => {
|
||||||
|
await expect(capture({ durationSec: 241 })).rejects.toThrow(
|
||||||
|
CHECK_VIOLATION,
|
||||||
|
);
|
||||||
|
await expect(capture({ durationSec: 0 })).rejects.toThrow(CHECK_VIOLATION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects CONFIRMED without inspectionId and inspectionId on a non-confirmed row', async () => {
|
||||||
|
await expect(capture({ status: 'CONFIRMED' })).rejects.toThrow(
|
||||||
|
CHECK_VIOLATION,
|
||||||
|
);
|
||||||
|
await expect(capture({ status: 'READY', inspectionId })).rejects.toThrow(
|
||||||
|
CHECK_VIOLATION,
|
||||||
|
);
|
||||||
|
const confirmed = await capture({ status: 'CONFIRMED', inspectionId });
|
||||||
|
expect(confirmed.inspectionId).toBe(inspectionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects audioMime on a TEXT capture', async () => {
|
||||||
|
await expect(
|
||||||
|
capture({ source: 'TEXT', audioMime: 'audio/webm' }),
|
||||||
|
).rejects.toThrow(CHECK_VIOLATION);
|
||||||
|
const text = await capture({ source: 'TEXT' });
|
||||||
|
expect(text.audioMime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleting a capture cascades to its audio row', async () => {
|
||||||
|
const row = await capture({
|
||||||
|
audioMime: 'audio/webm',
|
||||||
|
audioBytes: 3,
|
||||||
|
audio: { create: { dataEnc: Buffer.from([1, 2, 3]) } },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await prisma.voiceCaptureAudio.count({ where: { captureId: row.id } }),
|
||||||
|
).toBe(1);
|
||||||
|
|
||||||
|
await prisma.voiceCapture.delete({ where: { id: row.id } });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await prisma.voiceCaptureAudio.count({ where: { captureId: row.id } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { Prisma } from '../generated/prisma';
|
||||||
|
import { PrismaModule } from '../src/prisma/prisma.module';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
claimVoiceCaptures,
|
||||||
|
deferVoiceClaim,
|
||||||
|
releaseStaleVoiceClaims,
|
||||||
|
settleVoiceFailure,
|
||||||
|
settleVoiceSuccess,
|
||||||
|
} from '../src/voice/claims/voice-claims';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every query is scoped to this file's own owner (ownerUserId filter) and
|
||||||
|
* every row is created and removed here: other suites share the database.
|
||||||
|
*/
|
||||||
|
describe('voice claims (e2e)', () => {
|
||||||
|
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||||
|
let moduleRef: TestingModule;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
let roleId: string;
|
||||||
|
let userId: string;
|
||||||
|
let apiaryId: string;
|
||||||
|
let colonyId: string;
|
||||||
|
|
||||||
|
const mine = () => Prisma.sql`"ownerUserId" = ${userId}`;
|
||||||
|
const stale = (phase: 'transcribe' | 'extract') =>
|
||||||
|
releaseStaleVoiceClaims(prisma, phase, { filter: mine() });
|
||||||
|
const claim = (
|
||||||
|
phase: 'transcribe' | 'extract',
|
||||||
|
limit: number,
|
||||||
|
maxTotalSec?: number,
|
||||||
|
) =>
|
||||||
|
claimVoiceCaptures(prisma, phase, { limit, maxTotalSec, filter: mine() });
|
||||||
|
const TEXT_WAITING = {
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
source: 'TEXT',
|
||||||
|
audioMime: null,
|
||||||
|
durationSec: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function seed(
|
||||||
|
count: number,
|
||||||
|
data: Partial<Prisma.VoiceCaptureUncheckedCreateInput> = {},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const row = await prisma.voiceCapture.create({
|
||||||
|
data: {
|
||||||
|
ownerUserId: userId,
|
||||||
|
colonyId,
|
||||||
|
inspectionDate: new Date('2026-10-16'),
|
||||||
|
source: 'AUDIO',
|
||||||
|
audioMime: 'audio/webm',
|
||||||
|
durationSec: 30,
|
||||||
|
contentExpiresAt: new Date(Date.now() + 7 * 86_400_000),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ids.push(row.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowOf = (id: string) =>
|
||||||
|
prisma.voiceCapture.findUniqueOrThrow({ where: { id } });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
}).compile();
|
||||||
|
await moduleRef.init();
|
||||||
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: { key: `VOICE_CLAIMS_${suffix}`, name: `Voice claims ${suffix}` },
|
||||||
|
});
|
||||||
|
roleId = role.id;
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `voice-claims-${suffix}@example.com`,
|
||||||
|
passwordHash: await bcrypt.hash('SuperSecret123!', 4),
|
||||||
|
name: 'Voice claims',
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
const apiary = await prisma.apiary.create({
|
||||||
|
data: { name: `Voice claims ${suffix}` },
|
||||||
|
});
|
||||||
|
apiaryId = apiary.id;
|
||||||
|
const colony = await prisma.colony.create({
|
||||||
|
data: {
|
||||||
|
code: `VC-${suffix}`,
|
||||||
|
apiaryId,
|
||||||
|
colonyType: 'PRODUCTION_COLONY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
colonyId = colony.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.voiceCapture.deleteMany({ where: { ownerUserId: userId } });
|
||||||
|
await prisma.colony.deleteMany({ where: { id: colonyId } });
|
||||||
|
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
|
||||||
|
await prisma.user.deleteMany({ where: { id: userId } });
|
||||||
|
await prisma.role.deleteMany({ where: { id: roleId } });
|
||||||
|
await moduleRef.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetch claims disjoint sets under SKIP LOCKED', async () => {
|
||||||
|
const ids = await seed(8);
|
||||||
|
const claimedBy: string[][] = [[], [], []];
|
||||||
|
const worker = async (n: number) => {
|
||||||
|
for (let round = 0; round < 8; round++) {
|
||||||
|
const got = await claim('transcribe', 3);
|
||||||
|
if (got.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
claimedBy[n].push(...got.map((c) => c.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all([worker(0), worker(1), worker(2)]);
|
||||||
|
const all = claimedBy.flat();
|
||||||
|
expect(new Set(all).size).toBe(all.length);
|
||||||
|
expect([...all].sort()).toEqual([...ids].sort());
|
||||||
|
const rows = await prisma.voiceCapture.findMany({
|
||||||
|
where: { ownerUserId: userId },
|
||||||
|
});
|
||||||
|
expect(rows.every((r) => r.status === 'TRANSCRIBING')).toBe(true);
|
||||||
|
expect(rows.every((r) => r.claimToken && r.claimedAt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands out a distinct claim token per capture and the data a phase needs', async () => {
|
||||||
|
await seed(2, { durationSec: 42 });
|
||||||
|
const got = await claim('transcribe', 5);
|
||||||
|
expect(new Set(got.map((c) => c.claimToken)).size).toBe(2);
|
||||||
|
expect(got[0]).toMatchObject({
|
||||||
|
attempts: 0,
|
||||||
|
ownerUserId: userId,
|
||||||
|
colonyId,
|
||||||
|
audioMime: 'audio/webm',
|
||||||
|
durationSec: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('claims only captures that are due in the phase', async () => {
|
||||||
|
const [due] = await seed(1);
|
||||||
|
await seed(1, { availableAt: new Date(Date.now() + 3_600_000) });
|
||||||
|
await seed(1, { status: 'TRANSCRIBED' });
|
||||||
|
const got = await claim('transcribe', 10);
|
||||||
|
expect(got.map((c) => c.id)).toEqual([due]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the total seconds of audio per run', async () => {
|
||||||
|
const ids = await seed(3, { durationSec: 200 });
|
||||||
|
const got = await claim('transcribe', 5, 450);
|
||||||
|
expect(got.map((c) => c.id)).toEqual(ids.slice(0, 2));
|
||||||
|
expect((await rowOf(ids[2])).status).toBe('QUEUED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still claims the first capture when it alone exceeds the seconds budget', async () => {
|
||||||
|
await seed(1, { durationSec: 240 });
|
||||||
|
expect(await claim('transcribe', 5, 100)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim a capture whose content has expired', async () => {
|
||||||
|
await seed(1, { contentExpiresAt: new Date(Date.now() - 1000) });
|
||||||
|
expect(await claim('transcribe', 5)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers with a code that stays visible and counts no attempt', async () => {
|
||||||
|
const [id] = await seed(1, TEXT_WAITING);
|
||||||
|
const [claimed] = await claim('extract', 1);
|
||||||
|
expect(
|
||||||
|
await deferVoiceClaim(prisma, 'extract', claimed, 60_000, 'DAILY_LIMIT'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(await rowOf(id)).toMatchObject({
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
attempts: 0,
|
||||||
|
lastErrorCode: 'DAILY_LIMIT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles a success, resets attempts after transcribing and clears the claim', async () => {
|
||||||
|
const [id] = await seed(1, { attempts: 1 });
|
||||||
|
const [claimed] = await claim('transcribe', 1);
|
||||||
|
const ok = await settleVoiceSuccess(
|
||||||
|
prisma,
|
||||||
|
'transcribe',
|
||||||
|
claimed,
|
||||||
|
Prisma.sql`"transcriptEnc" = ${'ct'}`,
|
||||||
|
);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(await rowOf(id)).toMatchObject({
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
claimToken: null,
|
||||||
|
claimedAt: null,
|
||||||
|
attempts: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settle does nothing when the claim token no longer matches', async () => {
|
||||||
|
const [id] = await seed(1);
|
||||||
|
const [claimed] = await claim('transcribe', 1);
|
||||||
|
const wrong = { ...claimed, claimToken: 'someone-else' };
|
||||||
|
expect(await settleVoiceSuccess(prisma, 'transcribe', wrong)).toBe(false);
|
||||||
|
expect(
|
||||||
|
await settleVoiceFailure(
|
||||||
|
prisma,
|
||||||
|
'transcribe',
|
||||||
|
wrong,
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
),
|
||||||
|
).toBe('lost');
|
||||||
|
expect(await deferVoiceClaim(prisma, 'transcribe', wrong, 1000)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(await rowOf(id)).toMatchObject({
|
||||||
|
status: 'TRANSCRIBING',
|
||||||
|
claimToken: claimed.claimToken,
|
||||||
|
attempts: 0,
|
||||||
|
lastErrorCode: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settle does nothing on a capture confirmed meanwhile', async () => {
|
||||||
|
const [id] = await seed(1);
|
||||||
|
const [claimed] = await claim('transcribe', 1);
|
||||||
|
await prisma.voiceCapture.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
inspectionId: `insp-${suffix}`,
|
||||||
|
claimedAt: null,
|
||||||
|
claimToken: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await settleVoiceSuccess(prisma, 'transcribe', claimed)).toBe(false);
|
||||||
|
expect(
|
||||||
|
await settleVoiceFailure(
|
||||||
|
prisma,
|
||||||
|
'transcribe',
|
||||||
|
claimed,
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
),
|
||||||
|
).toBe('lost');
|
||||||
|
expect(
|
||||||
|
await deferVoiceClaim(prisma, 'transcribe', claimed, 1000, 'DAILY_LIMIT'),
|
||||||
|
).toBe(false);
|
||||||
|
expect(await rowOf(id)).toMatchObject({
|
||||||
|
status: 'CONFIRMED',
|
||||||
|
attempts: 0,
|
||||||
|
lastErrorCode: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries after the policy delay and the third failure ends FAILED with the code', async () => {
|
||||||
|
const [id] = await seed(1);
|
||||||
|
const delays: number[] = [];
|
||||||
|
for (let round = 1; round <= 3; round++) {
|
||||||
|
await prisma.voiceCapture.update({
|
||||||
|
where: { id },
|
||||||
|
data: { availableAt: new Date(Date.now() - 1000) },
|
||||||
|
});
|
||||||
|
const [claimed] = await claim('transcribe', 1);
|
||||||
|
expect(claimed.attempts).toBe(round - 1);
|
||||||
|
const outcome = await settleVoiceFailure(
|
||||||
|
prisma,
|
||||||
|
'transcribe',
|
||||||
|
claimed,
|
||||||
|
'TRANSCRIBE_FAILED',
|
||||||
|
);
|
||||||
|
expect(outcome).toBe(round < 3 ? 'retry' : 'failed');
|
||||||
|
const row = await rowOf(id);
|
||||||
|
if (round < 3) {
|
||||||
|
delays.push(row.availableAt.getTime() - Date.now());
|
||||||
|
expect(row.status).toBe('QUEUED');
|
||||||
|
} else {
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
status: 'FAILED',
|
||||||
|
attempts: 3,
|
||||||
|
lastErrorCode: 'TRANSCRIBE_FAILED',
|
||||||
|
claimToken: null,
|
||||||
|
claimedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(delays[0]).toBeGreaterThan(290_000);
|
||||||
|
expect(delays[0]).toBeLessThanOrEqual(300_500);
|
||||||
|
expect(delays[1]).toBeGreaterThan(890_000);
|
||||||
|
expect(delays[1]).toBeLessThanOrEqual(900_500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends an extraction that kept being retryable as EXTRACT_FAILED', async () => {
|
||||||
|
const [id] = await seed(1, { ...TEXT_WAITING, attempts: 2 });
|
||||||
|
const [claimed] = await claim('extract', 1);
|
||||||
|
expect(
|
||||||
|
await settleVoiceFailure(prisma, 'extract', claimed, 'EXTRACT_RETRY'),
|
||||||
|
).toBe('failed');
|
||||||
|
expect(await rowOf(id)).toMatchObject({
|
||||||
|
status: 'FAILED',
|
||||||
|
lastErrorCode: 'EXTRACT_FAILED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers a claim without counting an attempt', async () => {
|
||||||
|
const [id] = await seed(1, TEXT_WAITING);
|
||||||
|
const [claimed] = await claim('extract', 1);
|
||||||
|
expect(await deferVoiceClaim(prisma, 'extract', claimed, 600_000)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const row = await rowOf(id);
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
status: 'TRANSCRIBED',
|
||||||
|
attempts: 0,
|
||||||
|
claimToken: null,
|
||||||
|
});
|
||||||
|
expect(row.availableAt.getTime() - Date.now()).toBeGreaterThan(590_000);
|
||||||
|
expect(await claim('extract', 1)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stale TRANSCRIBING claims are released with attempts + 1', async () => {
|
||||||
|
const [id] = await seed(1);
|
||||||
|
await claim('transcribe', 1);
|
||||||
|
await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '31 minutes' WHERE "id" = ${id}`;
|
||||||
|
expect(await stale('transcribe')).toEqual([
|
||||||
|
{ id, status: 'QUEUED', code: null },
|
||||||
|
]);
|
||||||
|
expect(await rowOf(id)).toMatchObject({
|
||||||
|
status: 'QUEUED',
|
||||||
|
attempts: 1,
|
||||||
|
claimToken: null,
|
||||||
|
claimedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a fresh claim and ends a worn-out stale one as FAILED', async () => {
|
||||||
|
const [fresh] = await seed(1);
|
||||||
|
const [worn] = await seed(1, { attempts: 2 });
|
||||||
|
await claim('transcribe', 2);
|
||||||
|
await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '29 minutes' WHERE "id" = ${worn}`;
|
||||||
|
expect(await stale('transcribe')).toEqual([]);
|
||||||
|
await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '31 minutes' WHERE "id" = ${worn}`;
|
||||||
|
expect(await stale('transcribe')).toEqual([
|
||||||
|
{ id: worn, status: 'FAILED', code: 'TRANSCRIBE_FAILED' },
|
||||||
|
]);
|
||||||
|
expect(await rowOf(worn)).toMatchObject({
|
||||||
|
attempts: 3,
|
||||||
|
lastErrorCode: 'TRANSCRIBE_FAILED',
|
||||||
|
});
|
||||||
|
expect((await rowOf(fresh)).status).toBe('TRANSCRIBING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the shorter stale window of the extract phase', async () => {
|
||||||
|
const [id] = await seed(1, TEXT_WAITING);
|
||||||
|
await claim('extract', 1);
|
||||||
|
await prisma.$executeRaw`UPDATE "VoiceCapture" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`;
|
||||||
|
expect(await stale('transcribe')).toEqual([]);
|
||||||
|
expect(await stale('extract')).toEqual([
|
||||||
|
{ id, status: 'TRANSCRIBED', code: null },
|
||||||
|
]);
|
||||||
|
expect((await rowOf(id)).attempts).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { PERMISSION_CATALOG } from '../src/auth/permissions/permission-catalog';
|
||||||
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
|
||||||
|
const KEY = 'voice-captures:use';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The migration grants the T7 permission to the system roles (the boot-time
|
||||||
|
* catalog sync only grants new permissions to ADMIN). Checked against the
|
||||||
|
* migrated test database.
|
||||||
|
*/
|
||||||
|
describe('T7 voice permissions (e2e)', () => {
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
prisma = new PrismaService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function holds(roleKey: string): Promise<boolean> {
|
||||||
|
const grant = await prisma.rolePermission.findFirst({
|
||||||
|
where: { role: { key: roleKey }, permission: { key: KEY } },
|
||||||
|
});
|
||||||
|
return grant !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('ADMIN, BEEKEEPER and STAFF hold voice-captures:use', async () => {
|
||||||
|
expect(await holds('ADMIN')).toBe(true);
|
||||||
|
expect(await holds('BEEKEEPER')).toBe(true);
|
||||||
|
expect(await holds('STAFF')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('READONLY and API_CLIENT do not', async () => {
|
||||||
|
expect(await holds('READONLY')).toBe(false);
|
||||||
|
expect(await holds('API_CLIENT')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the code catalog in the Sprache group at sortOrder 4000', async () => {
|
||||||
|
const entry = PERMISSION_CATALOG.find((e) => e.key === KEY);
|
||||||
|
const row = await prisma.permission.findUniqueOrThrow({
|
||||||
|
where: { key: KEY },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
resource: 'voice-captures',
|
||||||
|
action: 'use',
|
||||||
|
groupDe: 'Sprache',
|
||||||
|
labelDe: 'Benutzen',
|
||||||
|
sortOrder: 4000,
|
||||||
|
});
|
||||||
|
expect(entry).toMatchObject({ groupDe: 'Sprache', sortOrder: 4000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
// outside it would make the next build skip "unchanged" files.
|
// outside it would make the next build skip "unchanged" files.
|
||||||
"incremental": false
|
"incremental": false
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts", "**/*.spec-helper.ts", "prisma.config.ts", "prisma/seed.ts"]
|
"exclude": ["node_modules", "test", "scripts", "dist", "**/*spec.ts", "**/*.spec-helper.ts", "prisma.config.ts", "prisma/seed.ts"]
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { feederRange, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
|
import { feederRange, frameFeederOps, freeFeederStarts, rangeText } from "@/lib/hive/feeder-slots";
|
||||||
|
import { freshPart } from "@/lib/hive/fresh-part";
|
||||||
import { PART_KIND_LABELS } from "@/lib/hive/labels";
|
import { PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||||
import { partDisplayName } from "@/lib/hive/names";
|
import { partDisplayName } from "@/lib/hive/names";
|
||||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||||
@@ -20,13 +21,11 @@ import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
|||||||
import type {
|
import type {
|
||||||
CatalogPartType,
|
CatalogPartType,
|
||||||
HiveCatalog,
|
HiveCatalog,
|
||||||
HiveFrame,
|
|
||||||
HiveOp,
|
HiveOp,
|
||||||
HivePart,
|
HivePart,
|
||||||
HivePartKind,
|
HivePartKind,
|
||||||
HiveState,
|
HiveState,
|
||||||
} from "@/lib/hive/types";
|
} from "@/lib/hive/types";
|
||||||
import { frameFeederOps } from "../frame-feeder-picker";
|
|
||||||
import { areDraftFieldsValid, draftOf, draftValue, StateFieldControl } from "../state-field-control";
|
import { areDraftFieldsValid, draftOf, draftValue, StateFieldControl } from "../state-field-control";
|
||||||
import type { FieldDraft } from "../state-field-control";
|
import type { FieldDraft } from "../state-field-control";
|
||||||
import type { DraftApplyResult } from "./use-hive-draft";
|
import type { DraftApplyResult } from "./use-hive-draft";
|
||||||
@@ -117,10 +116,6 @@ function Field({ label, htmlFor, children }: { label: string; htmlFor: string; c
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newBoxFrames(type: CatalogPartType): HiveFrame[] {
|
|
||||||
return Array.from({ length: type.frameSlots ?? 0 }, (_, index) => ({ slot: index + 1, type: "FOUNDATION" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) {
|
function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps) {
|
||||||
const formId = useId();
|
const formId = useId();
|
||||||
const [newId] = useState(newIdempotencyKey);
|
const [newId] = useState(newIdempotencyKey);
|
||||||
@@ -142,8 +137,7 @@ function AddPartForm({ type, state, catalog, onAdd, onClose }: AddPartFormProps)
|
|||||||
function buildOps(): HiveOp[] {
|
function buildOps(): HiveOp[] {
|
||||||
const partState: Record<string, number | string> = {};
|
const partState: Record<string, number | string> = {};
|
||||||
for (const field of type.stateSchema) partState[field.key] = draftValue(field, draft[field.key] ?? "");
|
for (const field of type.stateSchema) partState[field.key] = draftValue(field, draft[field.key] ?? "");
|
||||||
const fresh: HivePart = { id: newId, partTypeId: type.id, state: partState };
|
const fresh = freshPart(type, newId, partState);
|
||||||
if (type.kind === "BOX") fresh.frames = newBoxFrames(type);
|
|
||||||
if (!isInsert) return [{ op: "addPart", part: fresh, at: Number(position) }];
|
if (!isInsert) return [{ op: "addPart", part: fresh, at: Number(position) }];
|
||||||
if (type.kind === "FRAME_FEEDER" && host && chosenStart !== null) {
|
if (type.kind === "FRAME_FEEDER" && host && chosenStart !== null) {
|
||||||
return frameFeederOps(host.id, host.frames ?? [], type, chosenStart, fresh);
|
return frameFeederOps(host.id, host.frames ?? [], type, chosenStart, fresh);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyHiveOps } from "@/lib/hive/ops";
|
||||||
|
import { frames, inlineCatalog, part, templateState } from "@/lib/hive/test-utils";
|
||||||
|
import type { HiveState } from "@/lib/hive/types";
|
||||||
|
import { dropOps, dropSlots, sameSlot } from "./drop-slots";
|
||||||
|
|
||||||
|
const catalog = inlineCatalog();
|
||||||
|
|
||||||
|
function withInserts(): HiveState {
|
||||||
|
const state = templateState();
|
||||||
|
state.stack[0].inserts = [part("fi", "floor-insert")];
|
||||||
|
state.stack[1].inserts = [part("ev", "evaporator")];
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dropSlots (plan step 1)", () => {
|
||||||
|
it("offers a dragged box every place between floor and roof", () => {
|
||||||
|
const slots = dropSlots(templateState(), catalog, { partTypeId: "box" });
|
||||||
|
expect(slots).toEqual([1, 2, 3, 4, 5].map((at) => ({ kind: "stack", at })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a dragged Bausperre one insert slot on the floor", () => {
|
||||||
|
expect(dropSlots(templateState(), catalog, { partTypeId: "floor-insert" })).toEqual([{ kind: "insert", hostId: "f" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a dragged floor nothing (one floor, always at the bottom)", () => {
|
||||||
|
expect(dropSlots(templateState(), catalog, { partTypeId: "floor" })).toEqual([]);
|
||||||
|
expect(dropSlots(templateState(), catalog, { partId: "f" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the existing roof nothing (it can only stay on top)", () => {
|
||||||
|
expect(dropSlots(templateState(), catalog, { partId: "r" })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dropSlots", () => {
|
||||||
|
it("moves an existing part to every other legal place, not to its own", () => {
|
||||||
|
const slots = dropSlots(templateState(), catalog, { partId: "c" });
|
||||||
|
expect(slots).toEqual([1, 3, 4].map((at) => ({ kind: "stack", at })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no second Bausperre and no second evaporator", () => {
|
||||||
|
const state = withInserts();
|
||||||
|
expect(dropSlots(state, catalog, { partTypeId: "floor-insert" })).toEqual([]);
|
||||||
|
expect(dropSlots(state, catalog, { partTypeId: "evaporator" })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a frame feeder every free start in each box, as the dialog does", () => {
|
||||||
|
const state = templateState();
|
||||||
|
state.stack[1].inserts = [part("ff", "frame-feeder", { slot: 1 })];
|
||||||
|
state.stack[1].frames = frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame));
|
||||||
|
const slots = dropSlots(state, catalog, { partTypeId: "frame-feeder" });
|
||||||
|
expect(slots.map((slot) => (slot.kind === "insert" ? slot.slot : null))).toEqual([3, 4, 5, 6, 7, 8, 9]);
|
||||||
|
expect(slots.every((slot) => slot.kind === "insert" && slot.hostId === "b")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves an existing insert to another host that can take it", () => {
|
||||||
|
const state = templateState();
|
||||||
|
state.stack.splice(2, 0, part("b2", "box", { frames: frames(10) }));
|
||||||
|
state.stack[1].inserts = [part("ev", "evaporator")];
|
||||||
|
expect(dropSlots(state, catalog, { partId: "ev" })).toEqual([{ kind: "insert", hostId: "b2" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for unknown parts and types", () => {
|
||||||
|
expect(dropSlots(templateState(), catalog, { partId: "nope" })).toEqual([]);
|
||||||
|
expect(dropSlots(templateState(), catalog, { partTypeId: "nope" })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dropOps", () => {
|
||||||
|
it("adds a new box with foundation frames at the slot", () => {
|
||||||
|
const ops = dropOps(templateState(), catalog, { partTypeId: "box" }, { kind: "stack", at: 2 }, "new-box");
|
||||||
|
expect(ops).toEqual([
|
||||||
|
{
|
||||||
|
op: "addPart",
|
||||||
|
at: 2,
|
||||||
|
part: { id: "new-box", partTypeId: "box", state: {}, frames: frames(10, "FOUNDATION") },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(applyHiveOps(templateState(), ops, catalog).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves an existing part", () => {
|
||||||
|
expect(dropOps(templateState(), catalog, { partId: "c" }, { kind: "stack", at: 4 }, "unused")).toEqual([
|
||||||
|
{ op: "movePart", id: "c", to: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empties the covered frames and hangs a frame feeder at its slot", () => {
|
||||||
|
const ops = dropOps(templateState(), catalog, { partTypeId: "frame-feeder" }, { kind: "insert", hostId: "b", slot: 4 }, "ff");
|
||||||
|
expect(ops[0]).toMatchObject({ op: "setFrames", boxId: "b" });
|
||||||
|
// Same part as the add dialog makes: every state field at its default.
|
||||||
|
expect(ops[1]).toEqual({
|
||||||
|
op: "addInsert",
|
||||||
|
hostId: "b",
|
||||||
|
part: { id: "ff", partTypeId: "frame-feeder", state: { syrupLiters: 0 }, slot: 4 },
|
||||||
|
});
|
||||||
|
expect(applyHiveOps(templateState(), ops, catalog).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves an insert by taking it out and putting the same part (id, state) into the new host", () => {
|
||||||
|
const state = templateState();
|
||||||
|
state.stack.splice(2, 0, part("b2", "box", { frames: frames(10) }));
|
||||||
|
state.stack[1].inserts = [part("ev", "evaporator", { state: { acidMl: 120 } })];
|
||||||
|
const ops = dropOps(state, catalog, { partId: "ev" }, { kind: "insert", hostId: "b2" }, "unused");
|
||||||
|
expect(ops).toEqual([
|
||||||
|
{ op: "removePart", id: "ev" },
|
||||||
|
{ op: "addInsert", hostId: "b2", part: { id: "ev", partTypeId: "evaporator", state: { acidMl: 120 } } },
|
||||||
|
]);
|
||||||
|
expect(applyHiveOps(state, ops, catalog).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sameSlot", () => {
|
||||||
|
it("compares slots by place", () => {
|
||||||
|
expect(sameSlot({ kind: "stack", at: 2 }, { kind: "stack", at: 2 })).toBe(true);
|
||||||
|
expect(sameSlot({ kind: "insert", hostId: "b", slot: 3 }, { kind: "insert", hostId: "b", slot: 4 })).toBe(false);
|
||||||
|
expect(sameSlot(null, null)).toBe(true);
|
||||||
|
expect(sameSlot({ kind: "stack", at: 1 }, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { frameFeederOps, freeFeederStarts } from "@/lib/hive/feeder-slots";
|
||||||
|
import { freshPart } from "@/lib/hive/fresh-part";
|
||||||
|
import { applyHiveOps } from "@/lib/hive/ops";
|
||||||
|
import type { HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||||
|
|
||||||
|
// Where a dragged part may land (spec 9.5, plan Task 18). Every candidate is
|
||||||
|
// applied to a copy and validated with the same rules as the server
|
||||||
|
// (applyHiveOps), so the drop slots never disagree with saving: one floor at
|
||||||
|
// the bottom, the roof only on top, one Bausperre per floor, one evaporator
|
||||||
|
// per box, frame feeders only over free frame slots.
|
||||||
|
|
||||||
|
/** A new part from the parts bar, or a part already in the build. */
|
||||||
|
export type DragSource = { partTypeId: string } | { partId: string };
|
||||||
|
|
||||||
|
export type DropSlot =
|
||||||
|
| { kind: "stack"; at: number }
|
||||||
|
/** `slot`: first frame slot of a frame feeder. */
|
||||||
|
| { kind: "insert"; hostId: string; slot?: number };
|
||||||
|
|
||||||
|
const INSERT_KINDS: readonly HivePartKind[] = ["FLOOR_INSERT", "EVAPORATOR", "FRAME_FEEDER"];
|
||||||
|
|
||||||
|
export function sameSlot(a: DropSlot | null, b: DropSlot | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
if (a.kind === "stack" && b.kind === "stack") return a.at === b.at;
|
||||||
|
if (a.kind === "insert" && b.kind === "insert") return a.hostId === b.hostId && a.slot === b.slot;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Located {
|
||||||
|
part: HivePart;
|
||||||
|
index: number;
|
||||||
|
hostId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function locate(state: HiveState, id: string): Located | null {
|
||||||
|
for (const [index, part] of state.stack.entries()) {
|
||||||
|
if (part.id === id) return { part, index, hostId: null };
|
||||||
|
const insert = part.inserts?.find((candidate) => candidate.id === id);
|
||||||
|
if (insert) return { part: insert, index, hostId: part.id };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dragged part with its type, or null for unknown ids and types. */
|
||||||
|
function draggedPart(state: HiveState, catalog: HiveCatalog, source: DragSource, newId: string) {
|
||||||
|
if ("partId" in source) {
|
||||||
|
const found = locate(state, source.partId);
|
||||||
|
const type = found ? catalog.partTypes.get(found.part.partTypeId) : undefined;
|
||||||
|
return found && type ? { part: found.part, type, found } : null;
|
||||||
|
}
|
||||||
|
const type = catalog.partTypes.get(source.partTypeId);
|
||||||
|
return type ? { part: freshPart(type, newId), type, found: null } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops that perform the drop; [] when the slot does not fit the source. */
|
||||||
|
export function dropOps(state: HiveState, catalog: HiveCatalog, source: DragSource, slot: DropSlot, newId: string): HiveOp[] {
|
||||||
|
const dragged = draggedPart(state, catalog, source, newId);
|
||||||
|
if (!dragged) return [];
|
||||||
|
const { part, type, found } = dragged;
|
||||||
|
const isInsert = INSERT_KINDS.includes(type.kind);
|
||||||
|
if (slot.kind === "stack") {
|
||||||
|
if (isInsert) return [];
|
||||||
|
return found ? [{ op: "movePart", id: part.id, to: slot.at }] : [{ op: "addPart", part, at: slot.at }];
|
||||||
|
}
|
||||||
|
if (!isInsert) return [];
|
||||||
|
// An insert moves by leaving its host and joining the new one as the same part.
|
||||||
|
const leave: HiveOp[] = found ? [{ op: "removePart", id: part.id }] : [];
|
||||||
|
const moved: HivePart = { ...part };
|
||||||
|
delete moved.slot;
|
||||||
|
if (type.kind === "FRAME_FEEDER") {
|
||||||
|
const host = state.stack.find((candidate) => candidate.id === slot.hostId);
|
||||||
|
if (!host || slot.slot === undefined) return [];
|
||||||
|
return [...leave, ...frameFeederOps(host.id, host.frames ?? [], type, slot.slot, moved)];
|
||||||
|
}
|
||||||
|
return [...leave, { op: "addInsert", hostId: slot.hostId, part: moved }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidates(state: HiveState, catalog: HiveCatalog, source: DragSource): DropSlot[] {
|
||||||
|
const dragged = draggedPart(state, catalog, source, "candidate");
|
||||||
|
if (!dragged) return [];
|
||||||
|
const { type, found } = dragged;
|
||||||
|
// The floor never moves (exactly one, always at the bottom).
|
||||||
|
if (type.kind === "FLOOR" && found) return [];
|
||||||
|
if (!INSERT_KINDS.includes(type.kind)) {
|
||||||
|
// Moving: every index but its own; adding: every gap including the top.
|
||||||
|
const count = found ? state.stack.length : state.stack.length + 1;
|
||||||
|
return Array.from({ length: count }, (_, at) => at)
|
||||||
|
.filter((at) => !found || at !== found.index)
|
||||||
|
.map((at) => ({ kind: "stack", at }));
|
||||||
|
}
|
||||||
|
return state.stack.flatMap((host): DropSlot[] => {
|
||||||
|
if (found && found.hostId === host.id) return [];
|
||||||
|
if (type.kind !== "FRAME_FEEDER") return [{ kind: "insert", hostId: host.id }];
|
||||||
|
const hostType = catalog.partTypes.get(host.partTypeId);
|
||||||
|
if (hostType?.kind !== "BOX") return [];
|
||||||
|
return freeFeederStarts(host, hostType, type, catalog).map((slot) => ({ kind: "insert", hostId: host.id, slot }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legal drop slots for a dragged part: each candidate is applied to a copy and validated. */
|
||||||
|
export function dropSlots(state: HiveState, catalog: HiveCatalog, source: DragSource): DropSlot[] {
|
||||||
|
return candidates(state, catalog, source).filter((slot) => {
|
||||||
|
const ops = dropOps(state, catalog, source, slot, "candidate");
|
||||||
|
return ops.length > 0 && applyHiveOps(state, ops, catalog).ok;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ZoomControls } from "./editor-stage-overlay";
|
||||||
|
|
||||||
|
describe("ZoomControls", () => {
|
||||||
|
it("zooms in and out in steps", async () => {
|
||||||
|
const onZoom = vi.fn();
|
||||||
|
render(<ZoomControls zoom={1} onZoom={onZoom} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Vergrößern" }));
|
||||||
|
expect(onZoom).toHaveBeenLastCalledWith(1.25);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Verkleinern" }));
|
||||||
|
expect(onZoom).toHaveBeenLastCalledWith(0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the keyboard focus at the limit: aria-disabled, no change", async () => {
|
||||||
|
const onZoom = vi.fn();
|
||||||
|
render(<ZoomControls zoom={2.5} onZoom={onZoom} />);
|
||||||
|
const zoomIn = screen.getByRole("button", { name: "Vergrößern" });
|
||||||
|
expect(zoomIn).not.toBeDisabled();
|
||||||
|
expect(zoomIn).toHaveAttribute("aria-disabled", "true");
|
||||||
|
zoomIn.focus();
|
||||||
|
await userEvent.keyboard("{Enter}");
|
||||||
|
expect(onZoom).not.toHaveBeenCalled();
|
||||||
|
expect(zoomIn).toHaveFocus();
|
||||||
|
expect(screen.getByRole("button", { name: "Verkleinern" })).toHaveAttribute("aria-disabled", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Trash2, ZoomIn, ZoomOut } from "lucide-react";
|
||||||
|
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
|
||||||
|
import { ZOOM_MAX, ZOOM_MIN, stepZoom } from "../hive-scene/camera-fit";
|
||||||
|
import type { DragPointer, HiveDragView } from "./use-hive-drag";
|
||||||
|
|
||||||
|
// DOM over the editor scene (spec 9.5): zoom buttons, the „Entfernen“ zone
|
||||||
|
// while a part of the build is dragged, and a name chip that follows the
|
||||||
|
// pointer while dragging.
|
||||||
|
|
||||||
|
export function ZoomControls({ zoom, onZoom }: { zoom: number; onZoom: (zoom: number) => void }) {
|
||||||
|
// At a limit the button stays focusable (aria-disabled) so the keyboard focus does not fall to the page.
|
||||||
|
const atMin = zoom <= ZOOM_MIN + 1e-6;
|
||||||
|
const atMax = zoom >= ZOOM_MAX - 1e-6;
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto absolute end-2 top-2 flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
aria-disabled={atMin}
|
||||||
|
onClick={() => {
|
||||||
|
if (!atMin) onZoom(stepZoom(zoom, -1));
|
||||||
|
}}
|
||||||
|
className={`${GLASS_BUTTON} aria-disabled:cursor-not-allowed aria-disabled:opacity-60`}
|
||||||
|
>
|
||||||
|
<ZoomOut aria-hidden />
|
||||||
|
</button>
|
||||||
|
<span className={`rounded-lg px-2 py-1 text-xs tabular-nums ${GLASS_SURFACE}`} aria-live="off">
|
||||||
|
{Math.round(zoom * 100)} %
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
aria-disabled={atMax}
|
||||||
|
onClick={() => {
|
||||||
|
if (!atMax) onZoom(stepZoom(zoom, 1));
|
||||||
|
}}
|
||||||
|
className={`${GLASS_BUTTON} aria-disabled:cursor-not-allowed aria-disabled:opacity-60`}
|
||||||
|
>
|
||||||
|
<ZoomIn aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop a part of the build here to remove it; only shown while such a part is dragged. */
|
||||||
|
export function RemoveZone({ zoneRef, active }: { zoneRef: RefObject<HTMLDivElement | null>; active: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={zoneRef}
|
||||||
|
className={`pointer-events-none absolute inset-x-0 bottom-3 mx-auto flex w-fit items-center gap-2 rounded-2xl border-2 border-dashed px-6 py-4 text-sm font-medium transition-colors duration-150 ${
|
||||||
|
active ? "border-red-300 bg-red-500/35 text-white" : `border-white/30 ${GLASS_SURFACE}`
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden className="size-5" />
|
||||||
|
Entfernen
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dragged part's name next to the pointer (handy on touch, where the finger hides the preview). */
|
||||||
|
export function DragChip({ drag, pointer }: { drag: HiveDragView; pointer: RefObject<DragPointer | null> }) {
|
||||||
|
const chip = useRef<HTMLDivElement>(null);
|
||||||
|
// Over „Entfernen“ the chip goes above the pointer, so the zone's label stays readable.
|
||||||
|
const above = useRef(drag.overRemove);
|
||||||
|
useEffect(() => {
|
||||||
|
above.current = drag.overRemove;
|
||||||
|
}, [drag.overRemove]);
|
||||||
|
useEffect(() => {
|
||||||
|
let frame = 0;
|
||||||
|
const follow = () => {
|
||||||
|
const at = pointer.current;
|
||||||
|
const node = chip.current;
|
||||||
|
if (node && at) {
|
||||||
|
// Beside the pointer, but always inside the window (narrow phones).
|
||||||
|
const x = Math.max(8, Math.min(at.x + 14, window.innerWidth - node.offsetWidth - 8));
|
||||||
|
const wanted = above.current ? at.y - node.offsetHeight - 14 : at.y + 14;
|
||||||
|
const y = Math.max(8, Math.min(wanted, window.innerHeight - node.offsetHeight - 8));
|
||||||
|
node.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(follow);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(follow);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [pointer]);
|
||||||
|
const where = drag.overRemove ? "Loslassen entfernt" : drag.hover ? "Loslassen setzt ein" : drag.slots.length === 0 ? "Kein Platz frei" : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={chip}
|
||||||
|
aria-hidden
|
||||||
|
className={`pointer-events-none fixed start-0 top-0 z-50 rounded-xl px-3 py-1.5 text-sm font-medium ${GLASS_SURFACE}`}
|
||||||
|
>
|
||||||
|
{drag.label}
|
||||||
|
{where ? <span className="block text-xs text-holo-label-fg/80">{where}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,13 +21,17 @@ import { HiveVisualSlot } from "../hive-visual-slot";
|
|||||||
import { useHiveMutation } from "../use-hive-mutation";
|
import { useHiveMutation } from "../use-hive-mutation";
|
||||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||||
import { SAVE_FAILED_TEXT } from "../use-sheet-submit";
|
import { SAVE_FAILED_TEXT } from "../use-sheet-submit";
|
||||||
|
import type { SceneEditor } from "../hive-scene/scene-editor";
|
||||||
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import { AddPartDialog } from "./add-part-dialog";
|
import { AddPartDialog } from "./add-part-dialog";
|
||||||
import { ConfirmDialog } from "./confirm-dialog";
|
import { ConfirmDialog } from "./confirm-dialog";
|
||||||
import { EditorStackList } from "./editor-stack-list";
|
import { EditorStackList } from "./editor-stack-list";
|
||||||
|
import { DragChip, RemoveZone, ZoomControls } from "./editor-stage-overlay";
|
||||||
import { EditorToolbar } from "./editor-toolbar";
|
import { EditorToolbar } from "./editor-toolbar";
|
||||||
import { templateFromState } from "./hive-template";
|
import { templateFromState } from "./hive-template";
|
||||||
import { PartPalette } from "./part-palette";
|
import { PartPalette } from "./part-palette";
|
||||||
import { useHiveDraft } from "./use-hive-draft";
|
import { useHiveDraft } from "./use-hive-draft";
|
||||||
|
import { useHiveDrag } from "./use-hive-drag";
|
||||||
import type { DraftApplyResult, DraftStepInfo } from "./use-hive-draft";
|
import type { DraftApplyResult, DraftStepInfo } from "./use-hive-draft";
|
||||||
|
|
||||||
export interface HiveEditorProps {
|
export interface HiveEditorProps {
|
||||||
@@ -162,6 +166,34 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
|
|||||||
);
|
);
|
||||||
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
|
const applyInDialog = useCallback((ops: HiveOp[]) => applyStep(ops, true), [applyStep]);
|
||||||
|
|
||||||
|
// Drag and drop in the scene (spec 9.5); the same draft step and announcement as the list.
|
||||||
|
const removeZoneRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const dragApply = useCallback((ops: HiveOp[]) => applyStep(ops, false), [applyStep]);
|
||||||
|
const drag = useHiveDrag({
|
||||||
|
state: draft.state,
|
||||||
|
catalog,
|
||||||
|
apply: dragApply,
|
||||||
|
announce,
|
||||||
|
removeZone: removeZoneRef,
|
||||||
|
newId: newIdempotencyKey,
|
||||||
|
});
|
||||||
|
const { begin: beginDrag, setHover: setDragHover, pointer: dragPointer, subscribePointer } = drag;
|
||||||
|
const dragView = drag.drag;
|
||||||
|
const sceneEditor = useMemo<SceneEditor>(
|
||||||
|
() => ({
|
||||||
|
drag: dragView ? { source: dragView.source, slots: dragView.slots, hover: dragView.hover } : null,
|
||||||
|
pointer: dragPointer,
|
||||||
|
subscribePointer,
|
||||||
|
onHoverSlot: setDragHover,
|
||||||
|
onPartPress: (id, event) => beginDrag({ partId: id }, partName(id), event),
|
||||||
|
zoom,
|
||||||
|
onZoom: setZoom,
|
||||||
|
}),
|
||||||
|
[dragView, dragPointer, subscribePointer, setDragHover, beginDrag, partName, zoom],
|
||||||
|
);
|
||||||
|
const draggingBuiltPart = dragView !== null && "partId" in dragView.source;
|
||||||
|
|
||||||
const { undo: undoDraft, redo: redoDraft } = draft;
|
const { undo: undoDraft, redo: redoDraft } = draft;
|
||||||
const describeStep = useCallback(
|
const describeStep = useCallback(
|
||||||
(prefix: string, step: DraftStepInfo) => `${prefix}: ${describeHiveOps(step.ops, step.before, catalog)}.`,
|
(prefix: string, step: DraftStepInfo) => `${prefix}: ${describeHiveOps(step.ops, step.before, catalog)}.`,
|
||||||
@@ -317,7 +349,10 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
|
|||||||
partName={partName}
|
partName={partName}
|
||||||
onSelectPart={setPickedId}
|
onSelectPart={setPickedId}
|
||||||
onSelectFrame={noop}
|
onSelectFrame={noop}
|
||||||
|
editor={sceneEditor}
|
||||||
|
sceneOverlay={<ZoomControls zoom={zoom} onZoom={setZoom} />}
|
||||||
/>
|
/>
|
||||||
|
{draggingBuiltPart ? <RemoveZone zoneRef={removeZoneRef} active={dragView.overRemove} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<aside
|
<aside
|
||||||
aria-label="Teile und Aufbau"
|
aria-label="Teile und Aufbau"
|
||||||
@@ -337,7 +372,11 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
|
|||||||
<h2 id="editor-parts" className="text-base font-semibold">
|
<h2 id="editor-parts" className="text-base font-semibold">
|
||||||
Teile
|
Teile
|
||||||
</h2>
|
</h2>
|
||||||
<PartPalette catalog={catalog} onAdd={openAdd} />
|
<PartPalette
|
||||||
|
catalog={catalog}
|
||||||
|
onAdd={openAdd}
|
||||||
|
onDragStart={(type, event) => beginDrag({ partTypeId: type.id }, type.name, event)}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
|
<section aria-labelledby="editor-stack" className="flex flex-col gap-3">
|
||||||
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none">
|
<h2 id="editor-stack" ref={stackHeadingRef} tabIndex={-1} className="text-base font-semibold outline-none">
|
||||||
@@ -353,6 +392,7 @@ function HiveEditorBody({ colonyId, setup, catalog, canManageTemplates, onRefres
|
|||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
{dragView ? <DragChip drag={dragView} pointer={dragPointer} /> : null}
|
||||||
<div role="status" aria-live="polite" className="sr-only">
|
<div role="status" aria-live="polite" className="sr-only">
|
||||||
<span key={announcement.nonce}>{announcement.text}</span>
|
<span key={announcement.nonce}>{announcement.text}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { inlineCatalog } from "@/lib/hive/test-utils";
|
import { inlineCatalog } from "@/lib/hive/test-utils";
|
||||||
import { PartPalette } from "./part-palette";
|
import { PartPalette } from "./part-palette";
|
||||||
@@ -33,4 +33,22 @@ describe("PartPalette", () => {
|
|||||||
|
|
||||||
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "box", kind: "BOX" }));
|
expect(onAdd).toHaveBeenCalledWith(expect.objectContaining({ id: "box", kind: "BOX" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts a pointer drag from a part's grip (the button stays the keyboard way)", () => {
|
||||||
|
const onDragStart = vi.fn();
|
||||||
|
const { container } = render(<PartPalette catalog={inlineCatalog()} onAdd={vi.fn()} onDragStart={onDragStart} />);
|
||||||
|
const grip = container.querySelector<HTMLElement>('[data-drag-type="box"]')!;
|
||||||
|
expect(grip).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(grip).toHaveClass("touch-none");
|
||||||
|
fireEvent.pointerDown(grip, { pointerId: 7, clientX: 12, clientY: 34, button: 0 });
|
||||||
|
expect(onDragStart).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: "box" }),
|
||||||
|
expect.objectContaining({ clientX: 12, clientY: 34 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no grip without a drag handler", () => {
|
||||||
|
const { container } = render(<PartPalette catalog={inlineCatalog()} onAdd={vi.fn()} />);
|
||||||
|
expect(container.querySelector("[data-drag-type]")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { PointerEvent } from "react";
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
import { PART_KIND_LABELS } from "@/lib/hive/labels";
|
import { PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||||
import type { CatalogPartType, HiveCatalog, HivePartKind } from "@/lib/hive/types";
|
import type { CatalogPartType, HiveCatalog, HivePartKind } from "@/lib/hive/types";
|
||||||
import { GLASS_BUTTON } from "../hive-glass";
|
import { GLASS_BUTTON } from "../hive-glass";
|
||||||
@@ -50,10 +52,12 @@ export interface PartPaletteProps {
|
|||||||
catalog: HiveCatalog;
|
catalog: HiveCatalog;
|
||||||
/** „Hinzufügen …“ of one part type: opens the dialog that asks for the position. */
|
/** „Hinzufügen …“ of one part type: opens the dialog that asks for the position. */
|
||||||
onAdd: (type: CatalogPartType) => void;
|
onAdd: (type: CatalogPartType) => void;
|
||||||
|
/** Pointer drag from the part's grip into the scene (mouse, pen, finger). */
|
||||||
|
onDragStart?: (type: CatalogPartType, event: PointerEvent<HTMLElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The parts bar of the editor (spec 9.5): active part types in four groups. */
|
/** The parts bar of the editor (spec 9.5): active part types in four groups. */
|
||||||
export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
|
export function PartPalette({ catalog, onAdd, onDragStart }: PartPaletteProps) {
|
||||||
const active = [...catalog.partTypes.values()].filter((type) => type.active);
|
const active = [...catalog.partTypes.values()].filter((type) => type.active);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -70,7 +74,25 @@ export function PartPalette({ catalog, onAdd }: PartPaletteProps) {
|
|||||||
<ul className="flex flex-col gap-1.5">
|
<ul className="flex flex-col gap-1.5">
|
||||||
{types.map((type) => (
|
{types.map((type) => (
|
||||||
<li key={type.id} className="flex items-center gap-2">
|
<li key={type.id} className="flex items-center gap-2">
|
||||||
|
{onDragStart ? (
|
||||||
|
// Pointer-only grip; „Hinzufügen …“ is the keyboard and screen reader way.
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
title="In die Beute ziehen"
|
||||||
|
data-drag-type={type.id}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onDragStart(type, event);
|
||||||
|
}}
|
||||||
|
className="flex h-11 shrink-0 cursor-grab touch-none items-center gap-0.5 rounded-lg pe-1 select-none hover:bg-white/5 active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripVertical className="size-4 text-holo-label-fg/60" />
|
||||||
<PartSymbol type={type} />
|
<PartSymbol type={type} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<PartSymbol type={type} />
|
||||||
|
)}
|
||||||
<span className="min-w-0 flex-1 text-sm">
|
<span className="min-w-0 flex-1 text-sm">
|
||||||
<span className="block truncate">{type.name}</span>
|
<span className="block truncate">{type.name}</span>
|
||||||
<span className="block text-xs text-holo-label-fg/70">{PART_KIND_LABELS[type.kind]}</span>
|
<span className="block text-xs text-holo-label-fg/70">{PART_KIND_LABELS[type.kind]}</span>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { inlineCatalog, templateState } from "@/lib/hive/test-utils";
|
||||||
|
import type { HiveOp } from "@/lib/hive/types";
|
||||||
|
import type { DragSource } from "./drop-slots";
|
||||||
|
import { DRAG_START_PX, useHiveDrag } from "./use-hive-drag";
|
||||||
|
|
||||||
|
const catalog = inlineCatalog();
|
||||||
|
|
||||||
|
function pointer(type: string, clientX: number, clientY = 100, pointerId = 1): Event {
|
||||||
|
const event = new Event(type);
|
||||||
|
Object.assign(event, { clientX, clientY, pointerId });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(removeRect = { left: 0, top: 500, right: 200, bottom: 600 }) {
|
||||||
|
const apply = vi.fn((ops: HiveOp[]) => {
|
||||||
|
void ops;
|
||||||
|
return { ok: true as const };
|
||||||
|
});
|
||||||
|
const announce = vi.fn();
|
||||||
|
const zone = document.createElement("div");
|
||||||
|
zone.getBoundingClientRect = () => ({ ...removeRect, width: 200, height: 100, x: 0, y: 500, toJSON: () => ({}) }) as DOMRect;
|
||||||
|
const removeZone = { current: zone };
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useHiveDrag({ state: templateState(), catalog, apply, announce, removeZone, newId: () => "new-1" }),
|
||||||
|
);
|
||||||
|
const begin = (source: DragSource, x = 100) =>
|
||||||
|
act(() => result.current.begin(source, "Teil", { pointerId: 1, clientX: x, clientY: 100 }));
|
||||||
|
const move = (x: number, y = 100) => act(() => void window.dispatchEvent(pointer("pointermove", x, y)));
|
||||||
|
const up = (x: number, y = 100) => act(() => void window.dispatchEvent(pointer("pointerup", x, y)));
|
||||||
|
return { result, apply, announce, begin, move, up };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useHiveDrag", () => {
|
||||||
|
it("only becomes a drag after the pointer moved a few pixels (a tap stays a tap)", () => {
|
||||||
|
const { result, apply, begin, move, up } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(100 + DRAG_START_PX - 2);
|
||||||
|
expect(result.current.drag).toBeNull();
|
||||||
|
up(100);
|
||||||
|
expect(apply).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.drag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts a drag with the legal slots and tracks the pointer for the scene", () => {
|
||||||
|
const { result, begin, move } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(140, 120);
|
||||||
|
expect(result.current.drag?.slots).toEqual([1, 2, 3, 4, 5].map((at) => ({ kind: "stack", at })));
|
||||||
|
expect(result.current.pointer.current).toEqual({ x: 140, y: 120 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops onto the slot the scene reports: adds the new part as one draft step", () => {
|
||||||
|
const { result, apply, begin, move, up } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(160);
|
||||||
|
act(() => result.current.setHover({ kind: "stack", at: 2 }));
|
||||||
|
expect(result.current.drag?.hover).toEqual({ kind: "stack", at: 2 });
|
||||||
|
up(160);
|
||||||
|
expect(apply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(apply.mock.calls[0][0]).toEqual([expect.objectContaining({ op: "addPart", at: 2, part: expect.objectContaining({ id: "new-1" }) })]);
|
||||||
|
expect(result.current.drag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves an existing part", () => {
|
||||||
|
const { result, apply, begin, move, up } = setup();
|
||||||
|
begin({ partId: "c" });
|
||||||
|
move(160);
|
||||||
|
act(() => result.current.setHover({ kind: "stack", at: 4 }));
|
||||||
|
up(160);
|
||||||
|
expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing part dropped on „Entfernen“", () => {
|
||||||
|
const { result, apply, begin, move, up } = setup();
|
||||||
|
begin({ partId: "c" });
|
||||||
|
move(100, 550);
|
||||||
|
expect(result.current.drag?.overRemove).toBe(true);
|
||||||
|
up(100, 550);
|
||||||
|
expect(apply).toHaveBeenCalledWith([{ op: "removePart", id: "c" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never removes a part that comes from the parts bar", () => {
|
||||||
|
const { result, apply, begin, move, up } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(100, 550);
|
||||||
|
expect(result.current.drag?.overRemove).toBe(false);
|
||||||
|
up(100, 550);
|
||||||
|
expect(apply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels on Esc and says so", () => {
|
||||||
|
const { result, apply, announce, begin, move, up } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(160);
|
||||||
|
act(() => result.current.setHover({ kind: "stack", at: 2 }));
|
||||||
|
act(() => void window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
|
||||||
|
expect(result.current.drag).toBeNull();
|
||||||
|
expect(announce).toHaveBeenCalledWith("Ziehen abgebrochen.");
|
||||||
|
up(160);
|
||||||
|
expect(apply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops nothing outside every slot and says so", () => {
|
||||||
|
const { apply, announce, begin, move, up } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(160);
|
||||||
|
up(160);
|
||||||
|
expect(apply).not.toHaveBeenCalled();
|
||||||
|
expect(announce).toHaveBeenCalledWith("Nicht abgelegt.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first press when a second one begins meanwhile (second finger, part below)", () => {
|
||||||
|
const { result, apply, begin, move, up } = setup();
|
||||||
|
begin({ partId: "c" });
|
||||||
|
act(() => result.current.begin({ partId: "b" }, "Zarge", { pointerId: 2, clientX: 300, clientY: 100 }));
|
||||||
|
move(160);
|
||||||
|
expect(result.current.drag?.source).toEqual({ partId: "c" });
|
||||||
|
act(() => result.current.setHover({ kind: "stack", at: 4 }));
|
||||||
|
up(160);
|
||||||
|
expect(apply).toHaveBeenCalledWith([{ op: "movePart", id: "c", to: 4 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels when the window loses focus (Alt+Tab) or the tab is hidden", () => {
|
||||||
|
const first = setup();
|
||||||
|
first.begin({ partTypeId: "box" });
|
||||||
|
first.move(160);
|
||||||
|
act(() => void window.dispatchEvent(new Event("blur")));
|
||||||
|
expect(first.result.current.drag).toBeNull();
|
||||||
|
first.up(160);
|
||||||
|
expect(first.apply).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const second = setup();
|
||||||
|
second.begin({ partTypeId: "box" });
|
||||||
|
second.move(160);
|
||||||
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "hidden" });
|
||||||
|
act(() => void document.dispatchEvent(new Event("visibilitychange")));
|
||||||
|
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
|
||||||
|
expect(second.result.current.drag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends a mouse drag whose button was released outside the window (buttons = 0)", () => {
|
||||||
|
const { result, apply, begin, move } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(160);
|
||||||
|
act(() => result.current.setHover({ kind: "stack", at: 2 }));
|
||||||
|
const released = pointer("pointermove", 170);
|
||||||
|
Object.assign(released, { pointerType: "mouse", buttons: 0 });
|
||||||
|
act(() => void window.dispatchEvent(released));
|
||||||
|
expect(result.current.drag).toBeNull();
|
||||||
|
expect(apply).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells subscribers when the pointer moves (the scene renders only then)", () => {
|
||||||
|
const { result, begin, move } = setup();
|
||||||
|
const listener = vi.fn();
|
||||||
|
let off = () => {};
|
||||||
|
act(() => {
|
||||||
|
off = result.current.subscribePointer(listener);
|
||||||
|
});
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(160);
|
||||||
|
move(170);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
off();
|
||||||
|
move(180);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a hover report for a slot that is not legal", () => {
|
||||||
|
const { result, begin, move } = setup();
|
||||||
|
begin({ partTypeId: "box" });
|
||||||
|
move(160);
|
||||||
|
act(() => result.current.setHover({ kind: "stack", at: 0 }));
|
||||||
|
expect(result.current.drag?.hover).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
|
||||||
|
import { dropOps, dropSlots, sameSlot } from "./drop-slots";
|
||||||
|
import type { DragSource, DropSlot } from "./drop-slots";
|
||||||
|
import type { DraftApplyResult } from "./use-hive-draft";
|
||||||
|
|
||||||
|
// Drag and drop of the editor (spec 9.5, plan Task 18) with Pointer Events,
|
||||||
|
// so mouse, pen and finger behave the same. A press becomes a drag after a
|
||||||
|
// few pixels (a tap stays a tap); the scene reports the slot under the
|
||||||
|
// pointer; release drops there, on „Entfernen“ removes; Esc, leaving the
|
||||||
|
// window (Alt+Tab, hidden tab) or a mouse released outside cancel.
|
||||||
|
// Window listeners, no pointer capture: a finger is captured to the element it
|
||||||
|
// started on anyway, so the scene reads the position from `pointer`.
|
||||||
|
|
||||||
|
/** Pixels before a press turns into a drag. */
|
||||||
|
export const DRAG_START_PX = 6;
|
||||||
|
|
||||||
|
export interface DragPointer {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HiveDragView {
|
||||||
|
source: DragSource;
|
||||||
|
/** Name of the dragged part (preview chip). */
|
||||||
|
label: string;
|
||||||
|
/** Legal drop slots; they glow in the scene. */
|
||||||
|
slots: DropSlot[];
|
||||||
|
/** Slot under the pointer, reported by the scene. */
|
||||||
|
hover: DropSlot | null;
|
||||||
|
/** Over the „Entfernen“ zone (only for parts already in the build). */
|
||||||
|
overRemove: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHiveDragOptions {
|
||||||
|
state: HiveState;
|
||||||
|
catalog: HiveCatalog;
|
||||||
|
/** One draft step; refusals are shown by the caller. */
|
||||||
|
apply: (ops: HiveOp[]) => DraftApplyResult;
|
||||||
|
/** Live region of the editor. */
|
||||||
|
announce: (text: string) => void;
|
||||||
|
removeZone: RefObject<HTMLElement | null>;
|
||||||
|
/** Id for a new part. */
|
||||||
|
newId: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HiveDrag {
|
||||||
|
drag: HiveDragView | null;
|
||||||
|
/** Latest pointer position (client pixels) while pressing or dragging. */
|
||||||
|
pointer: RefObject<DragPointer | null>;
|
||||||
|
begin(source: DragSource, label: string, event: { pointerId: number; clientX: number; clientY: number }): void;
|
||||||
|
setHover(slot: DropSlot | null): void;
|
||||||
|
cancel(): void;
|
||||||
|
/** Called on every pointer move of a press or drag; returns the unsubscribe. */
|
||||||
|
subscribePointer(listener: () => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Press {
|
||||||
|
source: DragSource;
|
||||||
|
label: string;
|
||||||
|
pointerId: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inside(element: HTMLElement | null, x: number, y: number): boolean {
|
||||||
|
if (!element) return false;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHiveDrag(options: UseHiveDragOptions): HiveDrag {
|
||||||
|
const latest = useRef(options);
|
||||||
|
useEffect(() => {
|
||||||
|
latest.current = options;
|
||||||
|
});
|
||||||
|
const [press, setPress] = useState<Press | null>(null);
|
||||||
|
const [drag, setDrag] = useState<HiveDragView | null>(null);
|
||||||
|
const dragRef = useRef<HiveDragView | null>(null);
|
||||||
|
const pointer = useRef<DragPointer | null>(null);
|
||||||
|
const pressRef = useRef<Press | null>(null);
|
||||||
|
const [listeners] = useState(() => new Set<() => void>());
|
||||||
|
const subscribePointer = useCallback(
|
||||||
|
(listener: () => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[listeners],
|
||||||
|
);
|
||||||
|
|
||||||
|
const update = useCallback((next: HiveDragView | null) => {
|
||||||
|
dragRef.current = next;
|
||||||
|
setDrag(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const end = useCallback(() => {
|
||||||
|
update(null);
|
||||||
|
pressRef.current = null;
|
||||||
|
setPress(null);
|
||||||
|
pointer.current = null;
|
||||||
|
}, [update]);
|
||||||
|
|
||||||
|
const begin = useCallback<HiveDrag["begin"]>((source, label, event) => {
|
||||||
|
// One press at a time: a second finger or a farther hit does not take over.
|
||||||
|
if (pressRef.current) return;
|
||||||
|
const next = { source, label, pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
||||||
|
pressRef.current = next;
|
||||||
|
pointer.current = { x: event.clientX, y: event.clientY };
|
||||||
|
setPress(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setHover = useCallback(
|
||||||
|
(slot: DropSlot | null) => {
|
||||||
|
const current = dragRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
const legal = slot && current.slots.some((candidate) => sameSlot(candidate, slot)) ? slot : null;
|
||||||
|
if (sameSlot(current.hover, legal)) return;
|
||||||
|
update({ ...current, hover: legal });
|
||||||
|
},
|
||||||
|
[update],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => end(), [end]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!press) return;
|
||||||
|
const samePointer = (event: Event) => ((event as PointerEvent).pointerId ?? press.pointerId) === press.pointerId;
|
||||||
|
|
||||||
|
const onMove = (event: Event) => {
|
||||||
|
if (!samePointer(event)) return;
|
||||||
|
const { clientX, clientY, pointerType, buttons } = event as PointerEvent;
|
||||||
|
// The mouse button went up outside the window: no drop, the drag ends.
|
||||||
|
if (pointerType === "mouse" && buttons === 0) {
|
||||||
|
abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pointer.current = { x: clientX, y: clientY };
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
const { state, catalog, removeZone } = latest.current;
|
||||||
|
let current = dragRef.current;
|
||||||
|
if (!current) {
|
||||||
|
if (Math.hypot(clientX - press.x, clientY - press.y) < DRAG_START_PX) return;
|
||||||
|
current = {
|
||||||
|
source: press.source,
|
||||||
|
label: press.label,
|
||||||
|
slots: dropSlots(state, catalog, press.source),
|
||||||
|
hover: null,
|
||||||
|
overRemove: false,
|
||||||
|
};
|
||||||
|
update(current);
|
||||||
|
}
|
||||||
|
const overRemove = "partId" in current.source && inside(removeZone.current, clientX, clientY);
|
||||||
|
if (overRemove !== current.overRemove) update({ ...current, overRemove, hover: overRemove ? null : current.hover });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = (event: Event) => {
|
||||||
|
if (!samePointer(event)) return;
|
||||||
|
const current = dragRef.current;
|
||||||
|
end();
|
||||||
|
if (!current) return;
|
||||||
|
const { state, catalog, apply, announce, newId } = latest.current;
|
||||||
|
if (current.overRemove && "partId" in current.source) {
|
||||||
|
apply([{ op: "removePart", id: current.source.partId }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!current.hover) {
|
||||||
|
announce("Nicht abgelegt.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ops = dropOps(state, catalog, current.source, current.hover, newId());
|
||||||
|
if (ops.length > 0) apply(ops);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = (event: Event) => {
|
||||||
|
if (samePointer(event)) end();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ends without dropping; says so when a drag was running. */
|
||||||
|
function abort() {
|
||||||
|
const active = dragRef.current !== null;
|
||||||
|
end();
|
||||||
|
if (active) latest.current.announce("Ziehen abgebrochen.");
|
||||||
|
}
|
||||||
|
const onBlur = () => abort();
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "hidden") abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const active = dragRef.current !== null;
|
||||||
|
end();
|
||||||
|
if (active) {
|
||||||
|
event.preventDefault();
|
||||||
|
latest.current.announce("Ziehen abgebrochen.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
window.addEventListener("pointerup", onUp);
|
||||||
|
window.addEventListener("pointercancel", onCancel);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("blur", onBlur);
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.removeEventListener("pointerup", onUp);
|
||||||
|
window.removeEventListener("pointercancel", onCancel);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [press, end, update, listeners]);
|
||||||
|
|
||||||
|
return { drag, pointer, begin, setHover, cancel, subscribePointer };
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||||
import { feederRange, rangeText } from "@/lib/hive/feeder-slots";
|
import { coveredSlots, feederRange, frameFeederOps, rangeText } from "@/lib/hive/feeder-slots";
|
||||||
import type { CatalogPartType, HiveFrame, HiveOp, HivePart } from "@/lib/hive/types";
|
import type { CatalogPartType, HiveFrame } from "@/lib/hive/types";
|
||||||
import { StateFieldControl } from "./state-field-control";
|
import { StateFieldControl } from "./state-field-control";
|
||||||
import type { FieldDraft } from "./state-field-control";
|
import type { FieldDraft } from "./state-field-control";
|
||||||
|
|
||||||
@@ -25,32 +25,8 @@ function rangeAt(start: number, type: CatalogPartType) {
|
|||||||
return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
|
return feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function coveredSlots(start: number, type: CatalogPartType): number[] {
|
/** Kept for existing imports; lives in lib/hive/feeder-slots. */
|
||||||
const range = rangeAt(start, type);
|
export { frameFeederOps };
|
||||||
if (!range) return [];
|
|
||||||
return Array.from({ length: range.to - range.from + 1 }, (_, index) => range.from + index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Frames of the box with the covered slots set to NONE (names of covered frames are dropped). */
|
|
||||||
function emptiedFrames(frames: HiveFrame[], covered: number[]): HiveFrame[] {
|
|
||||||
return frames.map((frame) => (covered.includes(frame.slot) ? { slot: frame.slot, type: "NONE" } : frame));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ops for the chosen pocket: setFrames only if a covered frame is not empty yet, then addInsert. */
|
|
||||||
export function frameFeederOps(
|
|
||||||
boxId: string,
|
|
||||||
frames: HiveFrame[],
|
|
||||||
type: CatalogPartType,
|
|
||||||
start: number,
|
|
||||||
pocket: HivePart,
|
|
||||||
): HiveOp[] {
|
|
||||||
const covered = coveredSlots(start, type);
|
|
||||||
const needsEmptying = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
|
|
||||||
const ops: HiveOp[] = [];
|
|
||||||
if (needsEmptying) ops.push({ op: "setFrames", boxId, frames: emptiedFrames(frames, covered) });
|
|
||||||
ops.push({ op: "addInsert", hostId: boxId, part: { ...pocket, slot: start } });
|
|
||||||
return ops;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FrameFeederPickerProps {
|
interface FrameFeederPickerProps {
|
||||||
boxId: string;
|
boxId: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { cameraBasis, cameraPosition, fitDistance, focusFrame, insetView } from "./camera-fit";
|
import { ZOOM_MAX, ZOOM_MIN, cameraBasis, cameraPosition, clampZoom, fitDistance, focusFrame, insetView, pinchZoom, stepZoom, wheelZoom } from "./camera-fit";
|
||||||
|
|
||||||
describe("cameraBasis", () => {
|
describe("cameraBasis", () => {
|
||||||
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
|
it("looks from 20° above and 30° to the side, with orthonormal axes", () => {
|
||||||
@@ -79,3 +79,30 @@ describe("cameraPosition", () => {
|
|||||||
expect(z).toBeCloseTo(3 + back[2] * 10, 9);
|
expect(z).toBeCloseTo(3 + back[2] * 10, 9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("editor zoom", () => {
|
||||||
|
it("stays between 0.6 and 2.5", () => {
|
||||||
|
expect(ZOOM_MIN).toBe(0.6);
|
||||||
|
expect(ZOOM_MAX).toBe(2.5);
|
||||||
|
expect(clampZoom(0.1)).toBe(0.6);
|
||||||
|
expect(clampZoom(9)).toBe(2.5);
|
||||||
|
expect(clampZoom(Number.NaN)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zooms in on wheel up and out on wheel down", () => {
|
||||||
|
expect(wheelZoom(1, -100)).toBeGreaterThan(1);
|
||||||
|
expect(wheelZoom(1, 100)).toBeLessThan(1);
|
||||||
|
expect(wheelZoom(2.5, -1000)).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the pinch distance from its start", () => {
|
||||||
|
expect(pinchZoom(1, 100, 200)).toBe(2);
|
||||||
|
expect(pinchZoom(1, 100, 10)).toBe(0.6);
|
||||||
|
expect(pinchZoom(1.5, 0, 50)).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steps for the buttons", () => {
|
||||||
|
expect(stepZoom(1, 1)).toBeCloseTo(1.25, 9);
|
||||||
|
expect(stepZoom(1, -1)).toBeCloseTo(0.8, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -135,3 +135,31 @@ export function cameraPosition(center: Vec3, distance: number, basis: CameraBasi
|
|||||||
center[2] + basis.back[2] * distance,
|
center[2] + basis.back[2] * distance,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Editor zoom (spec 9.5: mouse wheel, two fingers and buttons, 0.6–2.5) ----
|
||||||
|
|
||||||
|
export const ZOOM_MIN = 0.6;
|
||||||
|
export const ZOOM_MAX = 2.5;
|
||||||
|
/** Factor of one press of „Vergrößern“ / „Verkleinern“. */
|
||||||
|
const ZOOM_STEP = 1.25;
|
||||||
|
/** Wheel sensitivity per pixel of deltaY. */
|
||||||
|
const WHEEL_RATE = 0.0015;
|
||||||
|
|
||||||
|
export function clampZoom(zoom: number): number {
|
||||||
|
if (!Number.isFinite(zoom)) return 1;
|
||||||
|
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wheelZoom(zoom: number, deltaY: number): number {
|
||||||
|
return clampZoom(zoom * Math.exp(-deltaY * WHEEL_RATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinchZoom(startZoom: number, startDistance: number, distance: number): number {
|
||||||
|
if (!(startDistance > 0)) return clampZoom(startZoom);
|
||||||
|
return clampZoom((startZoom * distance) / startDistance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One button step: +1 zooms in, -1 out. */
|
||||||
|
export function stepZoom(zoom: number, direction: 1 | -1): number {
|
||||||
|
return clampZoom(direction > 0 ? zoom * ZOOM_STEP : zoom / ZOOM_STEP);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
|
import type { HiveLayout, Vec3 } from "@/lib/hive/layout";
|
||||||
import { FIT_MARGIN, FOCUS_FIT_MARGIN, fitDistance, focusFrame, insetView } from "./camera-fit";
|
import { FIT_MARGIN, FOCUS_FIT_MARGIN, clampZoom, fitDistance, focusFrame, insetView } from "./camera-fit";
|
||||||
import type { OverlayInsets } from "./camera-fit";
|
import type { OverlayInsets } from "./camera-fit";
|
||||||
import { useSceneKit } from "./scene-kit";
|
import { useSceneKit } from "./scene-kit";
|
||||||
import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations";
|
import { offsetView, placeCamera, setFocusPoint } from "./scene-mutations";
|
||||||
@@ -22,9 +22,13 @@ export interface CameraRigProps {
|
|||||||
onPivot: (center: Vec3) => void;
|
onPivot: (center: Vec3) => void;
|
||||||
/** Card pixels covered by the frosted overlays. */
|
/** Card pixels covered by the frosted overlays. */
|
||||||
insets: OverlayInsets;
|
insets: OverlayInsets;
|
||||||
|
/** Gap between stack levels (opened while dragging in the editor). */
|
||||||
|
gap?: number;
|
||||||
|
/** Editor zoom (1 = fitted). */
|
||||||
|
zoom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) {
|
export function CameraRig({ layout, focus, onPivot, insets, gap, zoom = 1 }: CameraRigProps) {
|
||||||
const { motion, signals } = useSceneKit();
|
const { motion, signals } = useSceneKit();
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const width = useThree((state) => state.size.width);
|
const width = useThree((state) => state.size.width);
|
||||||
@@ -34,14 +38,15 @@ export function CameraRig({ layout, focus, onPivot, insets }: CameraRigProps) {
|
|||||||
const goal = useMemo(() => {
|
const goal = useMemo(() => {
|
||||||
const aspect = height > 0 ? width / height : 1;
|
const aspect = height > 0 ? width / height : 1;
|
||||||
const band = insetView(height, insets);
|
const band = insetView(height, insets);
|
||||||
const bounds = restBounds(layout);
|
const bounds = restBounds(layout, gap);
|
||||||
const fit = (size: Vec3, margin: number) => fitDistance(size, aspect, margin, undefined, undefined, band.fraction);
|
const fit = (size: Vec3, margin: number) =>
|
||||||
|
fitDistance(size, aspect, margin, undefined, undefined, band.fraction) / clampZoom(zoom);
|
||||||
if (focus) {
|
if (focus) {
|
||||||
const frame = focusFrame(focus.center, focus.size, bounds.center);
|
const frame = focusFrame(focus.center, focus.size, bounds.center);
|
||||||
return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY };
|
return { center: frame.center, distance: fit(frame.size, FOCUS_FIT_MARGIN), offsetY: band.offsetY };
|
||||||
}
|
}
|
||||||
return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY };
|
return { center: bounds.center, distance: fit(bounds.size, FIT_MARGIN), offsetY: band.offsetY };
|
||||||
}, [focus, layout, width, height, insets]);
|
}, [focus, layout, width, height, insets, gap, zoom]);
|
||||||
|
|
||||||
const current = useRef<{ center: Vec3; distance: number } | null>(null);
|
const current = useRef<{ center: Vec3; distance: number } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { FLOOR_GRID_DEPTH, hiveLayout } from "@/lib/hive/layout";
|
||||||
|
import { inlineCatalog, templateState } from "@/lib/hive/test-utils";
|
||||||
|
import { dropSlots } from "../editor/drop-slots";
|
||||||
|
import { DRAG_GAP, SNAP_DISTANCE, pickAnchor, slotAnchors } from "./drop-anchors";
|
||||||
|
import { restCenter } from "./use-hive-motion";
|
||||||
|
|
||||||
|
const catalog = inlineCatalog();
|
||||||
|
const state = templateState();
|
||||||
|
const layout = hiveLayout(state, catalog);
|
||||||
|
const part = (id: string) => layout.parts.find((candidate) => candidate.id === id)!;
|
||||||
|
const top = (id: string) => restCenter(part(id), DRAG_GAP)[1] + part(id).size[1] / 2;
|
||||||
|
const bottom = (id: string) => restCenter(part(id), DRAG_GAP)[1] - part(id).size[1] / 2;
|
||||||
|
|
||||||
|
describe("slotAnchors", () => {
|
||||||
|
it("puts a stack slot in the middle of the opened gap between two parts", () => {
|
||||||
|
const source = { partTypeId: "box" };
|
||||||
|
const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source));
|
||||||
|
const between = anchors.find((anchor) => anchor.slot.kind === "stack" && anchor.slot.at === 2)!;
|
||||||
|
// Slot 2 = between the box (index 1) and the cover (index 2).
|
||||||
|
expect(between.center[1]).toBeCloseTo((top("b") + bottom("c")) / 2, 9);
|
||||||
|
expect(between.size).toEqual([5.25, 2.3, 4.3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the dragged part itself when it moves", () => {
|
||||||
|
const source = { partId: "c" };
|
||||||
|
const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source));
|
||||||
|
const atThree = anchors.find((anchor) => anchor.slot.kind === "stack" && anchor.slot.at === 3)!;
|
||||||
|
// movePart takes the cover out first: [floor, box, lid, insulation, roof]; index 3 is between lid and insulation.
|
||||||
|
expect(atThree.center[1]).toBeCloseTo((top("l") + bottom("i")) / 2, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts an insert slot where the insert would sit in its opened host", () => {
|
||||||
|
const source = { partTypeId: "floor-insert" };
|
||||||
|
const [anchor] = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source));
|
||||||
|
const floorTop = restCenter(part("f"), DRAG_GAP)[1] + part("f").size[1] / 2;
|
||||||
|
expect(anchor.center[1]).toBeCloseTo(floorTop - FLOOR_GRID_DEPTH + 0.025, 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a frame feeder slot over its covered frames", () => {
|
||||||
|
const source = { partTypeId: "frame-feeder" };
|
||||||
|
const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source));
|
||||||
|
const first = anchors[0];
|
||||||
|
const covered = layout.frames.filter((frame) => frame.boxId === "b" && frame.slot <= 2);
|
||||||
|
expect(first.center[0]).toBeCloseTo((covered[0].center[0] + covered[1].center[0]) / 2, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pickAnchor", () => {
|
||||||
|
const source = { partTypeId: "box" };
|
||||||
|
const anchors = slotAnchors(layout, state, catalog, source, dropSlots(state, catalog, source));
|
||||||
|
const target = anchors.find((anchor) => anchor.slot.kind === "stack" && anchor.slot.at === 2)!;
|
||||||
|
|
||||||
|
it("picks the slot the ray goes through", () => {
|
||||||
|
const picked = pickAnchor(anchors, [0, target.center[1], 20], [0, 0, -1]);
|
||||||
|
expect(picked?.slot).toEqual({ kind: "stack", at: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snaps within 0.5 units of a slot and lets go beyond", () => {
|
||||||
|
const near = pickAnchor(anchors, [5.25 / 2 + SNAP_DISTANCE - 0.05, target.center[1], 20], [0, 0, -1]);
|
||||||
|
expect(near?.slot).toEqual({ kind: "stack", at: 2 });
|
||||||
|
const far = pickAnchor(anchors, [5.25 / 2 + SNAP_DISTANCE + 0.2, target.center[1], 20], [0, 0, -1]);
|
||||||
|
expect(far).toBeNull();
|
||||||
|
expect(SNAP_DISTANCE).toBe(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the nearest slot along the ray", () => {
|
||||||
|
const picked = pickAnchor(anchors, [0, 40, 0], [0, -1, 0]);
|
||||||
|
// From above the first slot hit is the topmost (below the roof).
|
||||||
|
expect(picked?.slot).toEqual({ kind: "stack", at: 5 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { hiveLayout } from "@/lib/hive/layout";
|
||||||
|
import type { HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
||||||
|
import { applyHiveOps } from "@/lib/hive/ops";
|
||||||
|
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||||
|
import { dropOps } from "../editor/drop-slots";
|
||||||
|
import type { DragSource, DropSlot } from "../editor/drop-slots";
|
||||||
|
import { restCenter } from "./use-hive-motion";
|
||||||
|
|
||||||
|
// Where the drop slots sit in the opened stack (model space, the same space
|
||||||
|
// the parts are placed in) and which one the pointer ray points at (plan
|
||||||
|
// Task 18: the preview snaps to the nearest slot within 0.5 units).
|
||||||
|
|
||||||
|
/** While dragging the stack opens to this gap between levels (60 mm, spec 9.5). */
|
||||||
|
export const DRAG_GAP = 0.6;
|
||||||
|
/** A slot catches the pointer this far around it. */
|
||||||
|
export const SNAP_DISTANCE = 0.5;
|
||||||
|
const PREVIEW_ID = "__drag-preview__";
|
||||||
|
|
||||||
|
export interface SlotAnchor {
|
||||||
|
slot: DropSlot;
|
||||||
|
/** Centre of the preview (model space). */
|
||||||
|
center: Vec3;
|
||||||
|
/** Size of the dragged part, for the preview. */
|
||||||
|
size: Vec3;
|
||||||
|
/** Box the pointer ray must pass through (before the snap margin). */
|
||||||
|
hitCenter: Vec3;
|
||||||
|
hitSize: Vec3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = (placed: PartBox): number => restCenter(placed, DRAG_GAP)[1] + placed.size[1] / 2;
|
||||||
|
const bottom = (placed: PartBox): number => restCenter(placed, DRAG_GAP)[1] - placed.size[1] / 2;
|
||||||
|
|
||||||
|
/** Part box of the dragged part in the build after the drop, or null. */
|
||||||
|
function droppedBox(state: HiveState, catalog: HiveCatalog, source: DragSource, slot: DropSlot): { box: PartBox; layout: HiveLayout } | null {
|
||||||
|
const ops = dropOps(state, catalog, source, slot, PREVIEW_ID);
|
||||||
|
const applied = ops.length > 0 ? applyHiveOps(state, ops, catalog) : null;
|
||||||
|
if (!applied?.ok) return null;
|
||||||
|
const layout = hiveLayout(applied.state, catalog);
|
||||||
|
const id = "partId" in source ? source.partId : PREVIEW_ID;
|
||||||
|
const box = [...layout.parts, ...layout.inserts].find((candidate) => candidate.id === id);
|
||||||
|
return box ? { box, layout } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackAnchor(layout: HiveLayout, source: DragSource, slot: Extract<DropSlot, { kind: "stack" }>, size: Vec3): SlotAnchor {
|
||||||
|
const draggedId = "partId" in source ? source.partId : null;
|
||||||
|
const others = layout.parts.filter((placed) => placed.id !== draggedId);
|
||||||
|
const lower = others[slot.at - 1];
|
||||||
|
const upper = others[slot.at];
|
||||||
|
const y =
|
||||||
|
lower && upper
|
||||||
|
? (top(lower) + bottom(upper)) / 2
|
||||||
|
: lower
|
||||||
|
? top(lower) + DRAG_GAP / 2
|
||||||
|
: upper
|
||||||
|
? bottom(upper) - DRAG_GAP / 2
|
||||||
|
: 0;
|
||||||
|
const center: Vec3 = [0, y, 0];
|
||||||
|
return { slot, center, size, hitCenter: center, hitSize: [size[0], DRAG_GAP, size[2]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anchors of the legal slots of a drag, in model space of the opened stack. */
|
||||||
|
export function slotAnchors(
|
||||||
|
layout: HiveLayout,
|
||||||
|
state: HiveState,
|
||||||
|
catalog: HiveCatalog,
|
||||||
|
source: DragSource,
|
||||||
|
slots: DropSlot[],
|
||||||
|
): SlotAnchor[] {
|
||||||
|
return slots.flatMap((slot): SlotAnchor[] => {
|
||||||
|
const dropped = droppedBox(state, catalog, source, slot);
|
||||||
|
if (!dropped) return [];
|
||||||
|
const size = dropped.box.size;
|
||||||
|
if (slot.kind === "stack") return [stackAnchor(layout, source, slot, size)];
|
||||||
|
// An insert sits in its host where the layout puts it, the host at its opened place.
|
||||||
|
const host = layout.parts.find((placed) => placed.id === slot.hostId);
|
||||||
|
const hostAfter = dropped.layout.parts.find((placed) => placed.id === slot.hostId);
|
||||||
|
if (!host || !hostAfter) return [];
|
||||||
|
const opened = restCenter(host, DRAG_GAP);
|
||||||
|
const center: Vec3 = [
|
||||||
|
opened[0] + dropped.box.center[0] - hostAfter.center[0],
|
||||||
|
opened[1] + dropped.box.center[1] - hostAfter.center[1],
|
||||||
|
opened[2] + dropped.box.center[2] - hostAfter.center[2],
|
||||||
|
];
|
||||||
|
return [{ slot, center, size, hitCenter: center, hitSize: size }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry distance of a ray into an axis-aligned box, or null when it misses (slab test). */
|
||||||
|
function rayBoxEntry(origin: Vec3, direction: Vec3, center: Vec3, half: Vec3): number | null {
|
||||||
|
let near = Number.NEGATIVE_INFINITY;
|
||||||
|
let far = Number.POSITIVE_INFINITY;
|
||||||
|
for (let axis = 0; axis < 3; axis += 1) {
|
||||||
|
const min = center[axis] - half[axis];
|
||||||
|
const max = center[axis] + half[axis];
|
||||||
|
if (Math.abs(direction[axis]) < 1e-9) {
|
||||||
|
if (origin[axis] < min || origin[axis] > max) return null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const t1 = (min - origin[axis]) / direction[axis];
|
||||||
|
const t2 = (max - origin[axis]) / direction[axis];
|
||||||
|
near = Math.max(near, Math.min(t1, t2));
|
||||||
|
far = Math.min(far, Math.max(t1, t2));
|
||||||
|
if (near > far) return null;
|
||||||
|
}
|
||||||
|
if (far < 0) return null;
|
||||||
|
return Math.max(near, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot the ray points at (with the snap margin), nearest along the ray; null without one. */
|
||||||
|
export function pickAnchor(anchors: SlotAnchor[], origin: Vec3, direction: Vec3, snap: number = SNAP_DISTANCE): SlotAnchor | null {
|
||||||
|
let best: SlotAnchor | null = null;
|
||||||
|
let bestT = Number.POSITIVE_INFINITY;
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
const half: Vec3 = [anchor.hitSize[0] / 2 + snap, anchor.hitSize[1] / 2 + snap, anchor.hitSize[2] / 2 + snap];
|
||||||
|
const t = rayBoxEntry(origin, direction, anchor.hitCenter, half);
|
||||||
|
if (t !== null && t < bestT) {
|
||||||
|
best = anchor;
|
||||||
|
bestT = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { Raycaster, Vector2, Vector3 } from "three";
|
||||||
|
import type { Group, ShaderMaterial } from "three";
|
||||||
|
import type { Vec3 } from "@/lib/hive/layout";
|
||||||
|
import { sameSlot } from "../editor/drop-slots";
|
||||||
|
import type { DropSlot } from "../editor/drop-slots";
|
||||||
|
import { pickAnchor } from "./drop-anchors";
|
||||||
|
import type { SlotAnchor } from "./drop-anchors";
|
||||||
|
import { clampZoom, pinchZoom, wheelZoom } from "./camera-fit";
|
||||||
|
import { createEdgeMaterial, createGlassMaterial } from "./materials";
|
||||||
|
import { buildEdgeGeometry, buildGlassGeometry } from "./part-geometry";
|
||||||
|
import type { EdgeSegment } from "./part-geometry";
|
||||||
|
import { noRaycast, useSceneKit } from "./scene-kit";
|
||||||
|
import { setPinching, setUniform, setUniformColor } from "./scene-mutations";
|
||||||
|
|
||||||
|
// Editor drop targets (spec 9.5): every legal slot glows, the slot under the
|
||||||
|
// pointer lights up and a glass preview of the dragged part snaps into it.
|
||||||
|
// Sits inside the model rig, so slots turn with the model.
|
||||||
|
|
||||||
|
/** Outline of a box as glow segments (no white-hot corners: these are hints, not parts). */
|
||||||
|
function outline(size: Vec3, strength: number): EdgeSegment[] {
|
||||||
|
const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2];
|
||||||
|
const c = (x: number, y: number, z: number): Vec3 => [x * hx, y * hy, z * hz];
|
||||||
|
const segments: EdgeSegment[] = [];
|
||||||
|
const add = (a: Vec3, b: Vec3) => segments.push({ a, b, hotA: 0, hotB: 0, strength });
|
||||||
|
for (const y of [-1, 1]) {
|
||||||
|
add(c(-1, y, -1), c(1, y, -1));
|
||||||
|
add(c(1, y, -1), c(1, y, 1));
|
||||||
|
add(c(1, y, 1), c(-1, y, 1));
|
||||||
|
add(c(-1, y, 1), c(-1, y, -1));
|
||||||
|
}
|
||||||
|
if (hy > 0.02) for (const [x, z] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) add(c(x, -1, z), c(x, 1, z));
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The glowing place of one slot: a flat frame for a stack gap, a box outline for an insert. */
|
||||||
|
function SlotMarker({ anchor, material }: { anchor: SlotAnchor; material: ShaderMaterial }) {
|
||||||
|
const flat = anchor.slot.kind === "stack";
|
||||||
|
const [mx, my, mz] = flat ? [anchor.size[0], 0.001, anchor.size[2]] : anchor.size;
|
||||||
|
const geometry = useMemo(() => buildEdgeGeometry(outline([mx, my, mz], 1), 0.03), [mx, my, mz]);
|
||||||
|
useEffect(() => () => geometry.dispose(), [geometry]);
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
name="drop-slot"
|
||||||
|
geometry={geometry}
|
||||||
|
material={material}
|
||||||
|
position={flat ? [anchor.hitCenter[0], anchor.hitCenter[1], anchor.hitCenter[2]] : anchor.center}
|
||||||
|
raycast={noRaycast}
|
||||||
|
renderOrder={6}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Translucent glass ghost of the dragged part at the hovered slot. */
|
||||||
|
function Preview({ anchor }: { anchor: SlotAnchor }) {
|
||||||
|
const { theme, env } = useSceneKit();
|
||||||
|
const [sx, sy, sz] = anchor.size;
|
||||||
|
const geometry = useMemo(
|
||||||
|
() => buildGlassGeometry([{ size: [sx, sy, sz], at: [0, 0, 0], radius: 0.03 }]),
|
||||||
|
[sx, sy, sz],
|
||||||
|
);
|
||||||
|
const edges = useMemo(() => buildEdgeGeometry(outline([sx, sy, sz], 1.2)), [sx, sy, sz]);
|
||||||
|
const glass = useMemo(() => createGlassMaterial(theme, { frost: 0.75, env }), [theme, env]);
|
||||||
|
const glow = useMemo(() => createEdgeMaterial(theme), [theme]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
geometry.dispose();
|
||||||
|
edges.dispose();
|
||||||
|
glass.dispose();
|
||||||
|
glow.dispose();
|
||||||
|
},
|
||||||
|
[geometry, edges, glass, glow],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
// Brighter than the parts around it, so the snapped place reads at once.
|
||||||
|
setUniform(glass, "uOpacity", 0.9);
|
||||||
|
setUniform(glass, "uGlow", 2.2);
|
||||||
|
setUniform(glow, "uIntensity", 3);
|
||||||
|
setUniformColor(glow, "uCyan", theme.edgeStrong);
|
||||||
|
}, [glass, glow, theme]);
|
||||||
|
return (
|
||||||
|
<group position={anchor.center}>
|
||||||
|
<mesh geometry={geometry} material={glass} raycast={noRaycast} renderOrder={7} />
|
||||||
|
<mesh geometry={edges} material={glow} raycast={noRaycast} renderOrder={8} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropTargetsProps {
|
||||||
|
anchors: SlotAnchor[];
|
||||||
|
hover: DropSlot | null;
|
||||||
|
pointer: RefObject<{ x: number; y: number } | null>;
|
||||||
|
/** Called on every pointer move; returns the unsubscribe. */
|
||||||
|
subscribePointer: (listener: () => void) => () => void;
|
||||||
|
onHover: (slot: DropSlot | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slots, hover and preview; picks the slot under the pointer on each rendered frame. */
|
||||||
|
export function DropTargets({ anchors, hover, pointer, subscribePointer, onHover }: DropTargetsProps) {
|
||||||
|
const { theme } = useSceneKit();
|
||||||
|
const group = useRef<Group>(null);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
|
const size = useThree((state) => state.size);
|
||||||
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
|
// Reused every frame (no allocations while dragging).
|
||||||
|
const scratch = useMemo(
|
||||||
|
() => ({ raycaster: new Raycaster(), ndc: new Vector2(), origin: new Vector3(), ahead: new Vector3() }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const reported = useRef<DropSlot | null>(hover);
|
||||||
|
const hovered = anchors.find((anchor) => sameSlot(anchor.slot, hover)) ?? null;
|
||||||
|
|
||||||
|
// Two shared materials for every marker: idle and the one under the pointer.
|
||||||
|
const materials = useMemo(() => ({ idle: createEdgeMaterial(theme), active: createEdgeMaterial(theme) }), [theme]);
|
||||||
|
useEffect(() => {
|
||||||
|
setUniform(materials.idle, "uIntensity", 1.4);
|
||||||
|
setUniform(materials.active, "uIntensity", 3.2);
|
||||||
|
return () => {
|
||||||
|
materials.idle.dispose();
|
||||||
|
materials.active.dispose();
|
||||||
|
};
|
||||||
|
}, [materials]);
|
||||||
|
|
||||||
|
// The canvas rect only changes with the canvas size (the editor does not scroll).
|
||||||
|
const rect = useRef<DOMRect | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const element = gl.domElement as HTMLElement | undefined;
|
||||||
|
rect.current = element?.getBoundingClientRect ? element.getBoundingClientRect() : null;
|
||||||
|
}, [gl, size.width, size.height]);
|
||||||
|
|
||||||
|
// Render only when the pointer moved; flights and the opening stack request their own frames.
|
||||||
|
useEffect(() => subscribePointer(() => invalidate()), [subscribePointer, invalidate]);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const node = group.current;
|
||||||
|
const at = pointer.current;
|
||||||
|
const box = rect.current;
|
||||||
|
if (!node || !at || !box) return;
|
||||||
|
const inCanvas = at.x >= box.left && at.x <= box.right && at.y >= box.top && at.y <= box.bottom;
|
||||||
|
let slot: DropSlot | null = null;
|
||||||
|
if (inCanvas) {
|
||||||
|
const { raycaster, ndc, origin, ahead } = scratch;
|
||||||
|
ndc.set(((at.x - box.left) / box.width) * 2 - 1, -((at.y - box.top) / box.height) * 2 + 1);
|
||||||
|
raycaster.setFromCamera(ndc, camera);
|
||||||
|
// Into model space: the slots live inside the turning, floating rig.
|
||||||
|
node.worldToLocal(origin.copy(raycaster.ray.origin));
|
||||||
|
node.worldToLocal(ahead.copy(raycaster.ray.origin).add(raycaster.ray.direction));
|
||||||
|
ahead.sub(origin);
|
||||||
|
slot = pickAnchor(anchors, [origin.x, origin.y, origin.z], [ahead.x, ahead.y, ahead.z])?.slot ?? null;
|
||||||
|
}
|
||||||
|
if (!sameSlot(slot, reported.current)) {
|
||||||
|
reported.current = slot;
|
||||||
|
onHover(slot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={group}>
|
||||||
|
{anchors.map((anchor, index) => (
|
||||||
|
<SlotMarker
|
||||||
|
key={index}
|
||||||
|
anchor={anchor}
|
||||||
|
material={sameSlot(anchor.slot, hover) ? materials.active : materials.idle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hovered ? <Preview anchor={hovered} /> : null}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wheel and two-finger pinch zoom on the canvas (editor only, 0.6 … 2.5). */
|
||||||
|
export function EditorZoom({ zoom, onZoom }: { zoom: number; onZoom: (zoom: number) => void }) {
|
||||||
|
const gl = useThree((state) => state.gl);
|
||||||
|
const { signals } = useSceneKit();
|
||||||
|
const latest = useRef({ zoom, onZoom });
|
||||||
|
useEffect(() => {
|
||||||
|
latest.current = { zoom, onZoom };
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = gl.domElement as HTMLElement | undefined;
|
||||||
|
if (!element || typeof element.addEventListener !== "function") return;
|
||||||
|
const touches = new Map<number, { x: number; y: number }>();
|
||||||
|
let pinch: { distance: number; zoom: number } | null = null;
|
||||||
|
const distance = () => {
|
||||||
|
const [a, b] = [...touches.values()];
|
||||||
|
return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
|
||||||
|
};
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
latest.current.onZoom(wheelZoom(latest.current.zoom, event.deltaY));
|
||||||
|
};
|
||||||
|
const onDown = (event: PointerEvent) => {
|
||||||
|
if (event.pointerType !== "touch") return;
|
||||||
|
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
if (touches.size === 2) {
|
||||||
|
pinch = { distance: distance(), zoom: clampZoom(latest.current.zoom) };
|
||||||
|
setPinching(signals, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
if (!touches.has(event.pointerId)) return;
|
||||||
|
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||||
|
if (pinch && touches.size === 2) latest.current.onZoom(pinchZoom(pinch.zoom, pinch.distance, distance()));
|
||||||
|
};
|
||||||
|
const onUp = (event: PointerEvent) => {
|
||||||
|
touches.delete(event.pointerId);
|
||||||
|
if (touches.size < 2) {
|
||||||
|
pinch = null;
|
||||||
|
setPinching(signals, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
element.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
element.addEventListener("pointerdown", onDown);
|
||||||
|
element.addEventListener("pointermove", onMove);
|
||||||
|
element.addEventListener("pointerup", onUp);
|
||||||
|
element.addEventListener("pointercancel", onUp);
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("wheel", onWheel);
|
||||||
|
element.removeEventListener("pointerdown", onDown);
|
||||||
|
element.removeEventListener("pointermove", onMove);
|
||||||
|
element.removeEventListener("pointerup", onUp);
|
||||||
|
element.removeEventListener("pointercancel", onUp);
|
||||||
|
setPinching(signals, false);
|
||||||
|
};
|
||||||
|
}, [gl, signals]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { OverlayInsets } from "./camera-fit";
|
|||||||
import { HiveSceneContent } from "./hive-scene-content";
|
import { HiveSceneContent } from "./hive-scene-content";
|
||||||
import { HiveStage, detectStageTier } from "./hive-stage";
|
import { HiveStage, detectStageTier } from "./hive-stage";
|
||||||
import type { QualityVerdict } from "./quality";
|
import type { QualityVerdict } from "./quality";
|
||||||
|
import type { SceneEditor } from "./scene-editor";
|
||||||
import { SceneKitProvider } from "./scene-kit";
|
import { SceneKitProvider } from "./scene-kit";
|
||||||
import { shouldIdle } from "./use-hive-motion";
|
import { shouldIdle } from "./use-hive-motion";
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ export interface HiveCanvasProps {
|
|||||||
onContextLost: () => void;
|
onContextLost: () => void;
|
||||||
/** Card pixels covered by the frosted overlays (title/focus bar, buttons). */
|
/** Card pixels covered by the frosted overlays (title/focus bar, buttons). */
|
||||||
insets?: OverlayInsets;
|
insets?: OverlayInsets;
|
||||||
|
/** Editor route: drag and drop and zoom. */
|
||||||
|
editor?: SceneEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
|
/** Card visible in the viewport and tab in the foreground (idle motion pauses otherwise). */
|
||||||
@@ -66,6 +69,7 @@ export default function HiveCanvas({
|
|||||||
onReady,
|
onReady,
|
||||||
onContextLost,
|
onContextLost,
|
||||||
insets,
|
insets,
|
||||||
|
editor,
|
||||||
}: HiveCanvasProps) {
|
}: HiveCanvasProps) {
|
||||||
const motion = useEffectiveMotionLevel();
|
const motion = useEffectiveMotionLevel();
|
||||||
// Only acts on the view (empty click); the scene content subscribes itself.
|
// Only acts on the view (empty click); the scene content subscribes itself.
|
||||||
@@ -95,8 +99,9 @@ export default function HiveCanvas({
|
|||||||
frameloop="demand"
|
frameloop="demand"
|
||||||
dpr={[1, 2]}
|
dpr={[1, 2]}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
// Vertical swipes scroll the page; horizontal drags turn the model.
|
// Card: vertical swipes scroll the page, horizontal drags turn the model.
|
||||||
style={{ touchAction: "pan-y" }}
|
// Editor: the canvas owns every gesture (drag parts, turn, pinch zoom).
|
||||||
|
style={{ touchAction: editor ? "none" : "pan-y" }}
|
||||||
camera={{ fov: CAMERA_FOV_DEG, near: 0.1, far: 200, position: [8, 6, 14] }}
|
camera={{ fov: CAMERA_FOV_DEG, near: 0.1, far: 200, position: [8, 6, 14] }}
|
||||||
gl={{ antialias: false, stencil: false, powerPreference: "high-performance" }}
|
gl={{ antialias: false, stencil: false, powerPreference: "high-performance" }}
|
||||||
onPointerMissed={() => {
|
onPointerMissed={() => {
|
||||||
@@ -130,6 +135,7 @@ export default function HiveCanvas({
|
|||||||
idle={idle}
|
idle={idle}
|
||||||
onScreen={visible && tabVisible}
|
onScreen={visible && tabVisible}
|
||||||
insets={insets}
|
insets={insets}
|
||||||
|
editor={editor}
|
||||||
/>
|
/>
|
||||||
</SceneKitProvider>
|
</SceneKitProvider>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|||||||
@@ -10,12 +10,27 @@ import type { HiveState } from "@/lib/hive/types";
|
|||||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||||
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
import { createHiveViewStore, HiveViewProvider } from "../use-hive-view";
|
||||||
import type { HiveViewStore } from "../use-hive-view";
|
import type { HiveViewStore } from "../use-hive-view";
|
||||||
|
import { dropSlots } from "../editor/drop-slots";
|
||||||
|
import type { DropSlot } from "../editor/drop-slots";
|
||||||
import { HiveSceneContent } from "./hive-scene-content";
|
import { HiveSceneContent } from "./hive-scene-content";
|
||||||
|
import type { SceneEditor } from "./scene-editor";
|
||||||
import { IDLE_FLOAT, ModelRig } from "./model-rig";
|
import { IDLE_FLOAT, ModelRig } from "./model-rig";
|
||||||
import { SceneKitProvider } from "./scene-kit";
|
import { SceneKitProvider } from "./scene-kit";
|
||||||
|
|
||||||
// The scene content runs on the r3f test renderer (no WebGL). Labels are DOM
|
// The scene content runs on the r3f test renderer (no WebGL). Labels are DOM
|
||||||
// portals (drei Html), so they are replaced by a marker here.
|
// portals (drei Html), so they are replaced by a marker here.
|
||||||
|
const anchorCalls = vi.hoisted(() => ({ count: 0 }));
|
||||||
|
vi.mock("./drop-anchors", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("./drop-anchors")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
slotAnchors: (...args: Parameters<typeof actual.slotAnchors>) => {
|
||||||
|
anchorCalls.count += 1;
|
||||||
|
return actual.slotAnchors(...args);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./hive-labels", () => ({
|
vi.mock("./hive-labels", () => ({
|
||||||
HoloLabel: ({ text }: { text: string }) => <group name={`label:${text}`} />,
|
HoloLabel: ({ text }: { text: string }) => <group name={`label:${text}`} />,
|
||||||
}));
|
}));
|
||||||
@@ -48,7 +63,7 @@ interface Rendered {
|
|||||||
|
|
||||||
let current: Rendered | null = null;
|
let current: Rendered | null = null;
|
||||||
|
|
||||||
async function renderScene(motion: MotionLevel = "full"): Promise<Rendered> {
|
async function renderScene(motion: MotionLevel = "full", editor?: SceneEditor): Promise<Rendered> {
|
||||||
const state = hiveState();
|
const state = hiveState();
|
||||||
const layout = hiveLayout(state, catalog);
|
const layout = hiveLayout(state, catalog);
|
||||||
const store = createHiveViewStore();
|
const store = createHiveViewStore();
|
||||||
@@ -65,6 +80,7 @@ async function renderScene(motion: MotionLevel = "full"): Promise<Rendered> {
|
|||||||
onSelectPart={onSelectPart}
|
onSelectPart={onSelectPart}
|
||||||
onSelectFrame={onSelectFrame}
|
onSelectFrame={onSelectFrame}
|
||||||
idle={false}
|
idle={false}
|
||||||
|
editor={editor}
|
||||||
/>
|
/>
|
||||||
</SceneKitProvider>
|
</SceneKitProvider>
|
||||||
</HiveViewProvider>,
|
</HiveViewProvider>,
|
||||||
@@ -122,6 +138,32 @@ describe("HiveSceneContent", () => {
|
|||||||
expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before);
|
expect((hint() as unknown as { geometry: unknown }).geometry).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a very thin part a hit box tall enough to grab (at least 12 mm)", async () => {
|
||||||
|
const thin: HiveState = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("c", "cover"), part("r", "roof")],
|
||||||
|
};
|
||||||
|
const layout = hiveLayout(thin, catalog);
|
||||||
|
const renderer = await ReactThreeTestRenderer.create(
|
||||||
|
<HiveViewProvider store={createHiveViewStore()}>
|
||||||
|
<SceneKitProvider motion="off">
|
||||||
|
<HiveSceneContent
|
||||||
|
layout={layout}
|
||||||
|
state={thin}
|
||||||
|
catalog={catalog}
|
||||||
|
partName={(id) => id}
|
||||||
|
onSelectPart={vi.fn()}
|
||||||
|
onSelectFrame={vi.fn()}
|
||||||
|
idle={false}
|
||||||
|
/>
|
||||||
|
</SceneKitProvider>
|
||||||
|
</HiveViewProvider>,
|
||||||
|
);
|
||||||
|
const cover = renderer.scene.find((node) => node.props.userData?.hit === "c");
|
||||||
|
expect(cover.props.scale[1]).toBeGreaterThanOrEqual(0.12);
|
||||||
|
await renderer.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
|
it("reports pointer over a part as hover to the shared store and clears it on leave", async () => {
|
||||||
const { renderer, store } = await renderScene();
|
const { renderer, store } = await renderScene();
|
||||||
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
|
await renderer.fireEvent(hit(renderer, "b1"), "onPointerOver", { pointerType: "mouse" });
|
||||||
@@ -222,6 +264,92 @@ describe("HiveSceneContent", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("HiveSceneContent in the editor", () => {
|
||||||
|
const editorOf = (extra: Partial<SceneEditor> = {}): SceneEditor => ({
|
||||||
|
drag: null,
|
||||||
|
pointer: { current: null },
|
||||||
|
subscribePointer: () => () => {},
|
||||||
|
onHoverSlot: vi.fn(),
|
||||||
|
onPartPress: vi.fn(),
|
||||||
|
zoom: 1,
|
||||||
|
onZoom: vi.fn(),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a press on a part, so it can become a drag, and stops it there (the nearest hit wins)", async () => {
|
||||||
|
const editor = editorOf();
|
||||||
|
const { renderer } = await renderScene("full", editor);
|
||||||
|
const stopPropagation = vi.fn();
|
||||||
|
await renderer.fireEvent(hit(renderer, "fe"), "onPointerDown", {
|
||||||
|
pointerType: "mouse",
|
||||||
|
pointerId: 3,
|
||||||
|
clientX: 10,
|
||||||
|
clientY: 20,
|
||||||
|
stopPropagation,
|
||||||
|
});
|
||||||
|
expect(editor.onPartPress).toHaveBeenCalledWith("fe", { pointerId: 3, clientX: 10, clientY: 20 });
|
||||||
|
expect(stopPropagation).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows one glowing marker per legal slot while dragging and opens the stack", async () => {
|
||||||
|
const rest = await renderScene("off");
|
||||||
|
await rest.renderer.advanceFrames(1, 1 / 60);
|
||||||
|
const restY = hit(rest.renderer, "fe").parent!.instance.position.y;
|
||||||
|
await rest.renderer.unmount();
|
||||||
|
current = null;
|
||||||
|
|
||||||
|
const state = hiveState();
|
||||||
|
const slots = dropSlots(state, catalog, { partTypeId: "box" });
|
||||||
|
const { renderer } = await renderScene("off", editorOf({ drag: { source: { partTypeId: "box" }, slots, hover: null } }));
|
||||||
|
await renderer.advanceFrames(1, 1 / 60);
|
||||||
|
expect(renderer.scene.findAll((node) => node.props.name === "drop-slot")).toHaveLength(slots.length);
|
||||||
|
// The feeder (stack index 2) rises by 2 × (0.6 − 0.14) with the opened gaps.
|
||||||
|
expect(hit(renderer, "fe").parent!.instance.position.y - restY).toBeCloseTo(2 * (0.6 - 0.14), 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HiveSceneContent drop anchors", () => {
|
||||||
|
it("computes the slot anchors once per drag, not again on every hover change", async () => {
|
||||||
|
const state = hiveState();
|
||||||
|
const layout = hiveLayout(state, catalog);
|
||||||
|
const store = createHiveViewStore();
|
||||||
|
const source = { partTypeId: "box" };
|
||||||
|
const slots = dropSlots(state, catalog, source);
|
||||||
|
const editor = (hover: DropSlot | null): SceneEditor => ({
|
||||||
|
drag: { source, slots, hover },
|
||||||
|
pointer: { current: null },
|
||||||
|
subscribePointer: () => () => {},
|
||||||
|
onHoverSlot: vi.fn(),
|
||||||
|
onPartPress: vi.fn(),
|
||||||
|
zoom: 1,
|
||||||
|
onZoom: vi.fn(),
|
||||||
|
});
|
||||||
|
const element = (current: SceneEditor) => (
|
||||||
|
<HiveViewProvider store={store}>
|
||||||
|
<SceneKitProvider motion="off">
|
||||||
|
<HiveSceneContent
|
||||||
|
layout={layout}
|
||||||
|
state={state}
|
||||||
|
catalog={catalog}
|
||||||
|
partName={(id) => partDisplayName(state, catalog, id)}
|
||||||
|
onSelectPart={vi.fn()}
|
||||||
|
onSelectFrame={vi.fn()}
|
||||||
|
idle={false}
|
||||||
|
editor={current}
|
||||||
|
/>
|
||||||
|
</SceneKitProvider>
|
||||||
|
</HiveViewProvider>
|
||||||
|
);
|
||||||
|
anchorCalls.count = 0;
|
||||||
|
const renderer = await ReactThreeTestRenderer.create(element(editor(null)));
|
||||||
|
const afterMount = anchorCalls.count;
|
||||||
|
await renderer.update(element(editor(slots[0])));
|
||||||
|
await renderer.update(element(editor(slots[1])));
|
||||||
|
expect(anchorCalls.count).toBe(afterMount);
|
||||||
|
await renderer.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ModelRig", () => {
|
describe("ModelRig", () => {
|
||||||
async function renderRig(motion: MotionLevel, idle: boolean) {
|
async function renderRig(motion: MotionLevel, idle: boolean) {
|
||||||
const pivot: Vec3 = [0, 1, 0];
|
const pivot: Vec3 = [0, 1, 0];
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import { focusedIdOf, useHiveView } from "../use-hive-view";
|
|||||||
import type { HoveredFrame } from "../use-hive-view";
|
import type { HoveredFrame } from "../use-hive-view";
|
||||||
import type { OverlayInsets } from "./camera-fit";
|
import type { OverlayInsets } from "./camera-fit";
|
||||||
import { CameraRig } from "./camera-rig";
|
import { CameraRig } from "./camera-rig";
|
||||||
|
import { DRAG_GAP, slotAnchors } from "./drop-anchors";
|
||||||
|
import { DropTargets, EditorZoom } from "./drop-targets";
|
||||||
|
import { useSceneKit } from "./scene-kit";
|
||||||
|
import type { SceneEditor } from "./scene-editor";
|
||||||
import { FramePlate } from "./frame-plate";
|
import { FramePlate } from "./frame-plate";
|
||||||
import { ModelRig } from "./model-rig";
|
import { ModelRig } from "./model-rig";
|
||||||
import { buildSceneModel, sceneLabels } from "./scene-model";
|
import { buildSceneModel, sceneLabels } from "./scene-model";
|
||||||
import type { SceneLabel } from "./scene-model";
|
import type { SceneLabel } from "./scene-model";
|
||||||
import { setCanvasCursor } from "./scene-mutations";
|
import { markPartPress, setCanvasCursor } from "./scene-mutations";
|
||||||
import { StackPart } from "./stack-part";
|
import { StackPart } from "./stack-part";
|
||||||
import type { NodeHandlers } from "./stack-part";
|
import type { NodeHandlers } from "./stack-part";
|
||||||
import { frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion";
|
import { REST_GAP, frameKey, hiveMotionTargets, restBounds } from "./use-hive-motion";
|
||||||
import type { HiveMotionTargets } from "./use-hive-motion";
|
import type { HiveMotionTargets } from "./use-hive-motion";
|
||||||
import { useTouchSelect } from "./use-touch-select";
|
import { useTouchSelect } from "./use-touch-select";
|
||||||
|
|
||||||
@@ -42,8 +46,13 @@ export interface HiveSceneContentProps {
|
|||||||
onScreen?: boolean;
|
onScreen?: boolean;
|
||||||
/** Card pixels covered by the frosted overlays; the camera fits the model between them. */
|
/** Card pixels covered by the frosted overlays; the camera fits the model between them. */
|
||||||
insets?: OverlayInsets;
|
insets?: OverlayInsets;
|
||||||
|
/** Editor route only: drag and drop and zoom (spec 9.5). */
|
||||||
|
editor?: SceneEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The part being moved stays at its place, dimmed, while the stack opens around it. */
|
||||||
|
const DRAGGED_OPACITY = 0.35;
|
||||||
|
|
||||||
const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 };
|
const NO_INSETS: OverlayInsets = { top: 0, bottom: 0 };
|
||||||
|
|
||||||
const partKey = (id: string): string => `part:${id}`;
|
const partKey = (id: string): string => `part:${id}`;
|
||||||
@@ -82,8 +91,10 @@ export function HiveSceneContent({
|
|||||||
idle,
|
idle,
|
||||||
onScreen = false,
|
onScreen = false,
|
||||||
insets = NO_INSETS,
|
insets = NO_INSETS,
|
||||||
|
editor,
|
||||||
}: HiveSceneContentProps) {
|
}: HiveSceneContentProps) {
|
||||||
const view = useHiveView();
|
const view = useHiveView();
|
||||||
|
const { signals } = useSceneKit();
|
||||||
const focusedId = focusedIdOf(view);
|
const focusedId = focusedIdOf(view);
|
||||||
const gl = useThree((root) => root.gl);
|
const gl = useThree((root) => root.gl);
|
||||||
|
|
||||||
@@ -97,7 +108,24 @@ export function HiveSceneContent({
|
|||||||
|
|
||||||
const model = useMemo(() => buildSceneModel(layout, state, catalog, partName), [layout, state, catalog, partName]);
|
const model = useMemo(() => buildSceneModel(layout, state, catalog, partName), [layout, state, catalog, partName]);
|
||||||
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
|
const motionView = useMemo(() => ({ ...view, selectedFrame }), [view, selectedFrame]);
|
||||||
const targets: HiveMotionTargets = useMemo(() => hiveMotionTargets(layout, motionView), [layout, motionView]);
|
const drag = editor?.drag ?? null;
|
||||||
|
const gap = drag ? DRAG_GAP : REST_GAP;
|
||||||
|
const targets: HiveMotionTargets = useMemo(() => {
|
||||||
|
const computed = hiveMotionTargets(layout, motionView, gap);
|
||||||
|
const movedId = drag && "partId" in drag.source ? drag.source.partId : null;
|
||||||
|
const moved = movedId ? computed.nodes.get(movedId) : undefined;
|
||||||
|
if (!movedId || !moved) return computed;
|
||||||
|
const nodes = new Map(computed.nodes);
|
||||||
|
nodes.set(movedId, { ...moved, opacity: Math.min(moved.opacity, DRAGGED_OPACITY), interactive: false });
|
||||||
|
return { ...computed, nodes };
|
||||||
|
}, [layout, motionView, gap, drag]);
|
||||||
|
// Once per drag: the hover changes the drag object, not its source or its slots.
|
||||||
|
const dragSource = drag?.source ?? null;
|
||||||
|
const dragSlots = drag?.slots ?? null;
|
||||||
|
const anchors = useMemo(
|
||||||
|
() => (dragSource && dragSlots ? slotAnchors(layout, state, catalog, dragSource, dragSlots) : []),
|
||||||
|
[dragSource, dragSlots, layout, state, catalog],
|
||||||
|
);
|
||||||
const labels = useMemo(() => sceneLabels(model, layout, motionView), [model, layout, motionView]);
|
const labels = useMemo(() => sceneLabels(model, layout, motionView), [model, layout, motionView]);
|
||||||
|
|
||||||
const setCursor = (pointer: boolean) => setCanvasCursor(gl, pointer);
|
const setCursor = (pointer: boolean) => setCanvasCursor(gl, pointer);
|
||||||
@@ -151,7 +179,7 @@ export function HiveSceneContent({
|
|||||||
const handlersFor = (key: string): NodeHandlers => ({
|
const handlersFor = (key: string): NodeHandlers => ({
|
||||||
onPointerOver(event) {
|
onPointerOver(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.pointerType === "touch") return;
|
if (event.pointerType === "touch" || drag) return;
|
||||||
hoverKey(key);
|
hoverKey(key);
|
||||||
setCursor(true);
|
setCursor(true);
|
||||||
},
|
},
|
||||||
@@ -170,6 +198,15 @@ export function HiveSceneContent({
|
|||||||
},
|
},
|
||||||
onPointerDown(event) {
|
onPointerDown(event) {
|
||||||
pointerType.current = event.pointerType ?? "mouse";
|
pointerType.current = event.pointerType ?? "mouse";
|
||||||
|
const parsed = parseKey(key);
|
||||||
|
if (editor && parsed?.kind === "part") {
|
||||||
|
// Only the nearest hit takes the press (inserts and frames inside a box, thin parts on top).
|
||||||
|
event.stopPropagation();
|
||||||
|
if (signals.pinching) return;
|
||||||
|
// A press on a part may become a part drag: the rig must not turn the model for it.
|
||||||
|
markPartPress(signals, true);
|
||||||
|
editor.onPartPress(parsed.id, { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onClick(event) {
|
onClick(event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -193,7 +230,15 @@ export function HiveSceneContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CameraRig layout={layout} focus={targets.focus} onPivot={setPivot} insets={insets} />
|
<CameraRig
|
||||||
|
layout={layout}
|
||||||
|
focus={targets.focus}
|
||||||
|
onPivot={setPivot}
|
||||||
|
insets={insets}
|
||||||
|
gap={gap}
|
||||||
|
zoom={editor?.zoom ?? 1}
|
||||||
|
/>
|
||||||
|
{editor ? <EditorZoom zoom={editor.zoom} onZoom={editor.onZoom} /> : null}
|
||||||
<ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
|
<ModelRig getPivot={getPivot} idle={idle} onScreen={onScreen}>
|
||||||
{layout.parts.map((placed) => {
|
{layout.parts.map((placed) => {
|
||||||
const part = model.get(placed.id);
|
const part = model.get(placed.id);
|
||||||
@@ -244,6 +289,15 @@ export function HiveSceneContent({
|
|||||||
</StackPart>
|
</StackPart>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{drag && editor ? (
|
||||||
|
<DropTargets
|
||||||
|
anchors={anchors}
|
||||||
|
hover={drag.hover}
|
||||||
|
pointer={editor.pointer}
|
||||||
|
subscribePointer={editor.subscribePointer}
|
||||||
|
onHover={editor.onHoverSlot}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</ModelRig>
|
</ModelRig>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import type { MutableRefObject, ReactNode } from "react";
|
|||||||
import { useFrame, useThree } from "@react-three/fiber";
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
import type { Group } from "three";
|
import type { Group } from "three";
|
||||||
import type { Vec3 } from "@/lib/hive/layout";
|
import type { Vec3 } from "@/lib/hive/layout";
|
||||||
import { createRigPointer } from "./rig-pointer";
|
import { attachRigPointer } from "./rig-pointer";
|
||||||
import type { RigInput } from "./rig-pointer";
|
import type { RigInput } from "./rig-pointer";
|
||||||
import { useSceneKit } from "./scene-kit";
|
import { useSceneKit } from "./scene-kit";
|
||||||
import { capturePointer, markTimerFrame, releasePointer } from "./scene-mutations";
|
import { markPartPress, markTimerFrame } from "./scene-mutations";
|
||||||
import { MAX_FRAME_SECONDS } from "./use-animated-node";
|
import { MAX_FRAME_SECONDS } from "./use-animated-node";
|
||||||
import { stepToward } from "./use-hive-motion";
|
import { stepToward } from "./use-hive-motion";
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ export const IDLE_PERIOD_SECONDS = 6;
|
|||||||
export const IDLE_FRAME_MS = 33;
|
export const IDLE_FRAME_MS = 33;
|
||||||
|
|
||||||
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
|
function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
|
||||||
|
const { signals } = useSceneKit();
|
||||||
const gl = useThree((state) => state.gl);
|
const gl = useThree((state) => state.gl);
|
||||||
const invalidate = useThree((state) => state.invalidate);
|
const invalidate = useThree((state) => state.invalidate);
|
||||||
const input = useRef<RigInput>({ turn: 0, tiltX: 0, tiltY: 0, dragging: false });
|
const input = useRef<RigInput>({ turn: 0, tiltX: 0, tiltY: 0, dragging: false });
|
||||||
@@ -32,34 +33,15 @@ function useRigInput(enabledTilt: boolean): MutableRefObject<RigInput> {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const element = gl.domElement as HTMLElement | undefined;
|
const element = gl.domElement as HTMLElement | undefined;
|
||||||
if (!element || typeof element.addEventListener !== "function") return;
|
if (!element || typeof element.addEventListener !== "function") return;
|
||||||
const pointer = createRigPointer(input.current, {
|
return attachRigPointer(element, input.current, {
|
||||||
tilt: enabledTilt,
|
tilt: enabledTilt,
|
||||||
rect: () => element.getBoundingClientRect(),
|
|
||||||
// Captured while dragging, so a release outside the canvas still ends the drag.
|
|
||||||
capture: (id) => capturePointer(element, id),
|
|
||||||
release: (id) => releasePointer(element, id),
|
|
||||||
changed: invalidate,
|
changed: invalidate,
|
||||||
|
// Editor: a press on a part may become a part drag, so it never turns the model.
|
||||||
|
canTurn: () => !signals.partPress,
|
||||||
|
pressEnded: () => markPartPress(signals, false),
|
||||||
|
paused: () => signals.pinching,
|
||||||
});
|
});
|
||||||
const onDown = (event: PointerEvent) => pointer.down(event);
|
}, [gl, invalidate, enabledTilt, signals]);
|
||||||
const onMove = (event: PointerEvent) => pointer.move(event);
|
|
||||||
const onUp = (event: PointerEvent) => pointer.up(event);
|
|
||||||
const onLeave = () => pointer.leave();
|
|
||||||
element.addEventListener("pointerdown", onDown);
|
|
||||||
element.addEventListener("pointermove", onMove);
|
|
||||||
element.addEventListener("pointerup", onUp);
|
|
||||||
element.addEventListener("pointercancel", onUp);
|
|
||||||
element.addEventListener("lostpointercapture", onUp);
|
|
||||||
element.addEventListener("pointerleave", onLeave);
|
|
||||||
return () => {
|
|
||||||
pointer.dispose();
|
|
||||||
element.removeEventListener("pointerdown", onDown);
|
|
||||||
element.removeEventListener("pointermove", onMove);
|
|
||||||
element.removeEventListener("pointerup", onUp);
|
|
||||||
element.removeEventListener("pointercancel", onUp);
|
|
||||||
element.removeEventListener("lostpointercapture", onUp);
|
|
||||||
element.removeEventListener("pointerleave", onLeave);
|
|
||||||
};
|
|
||||||
}, [gl, invalidate, enabledTilt]);
|
|
||||||
|
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MAX_TURN, SPRING_BACK_MS, createRigPointer } from "./rig-pointer";
|
import { MAX_TURN, SPRING_BACK_MS, attachRigPointer, createRigPointer } from "./rig-pointer";
|
||||||
import type { PointerSample, RigInput } from "./rig-pointer";
|
import type { PointerSample, RigInput } from "./rig-pointer";
|
||||||
|
|
||||||
function setup(tilt = true) {
|
function setup(tilt = true) {
|
||||||
@@ -99,6 +99,48 @@ describe("createRigPointer", () => {
|
|||||||
expect(still.input.tiltY).toBe(0);
|
expect(still.input.tiltY).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not turn when the host refuses the turn as it would start (editor: the press was on a part)", () => {
|
||||||
|
let onPart = false;
|
||||||
|
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
|
||||||
|
const pressEnded = vi.fn();
|
||||||
|
const host = {
|
||||||
|
tilt: false,
|
||||||
|
rect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
|
||||||
|
capture: vi.fn(),
|
||||||
|
release: vi.fn(),
|
||||||
|
changed: vi.fn(),
|
||||||
|
canTurn: () => !onPart,
|
||||||
|
pressEnded,
|
||||||
|
};
|
||||||
|
const pointer = createRigPointer(input, host);
|
||||||
|
pointer.down(sample(100));
|
||||||
|
// R3F marks the part press after the rig saw the pointerdown.
|
||||||
|
onPart = true;
|
||||||
|
pointer.move(sample(250));
|
||||||
|
expect(input.dragging).toBe(false);
|
||||||
|
expect(input.turn).toBe(0);
|
||||||
|
pointer.up(sample(250));
|
||||||
|
expect(pressEnded).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds still while the host is paused (two-finger zoom)", () => {
|
||||||
|
let paused = false;
|
||||||
|
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
|
||||||
|
const host = {
|
||||||
|
tilt: false,
|
||||||
|
rect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
|
||||||
|
capture: vi.fn(),
|
||||||
|
release: vi.fn(),
|
||||||
|
changed: vi.fn(),
|
||||||
|
paused: () => paused,
|
||||||
|
};
|
||||||
|
const pointer = createRigPointer(input, host);
|
||||||
|
pointer.down(sample(100, { pointerType: "touch" }));
|
||||||
|
paused = true;
|
||||||
|
pointer.move(sample(250, { pointerType: "touch" }));
|
||||||
|
expect(input.turn).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears its timers on dispose", () => {
|
it("clears its timers on dispose", () => {
|
||||||
const { input, pointer } = setup();
|
const { input, pointer } = setup();
|
||||||
pointer.down(sample(100));
|
pointer.down(sample(100));
|
||||||
@@ -108,4 +150,49 @@ describe("createRigPointer", () => {
|
|||||||
vi.advanceTimersByTime(SPRING_BACK_MS);
|
vi.advanceTimersByTime(SPRING_BACK_MS);
|
||||||
expect(input.turn).toBeGreaterThan(0);
|
expect(input.turn).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the real DOM order: rig on the canvas, the part handler on the wrapper after it", () => {
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
wrapper.appendChild(canvas);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 300, right: 400, bottom: 300, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
|
||||||
|
const signals = { partPress: false };
|
||||||
|
// R3F 9 listens on the wrapper: its pointerdown runs after the canvas listener (bubbling).
|
||||||
|
wrapper.addEventListener("pointerdown", (event) => {
|
||||||
|
if ((event as PointerEvent & { onPart?: boolean }).onPart) signals.partPress = true;
|
||||||
|
});
|
||||||
|
const input: RigInput = { turn: 0, tiltX: 0, tiltY: 0, dragging: false };
|
||||||
|
const detach = attachRigPointer(canvas, input, {
|
||||||
|
tilt: false,
|
||||||
|
changed: vi.fn(),
|
||||||
|
canTurn: () => !signals.partPress,
|
||||||
|
pressEnded: () => {
|
||||||
|
signals.partPress = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fire = (type: string, clientX: number, onPart = false) => {
|
||||||
|
const event = new Event(type, { bubbles: true });
|
||||||
|
Object.assign(event, { clientX, clientY: 100, pointerId: 1, pointerType: "mouse", buttons: type === "pointerup" ? 0 : 1, onPart });
|
||||||
|
canvas.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// A horizontal drag that starts on a part: no turn.
|
||||||
|
fire("pointerdown", 100, true);
|
||||||
|
fire("pointermove", 250);
|
||||||
|
expect(input.turn).toBe(0);
|
||||||
|
fire("pointerup", 250);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(signals.partPress).toBe(false);
|
||||||
|
|
||||||
|
// A stale mark (part drag released outside the canvas) does not block the next press.
|
||||||
|
signals.partPress = true;
|
||||||
|
// The next drag on empty space turns (the flag of the previous press is gone).
|
||||||
|
fire("pointerdown", 100);
|
||||||
|
fire("pointermove", 250);
|
||||||
|
expect(input.turn).toBeGreaterThan(0);
|
||||||
|
fire("pointerup", 250);
|
||||||
|
detach();
|
||||||
|
wrapper.remove();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -35,6 +35,16 @@ export interface RigPointerHost {
|
|||||||
release(pointerId: number): void;
|
release(pointerId: number): void;
|
||||||
/** Something changed: request a frame. */
|
/** Something changed: request a frame. */
|
||||||
changed(): void;
|
changed(): void;
|
||||||
|
/**
|
||||||
|
* May this press turn the model? Asked when the turn would start, not on
|
||||||
|
* pointerdown: R3F handles its pointerdown on the wrapper after the canvas
|
||||||
|
* listeners, so the editor's "press was on a part" mark only exists by then.
|
||||||
|
*/
|
||||||
|
canTurn?(): boolean;
|
||||||
|
/** The press ended (up, cancel, lost capture): the host clears its per-press marks. */
|
||||||
|
pressEnded?(): void;
|
||||||
|
/** Turning holds still meanwhile (editor: two-finger zoom). */
|
||||||
|
paused?(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RigPointer {
|
export interface RigPointer {
|
||||||
@@ -56,12 +66,15 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin
|
|||||||
|
|
||||||
const pointer: RigPointer = {
|
const pointer: RigPointer = {
|
||||||
down(event) {
|
down(event) {
|
||||||
|
// A mark left from a press that ended outside the canvas; R3F sets this press's mark after us.
|
||||||
|
host.pressEnded?.();
|
||||||
press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false };
|
press = { x: event.clientX, y: event.clientY, turn: input.turn, id: event.pointerId, captured: false };
|
||||||
if (springBack) clearTimeout(springBack);
|
if (springBack) clearTimeout(springBack);
|
||||||
springBack = null;
|
springBack = null;
|
||||||
},
|
},
|
||||||
move(event) {
|
move(event) {
|
||||||
if (press && press.id === event.pointerId) {
|
if (press && press.id === event.pointerId) {
|
||||||
|
if (host.paused?.()) return;
|
||||||
// The mouse button went up outside the canvas: end the drag now.
|
// The mouse button went up outside the canvas: end the drag now.
|
||||||
if (event.pointerType === "mouse" && event.buttons === 0) {
|
if (event.pointerType === "mouse" && event.buttons === 0) {
|
||||||
pointer.up(event);
|
pointer.up(event);
|
||||||
@@ -70,6 +83,11 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin
|
|||||||
const dx = event.clientX - press.x;
|
const dx = event.clientX - press.x;
|
||||||
const dy = event.clientY - press.y;
|
const dy = event.clientY - press.y;
|
||||||
if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
|
if (!input.dragging && Math.abs(dx) > DRAG_START_PX && Math.abs(dx) > Math.abs(dy)) {
|
||||||
|
if (host.canTurn && !host.canTurn()) {
|
||||||
|
// The press belongs to something else (a part drag): give it up for good.
|
||||||
|
press = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
input.dragging = true;
|
input.dragging = true;
|
||||||
press.captured = true;
|
press.captured = true;
|
||||||
host.capture(press.id);
|
host.capture(press.id);
|
||||||
@@ -90,6 +108,7 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
up() {
|
up() {
|
||||||
|
host.pressEnded?.();
|
||||||
if (!press) return;
|
if (!press) return;
|
||||||
if (press.captured) host.release(press.id);
|
if (press.captured) host.release(press.id);
|
||||||
if (input.dragging) {
|
if (input.dragging) {
|
||||||
@@ -116,3 +135,47 @@ export function createRigPointer(input: RigInput, host: RigPointerHost): RigPoin
|
|||||||
};
|
};
|
||||||
return pointer;
|
return pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttachHost = Omit<RigPointerHost, "rect" | "capture" | "release">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires a rig pointer to a canvas element (captures the pointer while turning,
|
||||||
|
* so a release outside the canvas still ends the turn). Returns the detach.
|
||||||
|
*/
|
||||||
|
export function attachRigPointer(element: HTMLElement, input: RigInput, host: AttachHost): () => void {
|
||||||
|
const pointer = createRigPointer(input, {
|
||||||
|
...host,
|
||||||
|
rect: () => element.getBoundingClientRect(),
|
||||||
|
capture: (id) => {
|
||||||
|
try {
|
||||||
|
element.setPointerCapture?.(id);
|
||||||
|
} catch {
|
||||||
|
// The pointer is gone already; buttons === 0 on the next move ends the turn instead.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
release: (id) => {
|
||||||
|
try {
|
||||||
|
if (element.hasPointerCapture?.(id)) element.releasePointerCapture(id);
|
||||||
|
} catch {
|
||||||
|
// Already released.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const onDown = (event: PointerEvent) => pointer.down(event);
|
||||||
|
const onMove = (event: PointerEvent) => pointer.move(event);
|
||||||
|
const onUp = (event: PointerEvent) => pointer.up(event);
|
||||||
|
const onLeave = () => pointer.leave();
|
||||||
|
const events: [string, (event: PointerEvent) => void][] = [
|
||||||
|
["pointerdown", onDown],
|
||||||
|
["pointermove", onMove],
|
||||||
|
["pointerup", onUp],
|
||||||
|
["pointercancel", onUp],
|
||||||
|
["lostpointercapture", onUp],
|
||||||
|
["pointerleave", onLeave],
|
||||||
|
];
|
||||||
|
for (const [type, listener] of events) element.addEventListener(type, listener as EventListener);
|
||||||
|
return () => {
|
||||||
|
pointer.dispose();
|
||||||
|
for (const [type, listener] of events) element.removeEventListener(type, listener as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { DragSource, DropSlot } from "../editor/drop-slots";
|
||||||
|
|
||||||
|
// What the editor route hands the scene (spec 9.5): the running drag, where
|
||||||
|
// the pointer is, and zoom. Types only, so the editor bundle does not pull
|
||||||
|
// three.js in.
|
||||||
|
|
||||||
|
export interface SceneDrag {
|
||||||
|
source: DragSource;
|
||||||
|
slots: DropSlot[];
|
||||||
|
hover: DropSlot | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneEditor {
|
||||||
|
drag: SceneDrag | null;
|
||||||
|
/** Latest pointer position in client pixels while dragging. */
|
||||||
|
pointer: RefObject<{ x: number; y: number } | null>;
|
||||||
|
/** Called on every pointer move of a drag; returns the unsubscribe. */
|
||||||
|
subscribePointer: (listener: () => void) => () => void;
|
||||||
|
/** The slot under the pointer (null: none) - the drop goes there. */
|
||||||
|
onHoverSlot: (slot: DropSlot | null) => void;
|
||||||
|
/** A press on a part in the scene; it becomes a drag when the pointer moves. */
|
||||||
|
onPartPress: (partId: string, event: { pointerId: number; clientX: number; clientY: number }) => void;
|
||||||
|
/** 0.6 … 2.5 */
|
||||||
|
zoom: number;
|
||||||
|
onZoom: (zoom: number) => void;
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ export interface SceneSignals {
|
|||||||
timerFrame: boolean;
|
timerFrame: boolean;
|
||||||
/** World point the camera frames in focus mode (depth of field focuses there). */
|
/** World point the camera frames in focus mode (depth of field focuses there). */
|
||||||
focusPoint: Vec3 | null;
|
focusPoint: Vec3 | null;
|
||||||
|
/** The press that just started was on a part (editor: it may become a part drag, not a turn). */
|
||||||
|
partPress: boolean;
|
||||||
|
/** Two fingers are zooming (editor): no turning meanwhile. */
|
||||||
|
pinching: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SceneKit {
|
export interface SceneKit {
|
||||||
@@ -54,7 +58,12 @@ export function SceneKitProvider({ motion, tier = "high", children }: SceneKitPr
|
|||||||
const [theme] = useState(readHoloTheme);
|
const [theme] = useState(readHoloTheme);
|
||||||
const [geometries] = useState(createGeometryCache);
|
const [geometries] = useState(createGeometryCache);
|
||||||
const [textures] = useState(() => createFrameTextureCache());
|
const [textures] = useState(() => createFrameTextureCache());
|
||||||
const [signals] = useState<SceneSignals>(() => ({ timerFrame: false, focusPoint: null }));
|
const [signals] = useState<SceneSignals>(() => ({
|
||||||
|
timerFrame: false,
|
||||||
|
focusPoint: null,
|
||||||
|
partPress: false,
|
||||||
|
pinching: false,
|
||||||
|
}));
|
||||||
const [env, setEnv] = useState<CubeTexture | null>(null);
|
const [env, setEnv] = useState<CubeTexture | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -93,19 +93,10 @@ export function setFocusPoint(signals: SceneSignals, point: Vec3 | null): void {
|
|||||||
signals.focusPoint = point;
|
signals.focusPoint = point;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pointer capture on the canvas (missing in some test DOMs, refused for unknown ids). */
|
export function setPinching(signals: SceneSignals, value: boolean): void {
|
||||||
export function capturePointer(element: HTMLElement, pointerId: number): void {
|
signals.pinching = value;
|
||||||
try {
|
|
||||||
element.setPointerCapture?.(pointerId);
|
|
||||||
} catch {
|
|
||||||
// The pointer is gone already; buttons === 0 on the next move ends the drag instead.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releasePointer(element: HTMLElement, pointerId: number): void {
|
export function markPartPress(signals: SceneSignals, value: boolean): void {
|
||||||
try {
|
signals.partPress = value;
|
||||||
if (element.hasPointerCapture?.(pointerId)) element.releasePointerCapture(pointerId);
|
|
||||||
} catch {
|
|
||||||
// Already released.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,9 @@ export interface NodeHandlers {
|
|||||||
onClick(event: ThreeEvent<MouseEvent>): void;
|
onClick(event: ThreeEvent<MouseEvent>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Very thin parts (sheets, screens) still get a hit box a finger or a mouse can find (12 mm). */
|
||||||
|
const MIN_HIT_HEIGHT = 0.12;
|
||||||
|
|
||||||
/** Edge brightness at glow 1 (rest); bloom picks it up above the threshold. */
|
/** Edge brightness at glow 1 (rest); bloom picks it up above the threshold. */
|
||||||
const EDGE_INTENSITY = 1.25;
|
const EDGE_INTENSITY = 1.25;
|
||||||
|
|
||||||
@@ -109,7 +112,7 @@ export function StackPart({ part, target, focused, handlers, labels, hintFrames,
|
|||||||
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
|
{/* The hit box reaches down to the rest place, so a lifted part does not slip from under the pointer. */}
|
||||||
<mesh
|
<mesh
|
||||||
position={[0, -target.lift / 2, 0]}
|
position={[0, -target.lift / 2, 0]}
|
||||||
scale={[box.size[0], box.size[1] + target.lift, box.size[2]]}
|
scale={[box.size[0], Math.max(box.size[1], MIN_HIT_HEIGHT) + target.lift, box.size[2]]}
|
||||||
raycast={target.interactive ? meshRaycast : noRaycast}
|
raycast={target.interactive ? meshRaycast : noRaycast}
|
||||||
onPointerOver={handlers.onPointerOver}
|
onPointerOver={handlers.onPointerOver}
|
||||||
onPointerOut={handlers.onPointerOut}
|
onPointerOut={handlers.onPointerOut}
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ const sub = (a: Vec3, b: Vec3): Vec3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|||||||
const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]];
|
const lift = (value: Vec3, dy: number): Vec3 => [value[0], value[1] + dy, value[2]];
|
||||||
|
|
||||||
/** Rest place of a stack part: its layout centre raised by the stack gap. */
|
/** Rest place of a stack part: its layout centre raised by the stack gap. */
|
||||||
export function restCenter(placed: PartBox): Vec3 {
|
export function restCenter(placed: PartBox, gap: number = REST_GAP): Vec3 {
|
||||||
return lift(placed.center, placed.stackIndex * REST_GAP);
|
return lift(placed.center, placed.stackIndex * gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** World box of the rest stack (centre and size), gaps included. */
|
/** World box of the rest stack (centre and size), gaps included. */
|
||||||
export function restBounds(layout: HiveLayout): { center: Vec3; size: Vec3 } {
|
export function restBounds(layout: HiveLayout, gap: number = REST_GAP): { center: Vec3; size: Vec3 } {
|
||||||
const levels = layout.parts.length;
|
const levels = layout.parts.length;
|
||||||
const height = layout.bounds.h + Math.max(0, levels - 1) * REST_GAP;
|
const height = layout.bounds.h + Math.max(0, levels - 1) * gap;
|
||||||
return { center: [0, height / 2, 0], size: [layout.bounds.w, height, layout.bounds.d] };
|
return { center: [0, height / 2, 0], size: [layout.bounds.w, height, layout.bounds.d] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,7 +141,12 @@ function focusInfo(layout: HiveLayout, focus: FocusState): FocusInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState | null): Map<string, NodeTarget> {
|
function stackTargets(
|
||||||
|
layout: HiveLayout,
|
||||||
|
view: MotionView,
|
||||||
|
focus: FocusState | null,
|
||||||
|
gap: number,
|
||||||
|
): Map<string, NodeTarget> {
|
||||||
const nodes = new Map<string, NodeTarget>();
|
const nodes = new Map<string, NodeTarget>();
|
||||||
for (const placed of layout.parts) {
|
for (const placed of layout.parts) {
|
||||||
const focused = focus !== null && !focus.isInsert && focus.id === placed.id;
|
const focused = focus !== null && !focus.isInsert && focus.id === placed.id;
|
||||||
@@ -158,7 +163,7 @@ function stackTargets(layout: HiveLayout, view: MotionView, focus: FocusState |
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const home = restCenter(placed);
|
const home = restCenter(placed, gap);
|
||||||
if (focus) {
|
if (focus) {
|
||||||
nodes.set(placed.id, {
|
nodes.set(placed.id, {
|
||||||
position: home,
|
position: home,
|
||||||
@@ -193,11 +198,12 @@ function insertTarget(
|
|||||||
focus: FocusState | null,
|
focus: FocusState | null,
|
||||||
insert: PartBox,
|
insert: PartBox,
|
||||||
host: PartBox,
|
host: PartBox,
|
||||||
|
gap: number,
|
||||||
): NodeTarget {
|
): NodeTarget {
|
||||||
const local = sub(insert.center, host.center);
|
const local = sub(insert.center, host.center);
|
||||||
if (focus?.isInsert && focus.id === insert.id) {
|
if (focus?.isInsert && focus.id === insert.id) {
|
||||||
return {
|
return {
|
||||||
position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host)),
|
position: sub(focusPlacement(layout.bounds, insert.size), restCenter(host, gap)),
|
||||||
scale: FOCUS_SCALE,
|
scale: FOCUS_SCALE,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
glow: GLOW.focus,
|
glow: GLOW.focus,
|
||||||
@@ -251,12 +257,13 @@ function frameTarget(frame: FrameBox, host: PartBox, view: MotionView, focus: Fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Targets for every part, insert and frame of the layout in the given view state. */
|
/** Targets for every part, insert and frame of the layout in the given view state. */
|
||||||
export function hiveMotionTargets(layout: HiveLayout, view: MotionView): HiveMotionTargets {
|
/** `gap`: space between stack levels; the editor opens the stack while dragging (DRAG_GAP). */
|
||||||
|
export function hiveMotionTargets(layout: HiveLayout, view: MotionView, gap: number = REST_GAP): HiveMotionTargets {
|
||||||
const focus = findFocus(layout, view);
|
const focus = findFocus(layout, view);
|
||||||
const nodes = stackTargets(layout, view, focus);
|
const nodes = stackTargets(layout, view, focus, gap);
|
||||||
for (const insert of layout.inserts) {
|
for (const insert of layout.inserts) {
|
||||||
const host = hostOf(layout, insert);
|
const host = hostOf(layout, insert);
|
||||||
if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host));
|
if (host) nodes.set(insert.id, insertTarget(layout, view, focus, insert, host, gap));
|
||||||
}
|
}
|
||||||
const frames = new Map<string, FrameTarget>();
|
const frames = new Map<string, FrameTarget>();
|
||||||
for (const frame of layout.frames) {
|
for (const frame of layout.frames) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { hiveLayout } from "@/lib/hive/layout";
|
import { hiveLayout } from "@/lib/hive/layout";
|
||||||
import { inlineCatalog, part } from "@/lib/hive/test-utils";
|
import { inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||||
@@ -61,7 +62,7 @@ function enterViewport() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSlot() {
|
function renderSlot(sceneOverlay?: ReactNode) {
|
||||||
return render(
|
return render(
|
||||||
<HiveViewProvider store={createHiveViewStore()}>
|
<HiveViewProvider store={createHiveViewStore()}>
|
||||||
<HiveVisualSlot
|
<HiveVisualSlot
|
||||||
@@ -71,6 +72,7 @@ function renderSlot() {
|
|||||||
partName={(id) => id}
|
partName={(id) => id}
|
||||||
onSelectPart={vi.fn()}
|
onSelectPart={vi.fn()}
|
||||||
onSelectFrame={vi.fn()}
|
onSelectFrame={vi.fn()}
|
||||||
|
sceneOverlay={sceneOverlay}
|
||||||
/>
|
/>
|
||||||
</HiveViewProvider>,
|
</HiveViewProvider>,
|
||||||
);
|
);
|
||||||
@@ -158,6 +160,15 @@ describe("HiveVisualSlot", () => {
|
|||||||
expect(canvasLayer()).not.toHaveClass("pointer-events-none");
|
expect(canvasLayer()).not.toHaveClass("pointer-events-none");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the scene overlay (editor zoom buttons) only while the 3D scene is up", () => {
|
||||||
|
renderSlot(<button type="button">Vergrößern</button>);
|
||||||
|
expect(screen.queryByRole("button", { name: "Vergrößern" })).toBeNull();
|
||||||
|
enterViewport();
|
||||||
|
expect(screen.queryByRole("button", { name: "Vergrößern" })).toBeNull();
|
||||||
|
fireEvent.click(screen.getByText("ready"));
|
||||||
|
expect(screen.getByRole("button", { name: "Vergrößern" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("remounts the scene once after the first context loss", () => {
|
it("remounts the scene once after the first context loss", () => {
|
||||||
renderSlot();
|
renderSlot();
|
||||||
enterViewport();
|
enterViewport();
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import type { HiveLayout } from "@/lib/hive/layout";
|
import type { HiveLayout } from "@/lib/hive/layout";
|
||||||
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||||
import type { OverlayInsets } from "./hive-scene/camera-fit";
|
import type { OverlayInsets } from "./hive-scene/camera-fit";
|
||||||
|
import type { SceneEditor } from "./hive-scene/scene-editor";
|
||||||
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
|
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
|
||||||
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
|
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
|
||||||
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
import type { MotionLevel } from "@/lib/motion/motion-preference";
|
||||||
@@ -28,6 +30,10 @@ export interface HiveVisualSlotProps {
|
|||||||
onSelectFrame: (boxId: string, slot: number) => void;
|
onSelectFrame: (boxId: string, slot: number) => void;
|
||||||
/** Card pixels covered by the frosted overlays; the 3D camera fits the model between them. */
|
/** Card pixels covered by the frosted overlays; the 3D camera fits the model between them. */
|
||||||
insets?: OverlayInsets;
|
insets?: OverlayInsets;
|
||||||
|
/** Editor route: drag and drop and zoom in the scene. */
|
||||||
|
editor?: SceneEditor;
|
||||||
|
/** DOM over the 3D scene (editor zoom buttons); shown only while the scene is up, not over the SVG. */
|
||||||
|
sceneOverlay?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function allows3D(motion: MotionLevel): boolean {
|
function allows3D(motion: MotionLevel): boolean {
|
||||||
@@ -53,6 +59,8 @@ export function HiveVisualSlot({
|
|||||||
onSelectPart,
|
onSelectPart,
|
||||||
onSelectFrame,
|
onSelectFrame,
|
||||||
insets,
|
insets,
|
||||||
|
editor,
|
||||||
|
sceneOverlay,
|
||||||
}: HiveVisualSlotProps) {
|
}: HiveVisualSlotProps) {
|
||||||
const motion = useEffectiveMotionLevel();
|
const motion = useEffectiveMotionLevel();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -103,7 +111,9 @@ export function HiveVisualSlot({
|
|||||||
onReady={onReady}
|
onReady={onReady}
|
||||||
onContextLost={onContextLost}
|
onContextLost={onContextLost}
|
||||||
insets={insets}
|
insets={insets}
|
||||||
|
editor={editor}
|
||||||
/>
|
/>
|
||||||
|
{ready ? sceneOverlay : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { freeFeederStarts } from "@/lib/hive/feeder-slots";
|
|||||||
import { partDisplayName } from "@/lib/hive/names";
|
import { partDisplayName } from "@/lib/hive/names";
|
||||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||||
import { FrameFeederPicker, frameFeederOps } from "./frame-feeder-picker";
|
import { frameFeederOps } from "@/lib/hive/feeder-slots";
|
||||||
|
import { FrameFeederPicker } from "./frame-feeder-picker";
|
||||||
import type { FrameFeederChoice } from "./frame-feeder-picker";
|
import type { FrameFeederChoice } from "./frame-feeder-picker";
|
||||||
import type { HiveOutlineHandle } from "./hive-outline";
|
import type { HiveOutlineHandle } from "./hive-outline";
|
||||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ describe("describeHiveOps", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("describes an insert taken out and put into another host (same id) as moved", () => {
|
||||||
|
const state = templateState();
|
||||||
|
state.stack.splice(2, 0, part("b2", "box"));
|
||||||
|
state.stack[1].inserts = [part("ev", "evaporator")];
|
||||||
|
const ops: HiveOp[] = [
|
||||||
|
{ op: "removePart", id: "ev" },
|
||||||
|
{ op: "addInsert", hostId: "b2", part: part("ev", "evaporator") },
|
||||||
|
];
|
||||||
|
expect(describeHiveOps(ops, state, catalog)).toBe("Verdunster verschoben");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves parts added earlier in the same batch", () => {
|
it("resolves parts added earlier in the same batch", () => {
|
||||||
const ops: HiveOp[] = [
|
const ops: HiveOp[] = [
|
||||||
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
|
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
|
||||||
|
|||||||
@@ -83,7 +83,14 @@ export function describeHiveOps(
|
|||||||
const known = new Map<string, Named>();
|
const known = new Map<string, Named>();
|
||||||
for (const part of seen) collect(part, catalog, known);
|
for (const part of seen) collect(part, catalog, known);
|
||||||
for (const part of before.stack) collect(part, catalog, known);
|
for (const part of before.stack) collect(part, catalog, known);
|
||||||
const texts = ops.map((op) => describeOne(op, known, catalog));
|
// An insert taken out and put back in with the same id (drag to another host) is one move.
|
||||||
|
const reAdded = new Set(ops.flatMap((op) => (op.op === "addInsert" ? [op.part.id] : [])));
|
||||||
|
const moved = new Set(ops.flatMap((op) => (op.op === "removePart" && reAdded.has(op.id) ? [op.id] : [])));
|
||||||
|
const texts = ops.flatMap((op) => {
|
||||||
|
if (op.op === "removePart" && moved.has(op.id)) return [`${nameOf(known.get(op.id))} verschoben`];
|
||||||
|
if (op.op === "addInsert" && moved.has(op.part.id)) return [];
|
||||||
|
return [describeOne(op, known, catalog)];
|
||||||
|
});
|
||||||
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
|
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
|
||||||
const rest = texts.length - MAX_SHOWN_OPS;
|
const rest = texts.length - MAX_SHOWN_OPS;
|
||||||
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
|
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { feederRange, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
|
import { feederRange, frameFeederOps, freeFeederStarts, pocketRanges, rangeText } from "./feeder-slots";
|
||||||
import { frames, inlineCatalog, part } from "./test-utils";
|
import { frames, inlineCatalog, part } from "./test-utils";
|
||||||
|
|
||||||
const catalog = inlineCatalog();
|
const catalog = inlineCatalog();
|
||||||
@@ -34,3 +34,25 @@ describe("feeder slots", () => {
|
|||||||
expect(rangeText({ from: 3, to: 3 })).toBe("3");
|
expect(rangeText({ from: 3, to: 3 })).toBe("3");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("frameFeederOps", () => {
|
||||||
|
const catalogForOps = inlineCatalog();
|
||||||
|
const pocketType = catalogForOps.partTypes.get("frame-feeder")!;
|
||||||
|
|
||||||
|
it("empties the covered frames first, then hangs the pocket at its slot", () => {
|
||||||
|
const boxFrames = frames(10);
|
||||||
|
const ops = frameFeederOps("b", boxFrames, pocketType, 3, { id: "ff", partTypeId: "frame-feeder", state: {} });
|
||||||
|
expect(ops[0]).toEqual({
|
||||||
|
op: "setFrames",
|
||||||
|
boxId: "b",
|
||||||
|
frames: boxFrames.map((frame) => (frame.slot === 3 || frame.slot === 4 ? { slot: frame.slot, type: "NONE" } : frame)),
|
||||||
|
});
|
||||||
|
expect(ops[1]).toEqual({ op: "addInsert", hostId: "b", part: { id: "ff", partTypeId: "frame-feeder", state: {}, slot: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips setFrames when the covered slots are empty already", () => {
|
||||||
|
const boxFrames = frames(10).map((frame) => (frame.slot <= 2 ? { ...frame, type: "NONE" as const } : frame));
|
||||||
|
const ops = frameFeederOps("b", boxFrames, pocketType, 1, { id: "ff", partTypeId: "frame-feeder", state: {} });
|
||||||
|
expect(ops).toEqual([{ op: "addInsert", hostId: "b", part: { id: "ff", partTypeId: "frame-feeder", state: {}, slot: 1 } }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CatalogPartType, HiveCatalog, HivePart } from "./types";
|
import type { CatalogPartType, HiveCatalog, HiveFrame, HiveOp, HivePart } from "./types";
|
||||||
|
|
||||||
// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of
|
// Frame feeder (Futtertasche) positions in a box. A pocket hangs in place of
|
||||||
// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is
|
// `slotWidth` frames from its `slot` on (spec 4.1); the server rule is
|
||||||
@@ -48,3 +48,35 @@ export function freeFeederStarts(
|
|||||||
export function rangeText(range: SlotRange): string {
|
export function rangeText(range: SlotRange): string {
|
||||||
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
|
return range.from === range.to ? String(range.from) : `${range.from}–${range.to}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Frame slots a frame feeder starting at `start` covers. */
|
||||||
|
export function coveredSlots(start: number, type: CatalogPartType): number[] {
|
||||||
|
const range = feederRange({ id: "", partTypeId: type.id, state: {}, slot: start }, type);
|
||||||
|
if (!range) return [];
|
||||||
|
return Array.from({ length: range.to - range.from + 1 }, (_, index) => range.from + index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frames of the box with the covered slots set to NONE (names of covered frames are dropped). */
|
||||||
|
function emptiedFrames(frames: HiveFrame[], covered: number[]): HiveFrame[] {
|
||||||
|
return frames.map((frame) => (covered.includes(frame.slot) ? { slot: frame.slot, type: "NONE" } : frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ops that hang a frame feeder at `start`: setFrames only if a covered frame is
|
||||||
|
* not empty yet, then addInsert. Shared by the box sheet, the add dialog and
|
||||||
|
* drag and drop.
|
||||||
|
*/
|
||||||
|
export function frameFeederOps(
|
||||||
|
boxId: string,
|
||||||
|
frames: HiveFrame[],
|
||||||
|
type: CatalogPartType,
|
||||||
|
start: number,
|
||||||
|
pocket: HivePart,
|
||||||
|
): HiveOp[] {
|
||||||
|
const covered = coveredSlots(start, type);
|
||||||
|
const needsEmptying = frames.some((frame) => covered.includes(frame.slot) && frame.type !== "NONE");
|
||||||
|
const ops: HiveOp[] = [];
|
||||||
|
if (needsEmptying) ops.push({ op: "setFrames", boxId, frames: emptiedFrames(frames, covered) });
|
||||||
|
ops.push({ op: "addInsert", hostId: boxId, part: { ...pocket, slot: start } });
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
@@ -72,6 +72,17 @@ describe("hiveFollowUps", () => {
|
|||||||
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
|
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suggests nothing when an evaporator only moves to another box (remove + add with the same id)", () => {
|
||||||
|
const before = templateState();
|
||||||
|
before.stack.splice(2, 0, part("b2", "box", { frames: before.stack[1].frames }));
|
||||||
|
before.stack[1].inserts = [{ ...part("ev", "evaporator"), state: { acidMl: 200 } }];
|
||||||
|
const ops: HiveOp[] = [
|
||||||
|
{ op: "removePart", id: "ev" },
|
||||||
|
{ op: "addInsert", hostId: "b2", part: { ...part("ev", "evaporator"), state: { acidMl: 200 } } },
|
||||||
|
];
|
||||||
|
expect(run(before, ops)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives one treatment when an evaporator is added and raised in one batch", () => {
|
it("gives one treatment when an evaporator is added and raised in one batch", () => {
|
||||||
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
|
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
|
||||||
const ops: HiveOp[] = [
|
const ops: HiveOp[] = [
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ export function hiveFollowUps(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (op.op === "removePart") {
|
} else if (op.op === "removePart") {
|
||||||
if (typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR") followUps.push({ kind: "endTreatment" });
|
// Net difference: an evaporator that is back in the result (moved to another box) ends nothing.
|
||||||
|
const gone = typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR" && !findPart(after, op.id);
|
||||||
|
if (gone) followUps.push({ kind: "endTreatment" });
|
||||||
}
|
}
|
||||||
for (const followUp of followUps) if (followUp) out.push(followUp);
|
for (const followUp of followUps) if (followUp) out.push(followUp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { defaultPartState, freshPart } from "./fresh-part";
|
||||||
|
import { inlineCatalog } from "./test-utils";
|
||||||
|
|
||||||
|
const catalog = inlineCatalog();
|
||||||
|
|
||||||
|
describe("freshPart", () => {
|
||||||
|
it("gives a new box foundation frames for every slot and no state", () => {
|
||||||
|
const box = freshPart(catalog.partTypes.get("box")!, "b-new");
|
||||||
|
expect(box.state).toEqual({});
|
||||||
|
expect(box.frames).toHaveLength(10);
|
||||||
|
expect(box.frames!.every((frame) => frame.type === "FOUNDATION")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills every state field with its default: level 0, the first choice", () => {
|
||||||
|
expect(defaultPartState(catalog.partTypes.get("feeder")!)).toEqual({ syrupLiters: 0, fondantKg: 0 });
|
||||||
|
expect(defaultPartState(catalog.partTypes.get("inner-lid")!)).toEqual({ feedHole: "OPEN" });
|
||||||
|
expect(freshPart(catalog.partTypes.get("inner-lid")!, "l-new")).toEqual({
|
||||||
|
id: "l-new",
|
||||||
|
partTypeId: "inner-lid",
|
||||||
|
state: { feedHole: "OPEN" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the given state instead of the defaults", () => {
|
||||||
|
expect(freshPart(catalog.partTypes.get("feeder")!, "f", { syrupLiters: 2, fondantKg: 0 }).state).toEqual({
|
||||||
|
syrupLiters: 2,
|
||||||
|
fondantKg: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { CatalogPartType, HiveFrame, HivePart } from "./types";
|
||||||
|
|
||||||
|
// A new part as the editor adds it (dialog and drag and drop alike): every
|
||||||
|
// state field set to its default, a new box filled with foundation frames.
|
||||||
|
|
||||||
|
/** Level 0, the first option of a choice. */
|
||||||
|
export function defaultPartState(type: CatalogPartType): HivePart["state"] {
|
||||||
|
const state: HivePart["state"] = {};
|
||||||
|
for (const field of type.stateSchema) state[field.key] = field.kind === "level" ? 0 : (field.options[0] ?? "");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frames of a new box: every slot a foundation (spec 9.5). */
|
||||||
|
export function newBoxFrames(type: CatalogPartType): HiveFrame[] {
|
||||||
|
return Array.from({ length: type.frameSlots ?? 0 }, (_, index): HiveFrame => ({ slot: index + 1, type: "FOUNDATION" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function freshPart(type: CatalogPartType, id: string, state: HivePart["state"] = defaultPartState(type)): HivePart {
|
||||||
|
const part: HivePart = { id, partTypeId: type.id, state };
|
||||||
|
if (type.kind === "BOX") part.frames = newBoxFrames(type);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Durchsicht am Bienenvolk: Königin, Stifte, Weiselzellen, Brutwaben, Futterkranz, Honigraum, Varroa, Schwarmstimmung, sanftmütig, Wabensitz, Drohnenbrut, Brutnest, Brutfläche, Spielnäpfchen, Schwarmzellen, Absperrgitter, Futterteig, Ableger, Jungvolk, Rähmchen, Mittelwand, Flugloch, Beute, Zarge, Ameisensäure, Oxalsäure, Windel, Faulbrut, Kalkbrut, Nosema, Wachsmotten, Pollen.
|
||||||
@@ -17,8 +17,8 @@ stringData:
|
|||||||
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
|
JWT_ACCESS_SECRET: "change-me-to-a-random-value-at-least-32-characters-long"
|
||||||
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
JWT_REFRESH_SECRET: "change-me-refresh-secret"
|
||||||
# One encryption key per purpose (TOTP secrets, webhook signing secrets,
|
# One encryption key per purpose (TOTP secrets, webhook signing secrets,
|
||||||
# backup files). Required in production: at least 32 characters each, all
|
# backup files, voice notes). Required in production: at least 32 characters
|
||||||
# three different from each other and from both JWT secrets.
|
# each, all four different from each other and from both JWT secrets.
|
||||||
# BACKUP_ENCRYPTION_KEY (with MFA_/WEBHOOK_ENCRYPTION_KEY for MFA and webhook
|
# BACKUP_ENCRYPTION_KEY (with MFA_/WEBHOOK_ENCRYPTION_KEY for MFA and webhook
|
||||||
# secrets) is needed to restore backups on another deployment.
|
# secrets) is needed to restore backups on another deployment.
|
||||||
# Rotation: old value to <NAME>_PREVIOUS, new <NAME>, run the rekey job,
|
# Rotation: old value to <NAME>_PREVIOUS, new <NAME>, run the rekey job,
|
||||||
@@ -27,9 +27,13 @@ stringData:
|
|||||||
MFA_ENCRYPTION_KEY: "change-me-to-a-third-random-value-at-least-32-characters"
|
MFA_ENCRYPTION_KEY: "change-me-to-a-third-random-value-at-least-32-characters"
|
||||||
WEBHOOK_ENCRYPTION_KEY: "change-me-to-a-fourth-random-value-at-least-32-characters"
|
WEBHOOK_ENCRYPTION_KEY: "change-me-to-a-fourth-random-value-at-least-32-characters"
|
||||||
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
|
BACKUP_ENCRYPTION_KEY: "change-me-to-another-random-value-at-least-32-characters"
|
||||||
|
# VOICE_ENCRYPTION_KEY seals audio, transcripts and suggestions of voice notes.
|
||||||
|
# It must exist before the T7 backend image rolls out (every role checks it).
|
||||||
|
VOICE_ENCRYPTION_KEY: "change-me-to-a-fifth-random-value-at-least-32-characters"
|
||||||
# MFA_ENCRYPTION_KEY_PREVIOUS: ""
|
# MFA_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS: ""
|
# WEBHOOK_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
# BACKUP_ENCRYPTION_KEY_PREVIOUS: ""
|
# BACKUP_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
|
# VOICE_ENCRYPTION_KEY_PREVIOUS: ""
|
||||||
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
# Entra ID App-Registrierung (SSO). Siehe configmap.example.yaml
|
||||||
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
# fuer OIDC_ISSUER_URL/OIDC_REDIRECT_URI. Wird auch fuer die
|
||||||
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
|
# M365-Kalender-Synchronisation wiederverwendet - dafuer in der
|
||||||
@@ -55,3 +59,18 @@ stringData:
|
|||||||
# type: Opaque
|
# type: Opaque
|
||||||
# stringData:
|
# stringData:
|
||||||
# TELEGRAM_BOT_TOKEN: "<token-from-botfather>"
|
# TELEGRAM_BOT_TOKEN: "<token-from-botfather>"
|
||||||
|
|
||||||
|
# Anthropic-API-Schluessel fuer die Auswertung der Sprachnotizen (optional) -
|
||||||
|
# NICHT in diesem Secret, sondern im eigenen Secret apicore-anthropic, das nur
|
||||||
|
# der Worker einbindet (einzelne secretKeyRef-Variable ANTHROPIC_API_KEY mit
|
||||||
|
# optional: true, kein envFrom, kein Volume; Muster apicore-telegram). API- und
|
||||||
|
# Job-Pods lesen ihn nie. Der Inhaber legt das Secret selbst an; ohne
|
||||||
|
# Schluessel bleibt die Auswertung aus (EXTRACT_DISABLED), alles andere laeuft.
|
||||||
|
#
|
||||||
|
# apiVersion: v1
|
||||||
|
# kind: Secret
|
||||||
|
# metadata:
|
||||||
|
# name: apicore-anthropic
|
||||||
|
# type: Opaque
|
||||||
|
# stringData:
|
||||||
|
# ANTHROPIC_API_KEY: "<key-from-the-anthropic-console>"
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
# bash deploy/k8s/scripts/ensure-secrets.sh --dry-run # nur anzeigen
|
# bash deploy/k8s/scripts/ensure-secrets.sh --dry-run # nur anzeigen
|
||||||
#
|
#
|
||||||
# Werte:
|
# Werte:
|
||||||
# - MFA_/WEBHOOK_/BACKUP_ENCRYPTION_KEY: zufaellig (48 Byte, base64url). Die
|
# - MFA_/WEBHOOK_/BACKUP_/VOICE_ENCRYPTION_KEY: zufaellig (48 Byte, base64url). Die
|
||||||
# App verlangt mindestens 32 Zeichen; "dummy" wuerde den Start abbrechen.
|
# App verlangt mindestens 32 Zeichen; "dummy" wuerde den Start abbrechen.
|
||||||
# Diese Schluessel nie wieder aendern ohne *_PREVIOUS (sonst sind MFA-
|
# Diese Schluessel nie wieder aendern ohne *_PREVIOUS (sonst sind MFA-
|
||||||
# Geheimnisse, Webhook-Secrets und Backups nicht mehr lesbar). Sichern!
|
# Geheimnisse, Webhook-Secrets, Backups und offene Sprachnotizen nicht mehr
|
||||||
|
# lesbar). Sichern!
|
||||||
|
# VOICE_ENCRYPTION_KEY muss vor dem Rollout des T7-Backend-Images existieren:
|
||||||
|
# die App verlangt ihn in Produktion in jeder Rolle und startet sonst nicht.
|
||||||
# - OPEN_METEO_API_KEY: leer = freie Open-Meteo-API (ein Fantasiewert wuerde
|
# - OPEN_METEO_API_KEY: leer = freie Open-Meteo-API (ein Fantasiewert wuerde
|
||||||
# die kostenpflichtige API mit falschem Schluessel ansprechen).
|
# die kostenpflichtige API mit falschem Schluessel ansprechen).
|
||||||
# - TELEGRAM_BOT_TOKEN (Secret apicore-telegram, nur Worker): "dummy". Die App
|
# - TELEGRAM_BOT_TOKEN (Secret apicore-telegram, nur Worker): "dummy". Die App
|
||||||
@@ -83,9 +86,14 @@ ensure_keys apicore-backend-secret \
|
|||||||
MFA_ENCRYPTION_KEY random \
|
MFA_ENCRYPTION_KEY random \
|
||||||
WEBHOOK_ENCRYPTION_KEY random \
|
WEBHOOK_ENCRYPTION_KEY random \
|
||||||
BACKUP_ENCRYPTION_KEY random \
|
BACKUP_ENCRYPTION_KEY random \
|
||||||
|
VOICE_ENCRYPTION_KEY random \
|
||||||
OPEN_METEO_API_KEY empty
|
OPEN_METEO_API_KEY empty
|
||||||
|
|
||||||
ensure_keys apicore-telegram \
|
ensure_keys apicore-telegram \
|
||||||
TELEGRAM_BOT_TOKEN dummy
|
TELEGRAM_BOT_TOKEN dummy
|
||||||
|
|
||||||
|
# apicore-anthropic (ANTHROPIC_API_KEY, nur Worker, optional) legt der Inhaber
|
||||||
|
# selbst an; dieses Skript erzeugt es nicht. Ohne Schluessel bleibt die
|
||||||
|
# Auswertung der Sprachnotizen aus (EXTRACT_DISABLED), alles andere laeuft.
|
||||||
|
|
||||||
echo "Fertig. Laufende Pods lesen neue Werte erst nach einem Neustart."
|
echo "Fertig. Laufende Pods lesen neue Werte erst nach einem Neustart."
|
||||||
Reference in new issue
Block a user