From 9c68c414d81c1463683ee14a087506855159a429 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 6 Jul 2026 19:08:24 +0200 Subject: [PATCH] Update --- .gitignore | 3 + CLAUDE.md | 71 +- docs/HANDOVER.md | 31 +- package-lock.json | 1518 +++++++++++++++++++++- package.json | 10 +- server/database.ts | 18 + server/db/crud.ts | 125 ++ server/db/queries/appSettings.ts | 26 + server/db/queries/assessments.ts | 12 + server/db/queries/assignmentFeedback.ts | 44 + server/db/queries/assignmentTypes.ts | 31 + server/db/queries/assignments.ts | 31 + server/db/queries/backup.ts | 45 + server/db/queries/consultants.ts | 12 + server/db/queries/conversationEntries.ts | 19 + server/db/queries/feedbackStructure.ts | 57 + server/db/queries/meetings.ts | 69 + server/db/tableRegistry.ts | 160 +++ server/index.ts | 29 + server/rpcModules.ts | 33 + server/scripts/verifyBackupRoundtrip.ts | 66 + src/db/index.ts | 1 + src/db/queries/appSettings.ts | 6 + src/db/queries/assessments.ts | 13 + src/db/queries/assignmentFeedback.ts | 41 + src/db/queries/assignmentTypes.ts | 23 + src/db/queries/assignments.ts | 17 + src/db/queries/consultants.ts | 12 + src/db/queries/conversationEntries.ts | 21 + src/db/queries/feedbackStructure.ts | 56 + src/db/queries/index.ts | 9 + src/db/queries/meetings.ts | 39 + src/db/rpcClient.ts | 17 + src/main.tsx | 7 - src/pages/AssignmentCreate.tsx | 21 +- src/pages/AssignmentDetail.tsx | 69 +- src/pages/AssignmentEdit.tsx | 13 +- src/pages/AssignmentFeedbackPage.tsx | 87 +- src/pages/AssignmentTypeConfig.tsx | 45 +- src/pages/Consultants.tsx | 65 +- src/pages/Dashboard.tsx | 26 +- src/pages/Evaluation.tsx | 29 +- src/pages/FeedbackDraftPage.tsx | 36 +- src/pages/FeedbackStructureConfig.tsx | 50 +- src/pages/RatingWeightConfig.tsx | 14 +- src/pages/meeting/AssessmentTab.tsx | 15 +- src/pages/meeting/MeetingView.tsx | 86 +- src/utils/dbBackup.ts | 53 +- src/utils/meetingExport.ts | 41 +- src/utils/ratingTrend.ts | 40 +- tsconfig.json | 3 +- tsconfig.server.json | 20 + vite.config.ts | 3 + 53 files changed, 2919 insertions(+), 469 deletions(-) create mode 100644 server/database.ts create mode 100644 server/db/crud.ts create mode 100644 server/db/queries/appSettings.ts create mode 100644 server/db/queries/assessments.ts create mode 100644 server/db/queries/assignmentFeedback.ts create mode 100644 server/db/queries/assignmentTypes.ts create mode 100644 server/db/queries/assignments.ts create mode 100644 server/db/queries/backup.ts create mode 100644 server/db/queries/consultants.ts create mode 100644 server/db/queries/conversationEntries.ts create mode 100644 server/db/queries/feedbackStructure.ts create mode 100644 server/db/queries/meetings.ts create mode 100644 server/db/tableRegistry.ts create mode 100644 server/index.ts create mode 100644 server/rpcModules.ts create mode 100644 server/scripts/verifyBackupRoundtrip.ts create mode 100644 src/db/queries/appSettings.ts create mode 100644 src/db/queries/assessments.ts create mode 100644 src/db/queries/assignmentFeedback.ts create mode 100644 src/db/queries/assignmentTypes.ts create mode 100644 src/db/queries/assignments.ts create mode 100644 src/db/queries/consultants.ts create mode 100644 src/db/queries/conversationEntries.ts create mode 100644 src/db/queries/feedbackStructure.ts create mode 100644 src/db/queries/index.ts create mode 100644 src/db/queries/meetings.ts create mode 100644 src/db/rpcClient.ts create mode 100644 tsconfig.server.json diff --git a/.gitignore b/.gitignore index ebd40ee..ce7968f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ dist-ssr/ # TypeScript build info *.tsbuildinfo +# Lokale SQLite-Datenbank (enthält personenbezogene Daten, siehe CLAUDE.md → Datenschutz) +server/data/ + # Local environment / secrets .env .env.local diff --git a/CLAUDE.md b/CLAUDE.md index 066de99..ec10616 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,37 +10,65 @@ Progressive Web App zur Live-Bewertung von Capgemini-Trainees (Institutees) wäh - React 18 + TypeScript + Vite (PWA) - Tailwind CSS v4 — `@import "tailwindcss"` in CSS, **kein** `tailwind.config.js`; Brandfarbe als `@theme { --color-brand }` in `src/index.css` -- Dexie.js v5 — IndexedDB-Wrapper, kein Backend +- **Lokaler Node/Express-Server + SQLite** (seit 2026-07-04) — löst Dexie/IndexedDB als primären Datenspeicher ab, siehe eigener Abschnitt unten - React Router v6 - Capgemini-Brandfarbe: `#0070AD` (Tailwind-Klasse `brand`, JS-Konstante `BRAND_COLOR` in `src/config/constants.ts`) -## Projektstruktur (nach Architektur-Refactoring, Stand 2026-07-02) +## Projektstruktur (Stand 2026-07-04) ``` src/ config/constants.ts ← RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP, BRAND_COLOR, DEFAULT_RATING_TREND_WEIGHT_STEP db/ - index.ts ← reiner Re-Export (types + schema + seeds) - types.ts ← alle Interfaces, FeedbackRating, AppSettings - schema.ts ← Dexie-Klasse + Migrationsversionen - seeds/ ← assignmentTypes.ts, feedbackStructure.ts, migrations.ts + index.ts ← Re-Export (types + queries), rpcClient.ts eingebunden + types.ts ← alle Interfaces, FeedbackRating, AppSettings — von Client UND Server importiert + rpcClient.ts ← rpc(module, fn, ...args) — POST /api/rpc, einziger Netzwerk-Kontaktpunkt des Clients + queries/ ← Client-DB-Zugriffsschicht (siehe eigener Abschnitt unten) + schema.ts, seeds/ ← Dexie/IndexedDB-Legacy-Code, wird nicht mehr gelesen (siehe Datenbank-Abschnitt) pages/ meeting/ ← MeetingView.tsx, ConversationTab.tsx, AssessmentTab.tsx, CriterionTagPicker.tsx RatingWeightConfig.tsx ← Settings-Tab für Zeitgewichtungs-Faktor (in Configuration.tsx eingebunden) ...übrige Pages unverändert am alten Ort utils/ - ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung (genutzt von Evaluation.tsx & AssignmentFeedbackPage.tsx) + ratingTrend.ts ← zeitgewichteter Bewertungs-Vorschlag + Trend-Erkennung + weightedAverageScore/weightedAverageRating (genutzt von Evaluation.tsx, AssignmentFeedbackPage.tsx, AssessmentTab.tsx, AssignmentDetail.tsx) +server/ + index.ts ← Express-App, ein Endpunkt POST /api/rpc + database.ts ← node:sqlite-Verbindung (server/data/assignment-monitor.sqlite3), Schema aus tableRegistry.ts + rpcModules.ts ← Dispatch-Registry { appSettings, consultants, ..., backup } → server/db/queries/*.ts + db/ + tableRegistry.ts ← EINE Quelle der Wahrheit: alle 21 Tabellen + Spalten + JSON-/Bool-Codecs (treibt Schema UND Backup) + crud.ts ← generische SQL-CRUD-Bausteine über die Registry + queries/ ← Server-Pendant zu src/db/queries/*.ts, gleiche Funktionsnamen, echtes SQL statt Dexie + scripts/verifyBackupRoundtrip.ts ← automatisierter Vollständigkeits-Beweis für Backup-Export/-Import ``` -Bestehende Imports aus `'../db'` funktionieren weiterhin unverändert (Re-Export). +### Datenbank: lokaler Server statt IndexedDB (seit 2026-07-04) + +Grund: Mehrere Browser (Chrome, Edge, ...) auf demselben Laptop sollen dieselben Daten sehen, unabhängig vom Browser-Cache — als Testschritt vor einem späteren Umzug auf einen Docker-Container auf einem Linux-Server. Browser können keine SQLite-Datei direkt öffnen, daher läuft ein kleiner Node/Express-Server (`server/`, Port 4000) mit **`node:sqlite`** (Node-eingebautes SQLite-Modul, **kein** `better-sqlite3`!). Alle Browser sprechen über `http://localhost:5173` (Vite-Dev-Proxy `/api` → `http://localhost:4000`) denselben Server an. + +**Abweichung vom ursprünglich gewählten Stack:** `better-sqlite3` scheiterte lokal an einer fehlenden/kaputten Python-Installation (node-gyp-Kompilierung). `node:sqlite` bietet dieselbe synchrone API (`DatabaseSync`, `.prepare().run()/.get()/.all()`), braucht keine native Kompilierung und ist ab dieser Node-Version (25.x) ohne Flag nutzbar (Node meldet es noch als "experimental" — rein informativ, funktioniert stabil). Falls auf einem anderen Rechner/Server `node:sqlite` fehlt (ältere Node-Version), wäre `better-sqlite3` dort nachzuholen, sofern eine funktionierende Build-Toolchain (Python + C++-Compiler) vorhanden ist. + +**RPC statt REST:** Ein einziger Endpunkt `POST /api/rpc` mit Body `{ module, fn, args }` statt ~80 einzelnen Routen — Client (`src/db/rpcClient.ts`) und Server (`server/rpcModules.ts`) sind darüber 1:1 gekoppelt: jede Funktion aus `src/db/queries/*.ts` hat ein gleichnamiges Pendant in `server/db/queries/*.ts`. + +**Dexie/IndexedDB ist toter Code, aber noch nicht entfernt:** `src/db/schema.ts` und `src/db/seeds/*.ts` bleiben unverändert im Repo liegen, werden aber von nichts mehr gelesen (Seed-Aufrufe in `main.tsx` wurden entfernt). Vollständiges Entfernen ist eine spätere, bewusste Entscheidung. + +**Backup ist jetzt server-seitig:** `src/utils/dbBackup.ts` ruft `rpc('backup', 'exportAll'/'importAll', ...)` statt direkt gegen Dexie zu laufen. `server/db/queries/backup.ts` iteriert exakt `tableRegistry.ts` — dieselbe Liste, die auch das SQL-Schema erzeugt, damit eine neue Tabelle nicht mehr unbemerkt aus dem Backup fallen kann (das ist genau einmal mit `appSettings` passiert, siehe Erledigt-Historie). `server/scripts/verifyBackupRoundtrip.ts` (`npm run verify-backup -- `) beweist automatisiert Zeilenzahl- und Inhaltsgleichheit pro Tabelle nach einem Import→Export-Zyklus. + +### DB-Zugriffsschicht (`src/db/queries/`, seit 2026-07-04, intern auf RPC umgestellt) + +Alle Komponenten greifen auf Daten ausschließlich über benannte Funktionen aus `src/db/queries/*.ts` zu (re-exportiert über `'../db'`) — nach außen unverändert seit der ursprünglichen Extraktion, nur die Innenseite ruft jetzt `rpc(...)` statt `db..*` auf. Ein Modul pro Domäne: `appSettings.ts`, `consultants.ts` (Groups+Consultants), `assignmentTypes.ts` (AssignmentTypes+PhaseTemplates), `assignments.ts`, `feedbackStructure.ts` (Dimensions/Categories/CriterionItems — `filterVisibleFeedbackStructure()` bleibt reine Client-Funktion ohne RPC), `meetings.ts` (MeetingInstances inkl. kanonischem `!deletedAt && status==='done'`-Filter als `listDoneMeetingsChronological()`, Cascade-Deletes, Export-Datenaggregation `getMeetingExportData()`), `conversationEntries.ts`, `assessments.ts`, `assignmentFeedback.ts`. ## Entwicklung ```bash -npm run dev # Dev-Server starten -npx tsc --noEmit # TypeScript-Check — muss vor jeder Abgabe fehlerfrei sein +npm run dev:all # Frontend (Vite, :5173) + lokaler Server (Express, :4000) zusammen starten +npm run dev # Nur Frontend (Server muss separat laufen, sonst laufen alle DB-Zugriffe ins Leere) +npm run server # Nur den lokalen Server +npx tsc -b # TypeScript-Check über alle 3 Projekte (App/Node/Server) — muss vor jeder Abgabe fehlerfrei sein ``` +**Wichtig:** `npx tsc --noEmit` am Root prüft **nichts** (0 Dateien, `tsc --noEmit --listFiles` bestätigt das) — der Root-`tsconfig.json` hat `"files": []` und nur `references`. Ohne `-b` (Build-Modus) werden referenzierte Projekte nicht ausgewertet. Der bisher in diesem Dokument dokumentierte Workflow `npx tsc --noEmit` war dadurch vermutlich seit Einführung der Project References ein stiller No-Op — **immer `npx tsc -b` verwenden.** + --- ## Kritische Eigenheiten @@ -57,13 +85,7 @@ ConversationLineTag.criterionItemId → FeedbackCriterionItem.id (Kriter Referenziert `FeedbackCriterionItem.id[]`. Leer = alle Kriterien anzeigen. -Filterlogik (immer so anwenden): -```typescript -const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null -const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems -const visibleCatIds = new Set(filteredItems.map(i => i.categoryId)) -const filteredCats = itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats -``` +Filterlogik: `filterVisibleFeedbackStructure(allCategories, allItems, criteriaIds)` in `src/db/queries/feedbackStructure.ts` — seit dem `db/queries/`-Refactoring (2026-07-04) die einzige Implementierung, vorher identisch dupliziert in `MeetingView.tsx` und `AssignmentDetail.tsx`. Nicht erneut inline schreiben. ### Rating-Skala @@ -77,7 +99,7 @@ Aggregation: gewichteter Durchschnitt → `avg < 1.5` Not · `< 2.5` Partially **Bug-Klasse, auf die immer prüfen:** Filter der Form `a.score !== null` schließen N/A NICHT aus (N/A ist der String `'na'`, nicht `null`!). Richtig ist immer `a.score !== null && a.score !== 'na'`. Gefunden und gefixt in `Evaluation.tsx` (2026-07-02); dieselbe Prüfung nötig überall, wo `Assessment.score` oder `ConversationSkillScore.score` gemittelt wird. -**Zweite Bug-Klasse, auf die immer prüfen (Kriterium-Gewichtung):** `FeedbackCriterionItem.weight` geht seit 2026-07-03 von `0,0` bis `2,0` (Config → Feedback, vorher grobe `×1`–`×5`-Stufen). Jede gewichtete Mittelung MUSS normieren — `Σ(score×weight) / Σ(weight)`, niemals `score×weight` direkt als Endwert interpretieren (würde die Bewertungsstufe verzerren, z.B. Score 3 „Nearly" × Gewicht 0,5 = 1,5, was fälschlich zwischen Not/Partially läge). Zusätzlich: wenn `Σweight === 0` (z.B. alle beteiligten Kriterien einer Kategorie auf Gewicht 0 gesetzt), **nicht** dividieren — `NaN < 1.5/2.5/3.5` ist überall `false` und fällt sonst still auf den letzten Bucket „Fully" durch. Immer `if (wSum === 0) return null` (bzw. `continue`) vor der Division. Umgesetzt in `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`) — beim nächsten neuen gewichteten Durchschnitt genauso prüfen. +**Zweite Bug-Klasse, auf die immer prüfen (Kriterium-Gewichtung):** `FeedbackCriterionItem.weight` geht seit 2026-07-03 von `0,0` bis `2,0` (Config → Feedback, vorher grobe `×1`–`×5`-Stufen). Jede gewichtete Mittelung MUSS normieren — `Σ(score×weight) / Σ(weight)`, niemals `score×weight` direkt als Endwert interpretieren (würde die Bewertungsstufe verzerren, z.B. Score 3 „Nearly" × Gewicht 0,5 = 1,5, was fälschlich zwischen Not/Partially läge). Zusätzlich: wenn `Σweight === 0` (z.B. alle beteiligten Kriterien einer Kategorie auf Gewicht 0 gesetzt), **nicht** dividieren — `NaN < 1.5/2.5/3.5` ist überall `false` und fällt sonst still auf den letzten Bucket „Fully" durch. Immer `if (wSum === 0) return null` (bzw. `continue`) vor der Division. Seit dem `db/queries/`-Refactoring (2026-07-04) **eine** Implementierung: `weightedAverageScore()`/`weightedAverageRating()` in `src/utils/ratingTrend.ts`, genutzt von `buildGroupMeetingScores()`, `catMode()` (`AssessmentTab.tsx`) und der Meeting-Chip-Anzeige in `AssignmentDetail.tsx` (dort fehlte der `Σweight===0`-Guard vorher — beim Konsolidieren mitgefixt). Bei jedem neuen gewichteten Durchschnitt diese Funktion wiederverwenden, nicht neu implementieren. ### Routing @@ -118,9 +140,15 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` |---|---|---| | H | **KI-Generierung derzeit funktionsunfähig**: `generateWithAi()` schlägt beim Ausführen fehl, vermutlich blockiert das Capgemini-Netzwerk/Firewall den Call zu `openrouter.ai`. Root Cause noch nicht untersucht — ggf. mit internem IT-Support klären. | `src/pages/AssignmentFeedbackPage.tsx` | | H | **Größeres Vorhaben (noch nicht gescoped):** Mehrstufiges, pro Dimension konfigurierbares Prompt-System mit Spezialwissen je Dimension + Platzhaltersystem — Ablösung des aktuellen Einzel-Prompts in `buildAiPrompt`. Braucht eigene Design-Session. | `src/pages/AssignmentFeedbackPage.tsx` | -| M | `db/queries/`-Layer einführen (Vorbereitung SQLite-Migration, DB-Calls aus Komponenten extrahieren) | `src/db/queries/` (neu) | | M | **Selbstlernendes Tagging (Idee, noch nicht gescoped):** Zeile-zu-Kriterium-Zuordnungen sammeln und daraus beim Tippen automatische Tag-Vorschläge ableiten (ähnliche Formulierungen → gleiches Kriterium). Braucht eigene Design-Session (Ähnlichkeits-/Lernmechanik, Datenbasis, UI). | `src/pages/meeting/CriterionTagPicker.tsx` | -| L | SQLite WASM + OPFS Migration (kein festes Datum) | `src/db/schema.ts` | +| M | **Migration der echten Daten aussteht:** Aktuelle IndexedDB-Daten müssen noch einmalig über Config → Backup → Export (alte Version) exportiert und über Config → Backup → Import (neue, serverbasierte Version) in die SQLite-DB eingespielt werden. Siehe Datenbank-Abschnitt oben. | — | +| L | Dexie/IndexedDB-Legacy-Code vollständig entfernen (`src/db/schema.ts`, `src/db/seeds/`) — aktuell bewusst nur stillgelegt, nicht gelöscht | `src/db/schema.ts`, `src/db/seeds/` | +| L | Server-seitiges "Seed-if-empty" für Assignment-Typen/Feedback-Struktur (aktuell nur clientseitig vorhanden und nicht mehr wirksam) — nicht dringend, da der Backup-Import denselben Zweck erfüllt | `server/db/queries/` (neu) | +| L | SQLite WASM + OPFS Migration — durch den lokalen Server-Ansatz (2026-07-04) überholt/hinfällig | `src/db/schema.ts` | + +**Erledigt (2026-07-04, zweite Runde):** Lokaler Node/Express-Server mit `node:sqlite` (statt IndexedDB) eingeführt, RPC-Bridge (`POST /api/rpc`) zwischen `src/db/queries/*.ts` (Client) und neuem `server/db/queries/*.ts` (Server) — siehe eigener Abschnitt "Datenbank: lokaler Server statt IndexedDB" oben für Details, Abweichungen (node:sqlite statt better-sqlite3) und die wichtige `npx tsc -b`-statt-`--noEmit`-Korrektur. Ziel: mehrere Browser auf demselben Laptop teilen sich dieselben Daten, unabhängig vom Browser-Cache, als Zwischenschritt vor einem Docker/Linux-Deployment. + +**Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — alle 20 Komponenten/Utils mit vorherigem direktem `db.
.*`-Zugriff nutzen jetzt benannte Funktionen aus `src/db/queries/*.ts` (siehe eigener Abschnitt oben), reine Extraktion ohne beabsichtigte Verhaltensänderung. Dabei bewusst mitkonsolidiert: die drei unabhängig implementierten Varianten der gewichteten-Durchschnitt-Logik (`ratingTrend.ts`, `AssessmentTab.tsx`, `AssignmentDetail.tsx`) laufen jetzt über eine einzige Funktion `weightedAverageScore()`/`weightedAverageRating()` in `ratingTrend.ts` — dabei einen fehlenden `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt (identische Bug-Klasse wie die zweite Bug-Klasse oben, dort war er noch nicht behoben). Ebenfalls konsolidiert: die "AssignmentType.criteriaIds schränkt sichtbare Kriterien ein"-Filterlogik (vorher dupliziert in `MeetingView.tsx`/`AssignmentDetail.tsx`, jetzt `filterVisibleFeedbackStructure()`) und die doppelten Datenabfragen in `meetingExport.ts`s Markdown-/JSON-Export (jetzt `getMeetingExportData()` in `meetings.ts`). `dbBackup.ts` und Seed-Code bleiben bewusst bei direktem `db`-Zugriff (siehe Abschnitt oben). `npx tsc --noEmit` läuft fehlerfrei. **Erledigt (2026-07-02):** Konfiguration zentralisiert (`config/constants.ts`), `db/index.ts` in types/schema/seeds aufgeteilt, `MeetingView.tsx` in 3 Dateien aufgeteilt (`pages/meeting/`), `Evaluation.tsx` auf `feedbackCriterionItems` migriert + nach Assignment gruppiert + Papierkorb/Status-Filter + N/A-Bug gefixt, `zustand` entfernt (war ungenutzt). Zeitgewichteter Bewertungs-Vorschlag mit Trend-Erkennung umgesetzt (`src/utils/ratingTrend.ts`, neue Tabelle `appSettings`, neuer Config-Tab „Gewichtung“, Vorschlags-Badge + Übernehmen/Trend-in-Text auf `AssignmentFeedbackPage`, `buildAiPrompt` um Trend-Abschnitt erweitert und Legacy-`db.criteria`-Bug dabei gefixt). Kriterium-Zuordnung für Freitext-Notizen umgesetzt: `ConversationEntry.lineTags` (Zeilentext → Kriterium, nach einem verworfenen Zwischenstand mit "ein Entry pro Zeile" — siehe Sonstige Fallstricke), `CriterionTagPicker.tsx` (Chip+Popover mit Suche, unverändert seit erster Version) wird pro Zeile direkt an der Zeile via `NotationText`s neuer optionaler `renderLineAddon`-Prop gerendert; Zuordnung passiert bewusst nur nachträglich (nicht live während des Tippens), um den Schreibfluss nicht zu stören. `buildAiPrompt` führt Assessment-Notizen und getaggte Zeilen jetzt pro Kriterium zusammen, unzugeordnete Zeilen bleiben im allgemeinen Protokoll-Block. **(2026-07-03)** Vier Folge-Fixes: `meetingExport.ts` löste Kriterien über die Legacy-Tabelle `db.criteria` auf (identischer Bug wie der frühere `buildAiPrompt`-Fix) — dadurch war die Gesamtbewertung im Export faktisch leer; jetzt auf `feedbackCriterionItems` umgestellt, Markdown-Export zeigt zusätzlich das Kriterium pro getaggter Protokollzeile. `dbBackup.ts`s `TABLE_NAMES` enthielt `appSettings` nicht — Voll-Backups verloren die Gewichtungs-Config stillschweigend; jetzt ergänzt samt Kommentar, dass neue Tabellen dort manuell nachgezogen werden müssen. Notation → Bewertungs-Vorschlag: `detectNotationRating()` in `notationParser.tsx` (`!`→Not, `(!)`→Partially, `(+)`→Nearly, `+`→Fully) kombiniert mit `lineTags` liefert in `AssessmentTab.tsx` einen "Vorschlag aus Protokoll"-Badge pro Kriterium-Item, unabhängig vom bestehenden "Aus Protokoll"-Button (andere Datenquelle). `AssignmentCreate.tsx`: Assignment-Nummer ist jetzt Pflichtfeld (nur auf Formular-Ebene, Typ bleibt optional wegen Altdaten). Kriterium-Gewichtung auf `0,0–2,0` in `0,1`-Schritten verfeinert (`FeedbackStructureConfig.tsx`, vorher `×1`–`×5`), dabei einen latenten Division-durch-Null-Bug gefixt, der durch das neu erlaubte Gewicht `0` real wurde (`catMode()` in `AssessmentTab.tsx`, `buildGroupMeetingScores()` in `ratingTrend.ts` — siehe zweite Bug-Klasse oben). Sprecher eines bereits erfassten Protokoll-Beitrags kann nachträglich geändert werden (`ConversationTab.tsx`, im Bearbeiten-Modus neben Füllwörtern — ändert sofort `ConversationEntry.instituteeId`, kein separater Speichern-Schritt nötig). N/A-Aggregationsbug in `buildAiPrompt` (`AssignmentFeedbackPage.tsx`) gefixt — war die letzte offene Stelle dieser Bug-Klasse außer der jetzt migrierten `FeedbackDraftPage.tsx`. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (letzter Konsument der Legacy-Tabellen ist jetzt nur noch der Seed-Code), Notizen jetzt korrekt pro Kriterium über `lineTags` statt aller Protokoll-Notizen ungefiltert an jedes Kriterium gehängt, Score-Anzeige auf `RATING_OPTIONS`-Labels umgestellt (statt `/5`-Rohwert). Toter Code (verwaiste AssignmentType/PhaseTemplate-CRUD-Reste) aus `FeedbackStructureConfig.tsx` entfernt. „Dashboard zeigt keine Assignment-Nummer" war bereits gelöst, nur die Doku war veraltet — richtiggestellt. @@ -128,7 +156,8 @@ MeetingView liest: `const { id, meetingId: meetingIdParam } = useParams()` ## Datenschutz -- Alle Daten lokal (IndexedDB) — **kein Public Cloud Upload** +- Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server auf demselben Rechner) statt im Browser-IndexedDB; weiterhin **kein Cloud-Upload**, Server ist nur unter `localhost` erreichbar (kein Auth-Schutz nötig, solange das so bleibt — siehe Offene Punkte, falls sich das ändert, z.B. bei Docker/Linux-Deployment mit Netzwerkzugriff) +- `server/data/` ist per `.gitignore` ausgeschlossen — Datenbankdatei nie committen - GDPR-relevant: Institutee-Namen, Kundennamen, Gesprächsnotizen - KI-API-Key in `localStorage` — nie loggen oder ausgeben - Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index b0d5e84..f1049cc 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -6,7 +6,9 @@ Dieses Dokument ermöglicht einem neuen Chat-Kontext, die Arbeit am Projekt naht ## Stand & nächste Schritte -Zuletzt abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`. +Zuletzt abgeschlossen (2026-07-04): Umstellung von Dexie/IndexedDB auf einen lokalen Node/Express-Server mit `node:sqlite` (RPC-Bridge `POST /api/rpc`), damit mehrere Browser auf demselben Laptop dieselben Daten sehen. Details: `CLAUDE.md` → Abschnitt "Datenbank: lokaler Server statt IndexedDB". **Sofort nächster Schritt: die echten IndexedDB-Daten müssen noch per Backup-Export/-Import in die neue SQLite-DB migriert werden** — siehe CLAUDE.md-Offene-Punkte-Tabelle, Prio M. + +Davor abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ratingTrend.ts`), Kriterium-Zuordnung für Freitext-Notizen (`ConversationEntry.lineTags` + `CriterionTagPicker.tsx`), Notation → Bewertungs-Vorschlag in der Gesamtbewertung, feinere Kriterium-Gewichtung (0,0–2,0) inkl. Division-durch-Null-Fix, diverse Export-/Backup-Fixes. Design-Hintergrund dazu: `docs/EVALUATION_FEEDBACK_REDESIGN.md`. **Vollständige, priorisierte Liste der offenen Punkte:** siehe `CLAUDE.md` → Abschnitt "Offene Punkte (priorisiert)" — das ist die laufend gepflegte Quelle, hier nicht dupliziert. Größere offene Themen, die eine eigene Design-Session brauchen: mehrstufiges Prompt-System für die KI-Generierung, selbstlernendes Tagging. @@ -18,13 +20,15 @@ Zuletzt abgeschlossen (2026-07-02/03): zeitgewichteter Bewertungs-Vorschlag (`ra ## Tech-Stack -- **React 18 + TypeScript + Vite** (PWA) +- **React 18 + TypeScript + Vite** (PWA-Frontend) - **Tailwind CSS v4** (`@import "tailwindcss"` — kein `tailwind.config.js`!) -- **Dexie.js v5** als IndexedDB-Wrapper (lokale DB im Browser, kein Backend) +- **Lokaler Node/Express-Server + `node:sqlite`** (seit 2026-07-04) — löst Dexie/IndexedDB als Datenspeicher ab, siehe CLAUDE.md. `npm run dev:all` startet Frontend + Server zusammen. - **React Router v6** - **Capgemini-Brandfarbe:** `#0070AD` - Arbeitsverzeichnis: `C:\dev\AssigmentMonitor` +**Wichtig für den TypeScript-Check:** `npx tsc --noEmit` prüft am Root **nichts** (Project-References-Setup, `files: []`) — immer `npx tsc -b` verwenden. + ## Bewertungsskala (kritisch für alle Berechnungen) ```typescript @@ -110,13 +114,14 @@ MeetingView liest den Param als: `const { id, meetingId: meetingIdParam } = useP ## Bekannte offene Punkte (Priorität nach Dringlichkeit) ### Mittlere Aufgaben -1. **`db/queries/`-Layer** — DB-Zugriffe aus Komponenten in domänenspezifische Query-Funktionen extrahieren, bereitet SQLite-Migration vor -2. **KI-Generierung funktionsunfähig** — `generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht. +1. **KI-Generierung funktionsunfähig** — `generateWithAi()` schlägt fehl, vermutlich Capgemini-Netzwerk/Firewall blockiert `openrouter.ai`. Root Cause nicht untersucht. ### Große Aufgaben (eigene Design-Session nötig) -3. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus. -4. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem. -5. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten. +2. **SQLite WASM + OPFS** — Migration weg von IndexedDB. Konzept steht, Implementierung steht aus. +3. **Mehrstufiges, pro Dimension konfigurierbares Prompt-System** — löst den aktuellen Einzel-Prompt in `buildAiPrompt` ab, braucht Spezialwissen je Dimension + Platzhaltersystem. +4. **Selbstlernendes Tagging** — aus bisherigen Zeile-zu-Kriterium-Zuordnungen automatische Tag-Vorschläge beim Tippen ableiten. + +**Erledigt (2026-07-04):** `db/queries/`-Layer eingeführt — DB-Zugriffe aus allen 20 betroffenen Komponenten/Utils in domänenspezifische Query-Funktionen unter `src/db/queries/` extrahiert (Details siehe CLAUDE.md). Dabei die drei unabhängigen Implementierungen der gewichteten-Durchschnitt-Logik auf eine gemeinsame Funktion in `ratingTrend.ts` konsolidiert (Nebeneffekt: fehlender `Σweight===0`-Guard in `AssignmentDetail.tsx` gefixt) sowie die duplizierte `criteriaIds`-Filterlogik und die doppelten Meeting-Export-Queries zusammengeführt. **Erledigt (2026-07-03, zweite Runde):** N/A-Aggregationsbug in `buildAiPrompt` gefixt. `FeedbackDraftPage.tsx` vollständig auf `feedbackCriterionItems`/`feedbackCategories` migriert (Notizen jetzt korrekt pro Kriterium über `lineTags`, Score-Anzeige über `RATING_OPTIONS`). Toter Code in `FeedbackStructureConfig.tsx` entfernt. "Dashboard zeigt keine Assignment-Nummer" war bereits gelöst — nur die Doku war veraltet. @@ -159,7 +164,7 @@ Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen ## Datenschutz-Hinweise -- Alle Daten lokal, kein Cloud-Backend +- Alle Daten lokal — seit 2026-07-04 in `server/data/assignment-monitor.sqlite3` (lokaler Server, kein Cloud-Backend), `server/data/` ist gitignored - GDPR-relevant: Institutee-Namen + Kundennamen in der DB - KI-API: OpenRouter, API-Key in localStorage (nicht im Klartext loggen!) - Backup-JSON enthält alle personenbezogenen Daten → vertraulich behandeln @@ -167,11 +172,11 @@ Alle 21 Tabellen-Namen sind in `TABLE_NAMES` aufgelistet — bei neuen Tabellen ## TypeScript-Build ```bash -npx tsc --noEmit # Muss ohne Fehler durchlaufen -npm run dev # Dev-Server starten +npx tsc -b # Muss ohne Fehler durchlaufen — NICHT --noEmit, das prüft am Root nichts (siehe Tech-Stack-Abschnitt) +npm run dev:all # Frontend + lokaler Server zusammen starten ``` -Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben. +Vor jeder Abgabe: `npx tsc -b` ausführen und alle Fehler beheben. ## Wichtige Eigenheiten / Fallstricke @@ -180,6 +185,6 @@ Vor jeder Abgabe: `npx tsc --noEmit` ausführen und alle Fehler beheben. 3. **`PhaseKey`**: Ist `type PhaseKey = string` (kein Union-Typ mehr) 4. **`aType2`** in `AssignmentDetail.tsx`: Doppelte Variable aus Refactoring — `aType` wird zweimal deklariert, zweite heißt `aType2`. Technische Schuld. 5. **`expandedCat`** in `MeetingView`: `Record` — öffnet pro Institutee genau eine Kategorie gleichzeitig -6. **Dexie v5**: `bulkAdd` schlägt fehl wenn IDs schon vorhanden → Backup-Import leert erst alle Tabellen +6. **Backup-Import** (`server/db/queries/backup.ts`, seit 2026-07-04 SQL-basiert): leert alle Tabellen und fügt Zeilen mit expliziter, ursprünglicher `id` wieder ein (`insertRowWithId`), damit Fremdschlüssel-Referenzen im importierten Datensatz stabil bleiben — kein Dexie/`bulkAdd` mehr, siehe CLAUDE.md-Datenbank-Abschnitt 7. **Gewichtete Mittelwerte**: immer `Σ(score×weight)/Σ(weight)`, nie `score×weight` roh interpretieren; bei `Σweight === 0` (seit Gewicht `0` erlaubt ist) `null`/`continue` statt Division — sonst `NaN < x` überall `false` → fällt auf „Fully" durch. Siehe `catMode()` (`AssessmentTab.tsx`) und `buildGroupMeetingScores()` (`ratingTrend.ts`). 8. **`ConversationEntry`** entspricht einem ganzen Sprecher-Turn (mehrzeiliger Text-Block), nicht einer einzelnen Zeile — ein Zwischenstand mit "ein Entry pro Zeile" wurde verworfen. Kriterium-Zuordnung läuft über `lineTags` (Matching per Zeilentext, nicht Index). diff --git a/package-lock.json b/package-lock.json index 72fd8c9..838f0ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,23 +9,27 @@ "version": "0.0.0", "dependencies": { "dexie": "^4.0.11", + "express": "^4.21.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.6.2", - "recharts": "^2.15.3", - "zustand": "^5.0.5" + "recharts": "^2.15.3" }, "devDependencies": { "@eslint/js": "^9.25.0", "@tailwindcss/vite": "^4.1.10", + "@types/express": "^4.17.21", + "@types/node": "^22.10.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.1.2", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "tailwindcss": "^4.1.10", + "tsx": "^4.19.2", "typescript": "~5.8.3", "vite": "^6.3.5", "vite-plugin-pwa": "^1.0.0" @@ -3151,6 +3155,27 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3221,6 +3246,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3228,11 +3286,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "devOptional": true, + "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3256,6 +3345,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3284,6 +3406,19 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -3325,6 +3460,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3365,6 +3510,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", @@ -3492,6 +3643,45 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -3545,6 +3735,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3568,7 +3767,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3582,7 +3780,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3643,6 +3840,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3696,6 +3908,68 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", + "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3716,6 +3990,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -4013,6 +4293,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4043,7 +4342,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4054,6 +4352,12 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4077,6 +4381,22 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/enhanced-resolve": { "version": "5.21.6", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", @@ -4183,7 +4503,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4193,7 +4512,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4203,7 +4521,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4301,6 +4618,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4512,12 +4835,91 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4629,6 +5031,39 @@ "node": ">=10" } }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -4700,6 +5135,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -4735,7 +5188,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4795,11 +5247,20 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4831,7 +5292,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4970,7 +5430,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5042,7 +5501,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5071,7 +5529,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5080,6 +5537,38 @@ "node": ">= 0.4" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5124,6 +5613,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5148,6 +5643,15 @@ "node": ">=12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5325,6 +5829,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6117,12 +6631,71 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6150,7 +6723,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6179,6 +6751,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -6202,7 +6783,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6242,6 +6822,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6330,6 +6922,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6384,6 +6985,12 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6483,6 +7090,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6493,6 +7113,46 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -6736,6 +7396,16 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6823,6 +7493,16 @@ "fsevents": "~2.3.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -6843,6 +7523,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6878,6 +7578,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6894,6 +7600,45 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/serialize-javascript": { "version": "7.0.7", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", @@ -6904,6 +7649,21 @@ "node": ">=20.0.0" } }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -6959,6 +7719,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6982,11 +7748,23 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7006,7 +7784,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7023,7 +7800,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7042,7 +7818,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -7126,6 +7901,15 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -7140,6 +7924,21 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7243,6 +8042,19 @@ "node": ">=4" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -7385,6 +8197,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -7395,6 +8216,526 @@ "punycode": "^2.1.0" } }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7421,6 +8762,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7532,6 +8886,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -7599,6 +8960,15 @@ "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -7651,6 +9021,24 @@ "punycode": "^2.1.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -8176,6 +9564,34 @@ "workbox-core": "7.4.1" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8183,6 +9599,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8195,35 +9640,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index 8be877d..8c7a6a5 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "scripts": { "dev": "vite", + "server": "tsx watch server/index.ts", + "dev:all": "concurrently -n web,api -c blue,green \"npm:dev\" \"npm:server\"", + "verify-backup": "tsx server/scripts/verifyBackupRoundtrip.ts", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" @@ -14,19 +17,24 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.6.2", "dexie": "^4.0.11", - "recharts": "^2.15.3" + "recharts": "^2.15.3", + "express": "^4.21.2" }, "devDependencies": { "@eslint/js": "^9.25.0", "@tailwindcss/vite": "^4.1.10", + "@types/express": "^4.17.21", + "@types/node": "^22.10.5", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.4.1", + "concurrently": "^9.1.2", "eslint": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", "tailwindcss": "^4.1.10", + "tsx": "^4.19.2", "typescript": "~5.8.3", "vite": "^6.3.5", "vite-plugin-pwa": "^1.0.0" diff --git a/server/database.ts b/server/database.ts new file mode 100644 index 0000000..db47569 --- /dev/null +++ b/server/database.ts @@ -0,0 +1,18 @@ +import { DatabaseSync } from 'node:sqlite' +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { TABLE_DEFINITIONS } from './db/tableRegistry' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const dbPath = join(__dirname, 'data', 'assignment-monitor.sqlite3') +mkdirSync(dirname(dbPath), { recursive: true }) + +export const db = new DatabaseSync(dbPath) + +for (const table of TABLE_DEFINITIONS) { + const columnsSql = table.columns.map(c => `"${c.name}" ${c.sqlType}`).join(', ') + db.exec(`CREATE TABLE IF NOT EXISTS "${table.name}" (id INTEGER PRIMARY KEY AUTOINCREMENT, ${columnsSql})`) +} + +console.log(`[db] SQLite bereit unter ${dbPath}`) diff --git a/server/db/crud.ts b/server/db/crud.ts new file mode 100644 index 0000000..7e5f8d6 --- /dev/null +++ b/server/db/crud.ts @@ -0,0 +1,125 @@ +/** + * Generische SQL-CRUD-Bausteine über die Tabellen-Registry (tableRegistry.ts) — von allen + * server/db/queries/*.ts-Modulen genutzt, damit Spalten-Codecs (JSON/Bool) nur an einer + * Stelle behandelt werden. + */ +import { db } from '../database' +import { TABLE_DEFINITIONS, type TableDef, type ColumnCodec } from './tableRegistry' + +const tablesByName = new Map(TABLE_DEFINITIONS.map(t => [t.name, t])) + +function requireTable(name: string): TableDef { + const t = tablesByName.get(name) + if (!t) throw new Error(`Unbekannte Tabelle: ${name}`) + return t +} + +type SqlPrimitive = number | string | bigint | null + +function toBindValue(raw: unknown, codec: ColumnCodec): SqlPrimitive { + if (raw === undefined || raw === null) return null + if (codec === 'json') return JSON.stringify(raw) + if (codec === 'bool') return raw ? 1 : 0 + return raw as SqlPrimitive +} + +function fromSqlValue(raw: unknown, codec: ColumnCodec): unknown { + if (raw === null || raw === undefined) return null + if (codec === 'json') return JSON.parse(raw as string) + if (codec === 'bool') return raw === 1 + return raw +} + +export function decodeRow(table: TableDef, row: Record): T { + const result: Record = { id: row.id } + for (const c of table.columns) result[c.name] = fromSqlValue(row[c.name], c.codec) + return result as T +} + +export function insertRow(tableName: string, obj: Record): number { + const table = requireTable(tableName) + const cols = table.columns.map(c => `"${c.name}"`).join(', ') + const placeholders = table.columns.map(() => '?').join(', ') + const values = table.columns.map(c => toBindValue(obj[c.name], c.codec)) + const info = db.prepare(`INSERT INTO "${tableName}" (${cols}) VALUES (${placeholders})`).run(...values) + return Number(info.lastInsertRowid) +} + +/** Für Backup-Restore: fügt eine Zeile mit expliziter id ein, damit Fremdschlüssel-Referenzen im importierten Datensatz stabil bleiben. */ +export function insertRowWithId(tableName: string, id: number, obj: Record): void { + const table = requireTable(tableName) + const cols = ['"id"', ...table.columns.map(c => `"${c.name}"`)].join(', ') + const placeholders = ['?', ...table.columns.map(() => '?')].join(', ') + const values: SqlPrimitive[] = [id, ...table.columns.map(c => toBindValue(obj[c.name], c.codec))] + db.prepare(`INSERT INTO "${tableName}" (${cols}) VALUES (${placeholders})`).run(...values) +} + +export function updateRow(tableName: string, id: number, patch: Record): number { + const table = requireTable(tableName) + const entries = Object.entries(patch).filter(([k]) => k !== 'id') + if (entries.length === 0) return 0 + const setSql = entries.map(([k]) => `"${k}" = ?`).join(', ') + const values = entries.map(([k, v]) => { + const colDef = table.columns.find(c => c.name === k) + return toBindValue(v, colDef?.codec ?? 'plain') + }) + const info = db.prepare(`UPDATE "${tableName}" SET ${setSql} WHERE id = ?`).run(...values, id) + return Number(info.changes) +} + +export function getRowById(tableName: string, id: number): T | undefined { + const table = requireTable(tableName) + const row = db.prepare(`SELECT * FROM "${tableName}" WHERE id = ?`).get(id) as Record | undefined + return row ? decodeRow(table, row) : undefined +} + +export function getAllRows(tableName: string): T[] { + const table = requireTable(tableName) + const rows = db.prepare(`SELECT * FROM "${tableName}"`).all() as Record[] + return rows.map(r => decodeRow(table, r)) +} + +export function getAllRowsOrderedBy(tableName: string, column: string): T[] { + const table = requireTable(tableName) + const rows = db.prepare(`SELECT * FROM "${tableName}" ORDER BY "${column}" ASC`).all() as Record[] + return rows.map(r => decodeRow(table, r)) +} + +export function getRowsWhereEquals(tableName: string, column: string, value: SqlPrimitive): T[] { + const table = requireTable(tableName) + const rows = db.prepare(`SELECT * FROM "${tableName}" WHERE "${column}" = ?`).all(value) as Record[] + return rows.map(r => decodeRow(table, r)) +} + +export function getRowsWhereIn(tableName: string, column: string, values: number[]): T[] { + if (values.length === 0) return [] + const table = requireTable(tableName) + const placeholders = values.map(() => '?').join(', ') + const rows = db.prepare(`SELECT * FROM "${tableName}" WHERE "${column}" IN (${placeholders})`).all(...values) as Record[] + return rows.map(r => decodeRow(table, r)) +} + +export function deleteRowById(tableName: string, id: number): void { + requireTable(tableName) + db.prepare(`DELETE FROM "${tableName}" WHERE id = ?`).run(id) +} + +export function deleteRowsWhereEquals(tableName: string, column: string, value: SqlPrimitive): number { + requireTable(tableName) + const info = db.prepare(`DELETE FROM "${tableName}" WHERE "${column}" = ?`).run(value) + return Number(info.changes) +} + +export function deleteRowsWhereIn(tableName: string, column: string, values: number[]): number { + if (values.length === 0) return 0 + requireTable(tableName) + const placeholders = values.map(() => '?').join(', ') + const info = db.prepare(`DELETE FROM "${tableName}" WHERE "${column}" IN (${placeholders})`).run(...values) + return Number(info.changes) +} + +export function countWhereEquals(tableName: string, column: string, value: SqlPrimitive): number { + requireTable(tableName) + const row = db.prepare(`SELECT COUNT(*) as c FROM "${tableName}" WHERE "${column}" = ?`).get(value) as { c: number } + return row.c +} diff --git a/server/db/queries/appSettings.ts b/server/db/queries/appSettings.ts new file mode 100644 index 0000000..d236546 --- /dev/null +++ b/server/db/queries/appSettings.ts @@ -0,0 +1,26 @@ +import { getRowById, insertRowWithId, updateRow } from '../crud' +import type { AppSettings } from '../../../src/db/types' + +/** Muss mit DEFAULT_RATING_TREND_WEIGHT_STEP in src/config/constants.ts synchron gehalten werden. */ +const DEFAULT_RATING_TREND_WEIGHT_STEP = 0.5 + +export function getRatingTrendWeightStep(): number { + const settings = getRowById('appSettings', 1) + return settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP +} + +export function getOrSeedRatingTrendWeightStep(): number { + let settings = getRowById('appSettings', 1) + if (!settings) { + insertRowWithId('appSettings', 1, { ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP }) + settings = getRowById('appSettings', 1) + } + return settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP +} + +export function saveRatingTrendWeightStep(weightStep: number): number { + const existing = getRowById('appSettings', 1) + if (existing) updateRow('appSettings', 1, { ratingTrendWeightStep: weightStep }) + else insertRowWithId('appSettings', 1, { ratingTrendWeightStep: weightStep }) + return 1 +} diff --git a/server/db/queries/assessments.ts b/server/db/queries/assessments.ts new file mode 100644 index 0000000..6d7bdfe --- /dev/null +++ b/server/db/queries/assessments.ts @@ -0,0 +1,12 @@ +import { getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowsWhereEquals } from '../crud' +import type { Assessment } from '../../../src/db/types' + +export const listAssessmentsAll = (): Assessment[] => getAllRows('assessments') +export const listAssessmentsForMeeting = (meetingInstanceId: number): Assessment[] => + getRowsWhereEquals('assessments', 'meetingInstanceId', meetingInstanceId) +export const listAssessmentsForMeetings = (meetingInstanceIds: number[]): Assessment[] => + getRowsWhereIn('assessments', 'meetingInstanceId', meetingInstanceIds) +export const addAssessment = (a: Omit): number => insertRow('assessments', a) +export const updateAssessment = (id: number, patch: Partial): number => updateRow('assessments', id, patch) +export const deleteAssessmentsForMeeting = (meetingInstanceId: number): number => + deleteRowsWhereEquals('assessments', 'meetingInstanceId', meetingInstanceId) diff --git a/server/db/queries/assignmentFeedback.ts b/server/db/queries/assignmentFeedback.ts new file mode 100644 index 0000000..9ab9856 --- /dev/null +++ b/server/db/queries/assignmentFeedback.ts @@ -0,0 +1,44 @@ +import { getRowById, getRowsWhereEquals, insertRow, updateRow } from '../crud' +import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText } from '../../../src/db/types' + +export const getFeedbackDraft = (assignmentId: number, instituteeId: number): FeedbackDraft | undefined => + getRowsWhereEquals('feedbackDrafts', 'assignmentId', assignmentId).find(d => d.instituteeId === instituteeId) +export const addFeedbackDraft = (d: Omit): number => insertRow('feedbackDrafts', d) +export const updateFeedbackDraft = (id: number, patch: Partial): number => updateRow('feedbackDrafts', id, patch) + +export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): AssignmentFeedback | undefined => + getRowsWhereEquals('assignmentFeedbacks', 'assignmentId', assignmentId).find(f => f.instituteeId === instituteeId) +export const getAssignmentFeedbackById = (id: number): AssignmentFeedback | undefined => + getRowById('assignmentFeedbacks', id) +export const addAssignmentFeedback = (f: Omit): number => insertRow('assignmentFeedbacks', f) +export const updateAssignmentFeedback = (id: number, patch: Partial): number => + updateRow('assignmentFeedbacks', id, patch) + +export function getOrCreateAssignmentFeedback(assignmentId: number, instituteeId: number): AssignmentFeedback | undefined { + let fb = getAssignmentFeedback(assignmentId, instituteeId) + if (!fb) { + const fbId = addAssignmentFeedback({ + assignmentId, instituteeId, status: 'draft', aiModel: '', updatedAt: new Date().toISOString(), + }) + fb = getAssignmentFeedbackById(fbId) + } + return fb +} + +export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): FeedbackCategoryRating[] => + getRowsWhereEquals('feedbackCategoryRatings', 'assignmentFeedbackId', assignmentFeedbackId) +export const addFeedbackCategoryRating = (r: Omit): number => insertRow('feedbackCategoryRatings', r) +export const updateFeedbackCategoryRating = (id: number, patch: Partial): number => + updateRow('feedbackCategoryRatings', id, patch) + +export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): FeedbackDimensionText[] => + getRowsWhereEquals('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId) +export const getFeedbackDimensionText = ( + assignmentFeedbackId: number, + feedbackDimensionId: number, +): FeedbackDimensionText | undefined => + getRowsWhereEquals('feedbackDimensionTexts', 'assignmentFeedbackId', assignmentFeedbackId) + .find(t => t.feedbackDimensionId === feedbackDimensionId) +export const addFeedbackDimensionText = (t: Omit): number => insertRow('feedbackDimensionTexts', t) +export const updateFeedbackDimensionText = (id: number, patch: Partial): number => + updateRow('feedbackDimensionTexts', id, patch) diff --git a/server/db/queries/assignmentTypes.ts b/server/db/queries/assignmentTypes.ts new file mode 100644 index 0000000..7dcff70 --- /dev/null +++ b/server/db/queries/assignmentTypes.ts @@ -0,0 +1,31 @@ +import { + getAllRows, getRowById, insertRow, updateRow, + getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, countWhereEquals, +} from '../crud' +import type { AssignmentType, PhaseTemplate } from '../../../src/db/types' + +export const listAssignmentTypes = (): AssignmentType[] => getAllRows('assignmentTypes') +export const getAssignmentType = (id: number): AssignmentType | undefined => getRowById('assignmentTypes', id) +export const addAssignmentType = (type: Omit): number => insertRow('assignmentTypes', type) +export const updateAssignmentType = (id: number, patch: Partial): number => + updateRow('assignmentTypes', id, patch) + +/** Löscht nur, wenn keine Assignments mehr diesen Typ referenzieren (Cascade auf phaseTemplates sonst). */ +export function deleteAssignmentType(id: number): { ok: true } | { ok: false; assignmentCount: number } { + const assignmentCount = countWhereEquals('assignments', 'assignmentTypeId', id) + if (assignmentCount > 0) return { ok: false, assignmentCount } + deleteRowsWhereEquals('phaseTemplates', 'assignmentTypeId', id) + deleteRowById('assignmentTypes', id) + return { ok: true } +} + +export const listPhaseTemplates = (): PhaseTemplate[] => getAllRows('phaseTemplates') +export const getPhaseTemplate = (id: number): PhaseTemplate | undefined => getRowById('phaseTemplates', id) +export const listPhaseTemplatesForType = (assignmentTypeId: number): PhaseTemplate[] => { + const rows = getRowsWhereEquals('phaseTemplates', 'assignmentTypeId', assignmentTypeId) + return rows.sort((a, b) => a.order - b.order) +} +export const addPhaseTemplate = (tmpl: Omit): number => insertRow('phaseTemplates', tmpl) +export const updatePhaseTemplate = (id: number, patch: Partial): number => + updateRow('phaseTemplates', id, patch) +export const deletePhaseTemplate = (id: number): void => deleteRowById('phaseTemplates', id) diff --git a/server/db/queries/assignments.ts b/server/db/queries/assignments.ts new file mode 100644 index 0000000..93fe887 --- /dev/null +++ b/server/db/queries/assignments.ts @@ -0,0 +1,31 @@ +import { getAllRows, getRowById, insertRow, updateRow } from '../crud' +import type { Assignment, AssignmentType, Consultant } from '../../../src/db/types' + +export const listAssignments = (): Assignment[] => getAllRows('assignments') +export const listAssignmentsNewestFirst = (): Assignment[] => + getAllRows('assignments').sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) +export const getAssignment = (id: number): Assignment | undefined => getRowById('assignments', id) +export const addAssignment = (a: Omit): number => insertRow('assignments', a) +export const updateAssignment = (id: number, patch: Partial): number => updateRow('assignments', id, patch) + +export interface AssignmentWithDetails extends Assignment { + typeName: string + instituteeNames: string[] +} + +/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */ +export function listAssignmentsWithDetails(): AssignmentWithDetails[] { + const raw = listAssignmentsNewestFirst() + const types = getAllRows('assignmentTypes') + const consultants = getAllRows('consultants') + const typeMap = new Map(types.map(t => [t.id!, t])) + const consMap = new Map(consultants.map(c => [c.id!, c])) + return raw.map(a => ({ + ...a, + typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—', + instituteeNames: a.instituteeIds.map(id => { + const c = consMap.get(id) + return c ? `${c.firstName} ${c.lastName}` : '?' + }), + })) +} diff --git a/server/db/queries/backup.ts b/server/db/queries/backup.ts new file mode 100644 index 0000000..8b1e697 --- /dev/null +++ b/server/db/queries/backup.ts @@ -0,0 +1,45 @@ +/** + * Generischer Dump/Restore aller Tabellen — Server-Pendant zum bisherigen client-seitigen + * dbBackup.ts (das direkt gegen Dexie/IndexedDB lief). Iteriert exakt TABLE_DEFINITIONS aus + * tableRegistry.ts, derselben Liste, die auch das SQL-Schema erzeugt (siehe Vollständigkeits- + * Garantie im Plan) — eine neue Tabelle kann dadurch nicht unbemerkt aus dem Backup fallen. + */ +import { db } from '../../database' +import { TABLE_DEFINITIONS } from '../tableRegistry' +import { getAllRows, insertRowWithId } from '../crud' + +export interface BackupPayload { + version: number + exportedAt: string + tables: Record>> +} + +/** Liefert jede Tabelle mit dekodierten Spalten (echte Arrays/Booleans statt JSON-/Bit-Rohwerten). */ +export function exportAll(): BackupPayload { + const tables: Record>> = {} + for (const table of TABLE_DEFINITIONS) { + tables[table.name] = getAllRows>(table.name) + } + return { version: 1, exportedAt: new Date().toISOString(), tables } +} + +/** Löscht alle Tabellen und fügt die übergebenen Zeilen mit ihrer ursprünglichen id wieder ein (erhält Fremdschlüssel-Referenzen). */ +export function importAll(tables: Record>>): void { + db.exec('BEGIN') + try { + for (const table of TABLE_DEFINITIONS) { + db.prepare(`DELETE FROM "${table.name}"`).run() + } + for (const table of TABLE_DEFINITIONS) { + for (const row of tables[table.name] ?? []) { + const { id, ...rest } = row + if (typeof id !== 'number') continue + insertRowWithId(table.name, id, rest) + } + } + db.exec('COMMIT') + } catch (err) { + db.exec('ROLLBACK') + throw err + } +} diff --git a/server/db/queries/consultants.ts b/server/db/queries/consultants.ts new file mode 100644 index 0000000..125d828 --- /dev/null +++ b/server/db/queries/consultants.ts @@ -0,0 +1,12 @@ +import { getAllRows, getRowById, getRowsWhereIn, insertRow, updateRow, deleteRowById } from '../crud' +import type { Group, Consultant } from '../../../src/db/types' + +export const listGroups = (): Group[] => getAllRows('groups') +export const addGroup = (group: Omit): number => insertRow('groups', group) + +export const listConsultants = (): Consultant[] => getAllRows('consultants') +export const getConsultant = (id: number): Consultant | undefined => getRowById('consultants', id) +export const getConsultantsByIds = (ids: number[]): Consultant[] => getRowsWhereIn('consultants', 'id', ids) +export const addConsultant = (consultant: Omit): number => insertRow('consultants', consultant) +export const updateConsultant = (id: number, patch: Partial): number => updateRow('consultants', id, patch) +export const deleteConsultant = (id: number): void => deleteRowById('consultants', id) diff --git a/server/db/queries/conversationEntries.ts b/server/db/queries/conversationEntries.ts new file mode 100644 index 0000000..ee81d56 --- /dev/null +++ b/server/db/queries/conversationEntries.ts @@ -0,0 +1,19 @@ +import { getAllRows, getRowsWhereEquals, getRowsWhereIn, insertRow, updateRow, deleteRowById, deleteRowsWhereEquals } from '../crud' +import type { ConversationEntry, ConversationSkillScore } from '../../../src/db/types' + +export const listConversationEntriesForMeeting = (meetingInstanceId: number): ConversationEntry[] => + getRowsWhereEquals('conversationEntries', 'meetingInstanceId', meetingInstanceId) + .sort((a, b) => a.sequenceIndex - b.sequenceIndex) +export const listConversationEntriesForMeetings = (meetingInstanceIds: number[]): ConversationEntry[] => + getRowsWhereIn('conversationEntries', 'meetingInstanceId', meetingInstanceIds) +export const addConversationEntry = (e: Omit): number => insertRow('conversationEntries', e) +export const updateConversationEntry = (id: number, patch: Partial): number => + updateRow('conversationEntries', id, patch) +export const deleteConversationEntry = (id: number): void => deleteRowById('conversationEntries', id) + +export const listConversationSkillScoresAll = (): ConversationSkillScore[] => getAllRows('conversationSkillScores') +export const addConversationSkillScore = (s: Omit): number => insertRow('conversationSkillScores', s) +export const updateConversationSkillScore = (id: number, patch: Partial): number => + updateRow('conversationSkillScores', id, patch) +export const deleteConversationSkillScoresForEntry = (conversationEntryId: number): number => + deleteRowsWhereEquals('conversationSkillScores', 'conversationEntryId', conversationEntryId) diff --git a/server/db/queries/feedbackStructure.ts b/server/db/queries/feedbackStructure.ts new file mode 100644 index 0000000..6a1cd38 --- /dev/null +++ b/server/db/queries/feedbackStructure.ts @@ -0,0 +1,57 @@ +import { + getAllRows, getRowsWhereIn, insertRow, updateRow, + getRowsWhereEquals, deleteRowsWhereEquals, deleteRowById, +} from '../crud' +import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../../../src/db/types' + +export interface FeedbackStructure { + dimensions: FeedbackDimension[] + categories: FeedbackCategory[] + items: FeedbackCriterionItem[] +} + +const byOrder = (rows: T[]) => [...rows].sort((a, b) => a.order - b.order) + +/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */ +export function loadFeedbackStructure(): FeedbackStructure { + return { + dimensions: byOrder(getAllRows('feedbackDimensions')), + categories: byOrder(getAllRows('feedbackCategories')), + items: byOrder(getAllRows('feedbackCriterionItems')), + } +} + +export const listFeedbackCategoriesOrdered = (): FeedbackCategory[] => byOrder(getAllRows('feedbackCategories')) +export const listFeedbackCategoriesAll = (): FeedbackCategory[] => getAllRows('feedbackCategories') +export const listFeedbackCriterionItemsAll = (): FeedbackCriterionItem[] => getAllRows('feedbackCriterionItems') +export const getFeedbackCriterionItemsByIds = (ids: number[]): FeedbackCriterionItem[] => + getRowsWhereIn('feedbackCriterionItems', 'id', ids) + +export const addFeedbackDimension = (d: Omit): number => insertRow('feedbackDimensions', d) +export const updateFeedbackDimension = (id: number, patch: Partial): number => + updateRow('feedbackDimensions', id, patch) + +/** Cascade: Items der Kategorien → Kategorien → Dimension. */ +export function deleteFeedbackDimension(id: number): void { + const cats = getRowsWhereEquals('feedbackCategories', 'dimensionId', id) + for (const cat of cats) { + deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', cat.id!) + } + deleteRowsWhereEquals('feedbackCategories', 'dimensionId', id) + deleteRowById('feedbackDimensions', id) +} + +export const addFeedbackCategory = (c: Omit): number => insertRow('feedbackCategories', c) +export const updateFeedbackCategory = (id: number, patch: Partial): number => + updateRow('feedbackCategories', id, patch) + +/** Cascade: Items der Kategorie → Kategorie. */ +export function deleteFeedbackCategory(id: number): void { + deleteRowsWhereEquals('feedbackCriterionItems', 'categoryId', id) + deleteRowById('feedbackCategories', id) +} + +export const addFeedbackCriterionItem = (i: Omit): number => insertRow('feedbackCriterionItems', i) +export const updateFeedbackCriterionItem = (id: number, patch: Partial): number => + updateRow('feedbackCriterionItems', id, patch) +export const deleteFeedbackCriterionItem = (id: number): void => deleteRowById('feedbackCriterionItems', id) diff --git a/server/db/queries/meetings.ts b/server/db/queries/meetings.ts new file mode 100644 index 0000000..d944aaa --- /dev/null +++ b/server/db/queries/meetings.ts @@ -0,0 +1,69 @@ +import { + getAllRows, getRowById, getRowsWhereEquals, insertRow, updateRow, + deleteRowsWhereEquals, deleteRowsWhereIn, deleteRowById, +} from '../crud' +import type { + MeetingInstance, PhaseTemplate, Assignment, Consultant, + ConversationEntry, ConversationSkillScore, Assessment, +} from '../../../src/db/types' +import { getConsultantsByIds } from './consultants' + +export const getMeetingInstance = (id: number): MeetingInstance | undefined => getRowById('meetingInstances', id) +export const listMeetingInstancesForAssignment = (assignmentId: number): MeetingInstance[] => + getRowsWhereEquals('meetingInstances', 'assignmentId', assignmentId) + +/** + * Kanonischer Filter für Aggregationen über mehrere Meetings: Papierkorb-/unfertige Meetings ausschließen, + * chronologisch sortiert. Siehe CLAUDE.md — immer diese Funktion nutzen, nicht erneut inline filtern. + */ +export function listDoneMeetingsChronological(assignmentId: number): MeetingInstance[] { + const instances = listMeetingInstancesForAssignment(assignmentId) + return instances + .filter(m => !m.deletedAt && m.status === 'done') + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) +} + +export const addMeetingInstance = (m: Omit): number => insertRow('meetingInstances', m) +export const updateMeetingInstance = (id: number, patch: Partial): number => + updateRow('meetingInstances', id, patch) +export const softDeleteMeetingInstance = (id: number): number => + updateRow('meetingInstances', id, { deletedAt: new Date().toISOString() }) +export const restoreMeetingInstance = (id: number): number => + updateRow('meetingInstances', id, { deletedAt: null }) + +/** Cascade: conversationSkillScores der Entries → conversationEntries → assessments → meetingInstance. */ +export function hardDeleteMeetingInstance(id: number): void { + const entryIds = getRowsWhereEquals('conversationEntries', 'meetingInstanceId', id).map(e => e.id!) + deleteRowsWhereIn('conversationSkillScores', 'conversationEntryId', entryIds) + deleteRowsWhereEquals('conversationEntries', 'meetingInstanceId', id) + deleteRowsWhereEquals('assessments', 'meetingInstanceId', id) + deleteRowById('meetingInstances', id) +} + +export interface MeetingExportData { + meeting: MeetingInstance + template: PhaseTemplate | undefined + assignment: Assignment | undefined + consultants: Consultant[] + entries: ConversationEntry[] + allScores: ConversationSkillScore[] + assessments: Assessment[] +} + +/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */ +export function getMeetingExportData(meetingId: number): MeetingExportData { + const meeting = getRowById('meetingInstances', meetingId) + if (!meeting) throw new Error('Meeting nicht gefunden') + + const template = getRowById('phaseTemplates', meeting.phaseTemplateId) + const assignment = getRowById('assignments', meeting.assignmentId) + + const instituteeIds = assignment?.instituteeIds ?? [] + const consultants = getConsultantsByIds(instituteeIds) + const entries = getRowsWhereEquals('conversationEntries', 'meetingInstanceId', meetingId) + .sort((a, b) => a.sequenceIndex - b.sequenceIndex) + const allScores = getAllRows('conversationSkillScores') + const assessments = getRowsWhereEquals('assessments', 'meetingInstanceId', meetingId) + + return { meeting, template, assignment, consultants, entries, allScores, assessments } +} diff --git a/server/db/tableRegistry.ts b/server/db/tableRegistry.ts new file mode 100644 index 0000000..c901e97 --- /dev/null +++ b/server/db/tableRegistry.ts @@ -0,0 +1,160 @@ +/** + * Einzige Quelle der Wahrheit für alle SQL-Tabellen — treibt sowohl die Schema-Erstellung + * (database.ts) als auch Export/Import (queries/backup.ts) an. Eine neue Tabelle, die hier + * fehlt, fehlt dadurch gleichzeitig im Schema UND im Backup — sichtbarer Fehler statt eines + * Datenverlusts, der erst beim nächsten Restore aus dem Backup auffällt (siehe CLAUDE.md: + * appSettings fehlte einmal in TABLE_NAMES). + * + * 1:1 abgeleitet aus TABLE_NAMES (src/utils/dbBackup.ts) und den Interfaces in src/db/types.ts. + */ + +export type ColumnCodec = 'json' | 'bool' | 'plain' + +export interface ColumnDef { + name: string + sqlType: 'INTEGER' | 'TEXT' | 'REAL' + codec: ColumnCodec +} + +export interface TableDef { + name: string + columns: ColumnDef[] +} + +const col = (name: string, sqlType: ColumnDef['sqlType'], codec: ColumnCodec = 'plain'): ColumnDef => + ({ name, sqlType, codec }) + +export const TABLE_DEFINITIONS: TableDef[] = [ + { name: 'categories', columns: [ + col('name', 'TEXT'), + col('order', 'INTEGER'), + ]}, + { name: 'criteria', columns: [ + col('categoryId', 'INTEGER'), + col('name', 'TEXT'), + col('description', 'TEXT'), + col('weight', 'REAL'), + col('order', 'INTEGER'), + ]}, + { name: 'assignmentTypes', columns: [ + col('name', 'TEXT'), + col('defaultPhaseKeys', 'TEXT', 'json'), + col('criteriaIds', 'TEXT', 'json'), + ]}, + { name: 'phaseTemplates', columns: [ + col('assignmentTypeId', 'INTEGER'), + col('phaseKey', 'TEXT'), + col('label', 'TEXT'), + col('withWhom', 'TEXT'), + col('isRepeatable', 'INTEGER', 'bool'), + col('hasConversation', 'INTEGER', 'bool'), + col('hasAssessment', 'INTEGER', 'bool'), + col('criteriaIds', 'TEXT', 'json'), + col('order', 'INTEGER'), + ]}, + { name: 'groups', columns: [ + col('name', 'TEXT'), + col('cohortYear', 'INTEGER'), + ]}, + { name: 'consultants', columns: [ + col('groupId', 'INTEGER'), + col('firstName', 'TEXT'), + col('lastName', 'TEXT'), + ]}, + { name: 'assignments', columns: [ + col('title', 'TEXT'), + col('client', 'TEXT'), + col('assignmentTypeId', 'INTEGER'), + col('instituteeIds', 'TEXT', 'json'), + col('leadName', 'TEXT'), + col('status', 'TEXT'), + col('createdAt', 'TEXT'), + col('assignmentNumber', 'TEXT'), + col('description', 'TEXT'), + col('instituteeEnrollments', 'TEXT', 'json'), + ]}, + { name: 'meetingInstances', columns: [ + col('assignmentId', 'INTEGER'), + col('phaseTemplateId', 'INTEGER'), + col('phaseIndex', 'INTEGER'), + col('date', 'TEXT'), + col('status', 'TEXT'), + col('generalNotes', 'TEXT'), + col('deletedAt', 'TEXT'), + ]}, + { name: 'conversationEntries', columns: [ + col('meetingInstanceId', 'INTEGER'), + col('instituteeId', 'INTEGER'), + col('sequenceIndex', 'INTEGER'), + col('note', 'TEXT'), + col('fillerCount', 'INTEGER'), + col('updatedAt', 'TEXT'), + col('lineTags', 'TEXT', 'json'), + ]}, + { name: 'conversationSkillScores', columns: [ + col('conversationEntryId', 'INTEGER'), + col('criteriaId', 'INTEGER'), + col('score', 'TEXT'), + ]}, + { name: 'assessments', columns: [ + col('meetingInstanceId', 'INTEGER'), + col('instituteeId', 'INTEGER'), + col('criteriaId', 'INTEGER'), + col('score', 'TEXT'), + col('note', 'TEXT'), + col('updatedAt', 'TEXT'), + ]}, + { name: 'feedbackDrafts', columns: [ + col('assignmentId', 'INTEGER'), + col('instituteeId', 'INTEGER'), + col('content', 'TEXT'), + col('updatedAt', 'TEXT'), + ]}, + { name: 'feedbackDimensions', columns: [ + col('name', 'TEXT'), + col('order', 'INTEGER'), + ]}, + { name: 'feedbackCategories', columns: [ + col('dimensionId', 'INTEGER'), + col('name', 'TEXT'), + col('order', 'INTEGER'), + ]}, + { name: 'feedbackCriterionItems', columns: [ + col('categoryId', 'INTEGER'), + col('name', 'TEXT'), + col('weight', 'REAL'), + col('order', 'INTEGER'), + ]}, + { name: 'assignmentFeedbacks', columns: [ + col('assignmentId', 'INTEGER'), + col('instituteeId', 'INTEGER'), + col('status', 'TEXT'), + col('aiModel', 'TEXT'), + col('updatedAt', 'TEXT'), + ]}, + { name: 'feedbackCategoryRatings', columns: [ + col('assignmentFeedbackId', 'INTEGER'), + col('feedbackCategoryId', 'INTEGER'), + col('rating', 'TEXT'), + ]}, + { name: 'feedbackDimensionTexts', columns: [ + col('assignmentFeedbackId', 'INTEGER'), + col('feedbackDimensionId', 'INTEGER'), + col('achievements', 'TEXT'), + col('developmentNeeds', 'TEXT'), + ]}, + { name: 'criterionCategoryMappings', columns: [ + col('criterionId', 'INTEGER'), + col('feedbackCategoryId', 'INTEGER'), + ]}, + { name: 'criterionLevelDescriptions', columns: [ + col('criterionItemId', 'INTEGER'), + col('rating', 'TEXT'), + col('text', 'TEXT'), + ]}, + { name: 'appSettings', columns: [ + col('ratingTrendWeightStep', 'REAL'), + ]}, +] + +export const TABLE_NAMES = TABLE_DEFINITIONS.map(t => t.name) diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..40c0bad --- /dev/null +++ b/server/index.ts @@ -0,0 +1,29 @@ +import express from 'express' +import './database' +import { rpcModules } from './rpcModules' + +const app = express() +app.use(express.json({ limit: '50mb' })) // Backup-Import kann alle Tabellen als ein JSON-Body enthalten + +app.post('/api/rpc', (req, res) => { + const { module, fn, args } = req.body as { module?: string; fn?: string; args?: unknown[] } + + const mod = module ? rpcModules[module] : undefined + const handler = fn ? mod?.[fn] : undefined + if (!mod || typeof handler !== 'function') { + res.json({ ok: false, error: `Unbekannte RPC-Zielfunktion: ${module}.${fn}` }) + return + } + + try { + const result = handler(...(args ?? [])) + res.json({ ok: true, result: result ?? null }) + } catch (err) { + res.json({ ok: false, error: err instanceof Error ? err.message : 'Unbekannter Server-Fehler' }) + } +}) + +const port = process.env.API_PORT ? Number(process.env.API_PORT) : 4000 +app.listen(port, () => { + console.log(`[server] RPC-API läuft auf http://localhost:${port}/api/rpc`) +}) diff --git a/server/rpcModules.ts b/server/rpcModules.ts new file mode 100644 index 0000000..89be3ca --- /dev/null +++ b/server/rpcModules.ts @@ -0,0 +1,33 @@ +import * as appSettings from './db/queries/appSettings' +import * as consultants from './db/queries/consultants' +import * as assignmentTypes from './db/queries/assignmentTypes' +import * as assignments from './db/queries/assignments' +import * as feedbackStructure from './db/queries/feedbackStructure' +import * as meetings from './db/queries/meetings' +import * as conversationEntries from './db/queries/conversationEntries' +import * as assessments from './db/queries/assessments' +import * as assignmentFeedback from './db/queries/assignmentFeedback' +import * as backup from './db/queries/backup' + +const modules = { + appSettings, + consultants, + assignmentTypes, + assignments, + feedbackStructure, + meetings, + conversationEntries, + assessments, + assignmentFeedback, + backup, +} + +export type RpcModuleName = keyof typeof modules + +/** + * Modul-/Funktionsname kommen zur Laufzeit aus dem HTTP-Body, nicht aus statisch bekannten + * Aufrufstellen — `any[]` ist hier eine bewusste, eng begrenzte Typgrenze für den dynamischen + * Dispatch in index.ts (wie bei Express' req.body), nicht nachlässige Typisierung. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const rpcModules = modules as Record unknown> | undefined> diff --git a/server/scripts/verifyBackupRoundtrip.ts b/server/scripts/verifyBackupRoundtrip.ts new file mode 100644 index 0000000..3af7b39 --- /dev/null +++ b/server/scripts/verifyBackupRoundtrip.ts @@ -0,0 +1,66 @@ +/** + * Harter Vollständigkeits-Beweis für Backup-Export/-Import (siehe Plan "Vollständigkeits-Garantie"). + * Nimmt eine zuvor exportierte Backup-JSON (egal ob aus der alten Dexie-Version oder aus einem + * früheren exportAll()), importiert sie in die aktuelle SQLite-DB, exportiert sofort wieder und + * vergleicht pro Tabelle Zeilenzahl + Inhalt. ACHTUNG: überschreibt den aktuellen Inhalt der + * lokalen SQLite-Datenbank mit dem Inhalt der Backup-Datei. + * + * Aufruf: npm run verify-backup -- + */ +import { readFileSync } from 'node:fs' +import '../database' +import { TABLE_DEFINITIONS } from '../db/tableRegistry' +import { exportAll, importAll } from '../db/queries/backup' + +const filePath = process.argv[2] +if (!filePath) { + console.error('Nutzung: npm run verify-backup -- ') + process.exit(1) +} + +const backup = JSON.parse(readFileSync(filePath, 'utf8')) as { tables?: Record>> } +if (!backup.tables || typeof backup.tables !== 'object') { + console.error('Ungültiges Backup-Format: kein "tables"-Feld gefunden.') + process.exit(1) +} +const sourceTables = backup.tables + +console.log(`Importiere ${filePath} in die SQLite-DB ...`) +importAll(sourceTables) + +console.log('Exportiere sofort wieder zum Vergleich ...\n') +const reexported = exportAll() + +const byId = (rows: Array>) => + [...rows].sort((a, b) => (a.id as number) - (b.id as number)) + +let allPass = true + +for (const table of TABLE_DEFINITIONS) { + const name = table.name + const sourceRows = byId(sourceTables[name] ?? []) + const targetRows = byId(reexported.tables[name] ?? []) + + if (sourceRows.length !== targetRows.length) { + console.log(`❌ ${name}: FAIL — Zeilenzahl weicht ab (Quelle: ${sourceRows.length}, Reexport: ${targetRows.length})`) + allPass = false + continue + } + + const sourceJson = JSON.stringify(sourceRows) + const targetJson = JSON.stringify(targetRows) + if (sourceJson !== targetJson) { + console.log(`❌ ${name}: FAIL — Inhalt weicht ab`) + console.log(` Quelle (Ausschnitt): ${sourceJson.slice(0, 300)}`) + console.log(` Reexport (Ausschnitt): ${targetJson.slice(0, 300)}`) + allPass = false + continue + } + + console.log(`✅ ${name}: PASS (${sourceRows.length} Zeile${sourceRows.length === 1 ? '' : 'n'}, identisch)`) +} + +console.log(allPass + ? '\n✅ Alle 21 Tabellen vollständig und inhaltlich identisch importiert/exportiert.' + : '\n❌ Mindestens eine Tabelle weicht ab — siehe oben.') +process.exit(allPass ? 0 : 1) diff --git a/src/db/index.ts b/src/db/index.ts index 9634b21..74ea518 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,5 +1,6 @@ export * from './types' export { db } from './schema' +export * from './queries' export { seedIfEmpty, seedFeedbackStructureIfEmpty, diff --git a/src/db/queries/appSettings.ts b/src/db/queries/appSettings.ts new file mode 100644 index 0000000..47f55d3 --- /dev/null +++ b/src/db/queries/appSettings.ts @@ -0,0 +1,6 @@ +import { rpc } from '../rpcClient' + +export const getRatingTrendWeightStep = (): Promise => rpc('appSettings', 'getRatingTrendWeightStep') +export const getOrSeedRatingTrendWeightStep = (): Promise => rpc('appSettings', 'getOrSeedRatingTrendWeightStep') +export const saveRatingTrendWeightStep = (weightStep: number): Promise => + rpc('appSettings', 'saveRatingTrendWeightStep', weightStep) diff --git a/src/db/queries/assessments.ts b/src/db/queries/assessments.ts new file mode 100644 index 0000000..3c69df3 --- /dev/null +++ b/src/db/queries/assessments.ts @@ -0,0 +1,13 @@ +import { rpc } from '../rpcClient' +import type { Assessment } from '../types' + +export const listAssessmentsAll = (): Promise => rpc('assessments', 'listAssessmentsAll') +export const listAssessmentsForMeeting = (meetingInstanceId: number): Promise => + rpc('assessments', 'listAssessmentsForMeeting', meetingInstanceId) +export const listAssessmentsForMeetings = (meetingInstanceIds: number[]): Promise => + rpc('assessments', 'listAssessmentsForMeetings', meetingInstanceIds) +export const addAssessment = (a: Omit): Promise => rpc('assessments', 'addAssessment', a) +export const updateAssessment = (id: number, patch: Partial): Promise => + rpc('assessments', 'updateAssessment', id, patch) +export const deleteAssessmentsForMeeting = (meetingInstanceId: number): Promise => + rpc('assessments', 'deleteAssessmentsForMeeting', meetingInstanceId) diff --git a/src/db/queries/assignmentFeedback.ts b/src/db/queries/assignmentFeedback.ts new file mode 100644 index 0000000..2ef02f5 --- /dev/null +++ b/src/db/queries/assignmentFeedback.ts @@ -0,0 +1,41 @@ +import { rpc } from '../rpcClient' +import type { FeedbackDraft, AssignmentFeedback, FeedbackCategoryRating, FeedbackDimensionText } from '../types' + +export const getFeedbackDraft = (assignmentId: number, instituteeId: number): Promise => + rpc('assignmentFeedback', 'getFeedbackDraft', assignmentId, instituteeId) +export const addFeedbackDraft = (d: Omit): Promise => rpc('assignmentFeedback', 'addFeedbackDraft', d) +export const updateFeedbackDraft = (id: number, patch: Partial): Promise => + rpc('assignmentFeedback', 'updateFeedbackDraft', id, patch) + +export const getAssignmentFeedback = (assignmentId: number, instituteeId: number): Promise => + rpc('assignmentFeedback', 'getAssignmentFeedback', assignmentId, instituteeId) +export const getAssignmentFeedbackById = (id: number): Promise => + rpc('assignmentFeedback', 'getAssignmentFeedbackById', id) +export const addAssignmentFeedback = (f: Omit): Promise => + rpc('assignmentFeedback', 'addAssignmentFeedback', f) +export const updateAssignmentFeedback = (id: number, patch: Partial): Promise => + rpc('assignmentFeedback', 'updateAssignmentFeedback', id, patch) + +export const getOrCreateAssignmentFeedback = ( + assignmentId: number, + instituteeId: number, +): Promise => rpc('assignmentFeedback', 'getOrCreateAssignmentFeedback', assignmentId, instituteeId) + +export const listFeedbackCategoryRatings = (assignmentFeedbackId: number): Promise => + rpc('assignmentFeedback', 'listFeedbackCategoryRatings', assignmentFeedbackId) +export const addFeedbackCategoryRating = (r: Omit): Promise => + rpc('assignmentFeedback', 'addFeedbackCategoryRating', r) +export const updateFeedbackCategoryRating = (id: number, patch: Partial): Promise => + rpc('assignmentFeedback', 'updateFeedbackCategoryRating', id, patch) + +export const listFeedbackDimensionTexts = (assignmentFeedbackId: number): Promise => + rpc('assignmentFeedback', 'listFeedbackDimensionTexts', assignmentFeedbackId) +export const getFeedbackDimensionText = ( + assignmentFeedbackId: number, + feedbackDimensionId: number, +): Promise => + rpc('assignmentFeedback', 'getFeedbackDimensionText', assignmentFeedbackId, feedbackDimensionId) +export const addFeedbackDimensionText = (t: Omit): Promise => + rpc('assignmentFeedback', 'addFeedbackDimensionText', t) +export const updateFeedbackDimensionText = (id: number, patch: Partial): Promise => + rpc('assignmentFeedback', 'updateFeedbackDimensionText', id, patch) diff --git a/src/db/queries/assignmentTypes.ts b/src/db/queries/assignmentTypes.ts new file mode 100644 index 0000000..aba6fc1 --- /dev/null +++ b/src/db/queries/assignmentTypes.ts @@ -0,0 +1,23 @@ +import { rpc } from '../rpcClient' +import type { AssignmentType, PhaseTemplate } from '../types' + +export const listAssignmentTypes = (): Promise => rpc('assignmentTypes', 'listAssignmentTypes') +export const getAssignmentType = (id: number): Promise => rpc('assignmentTypes', 'getAssignmentType', id) +export const addAssignmentType = (type: Omit): Promise => + rpc('assignmentTypes', 'addAssignmentType', type) +export const updateAssignmentType = (id: number, patch: Partial): Promise => + rpc('assignmentTypes', 'updateAssignmentType', id, patch) + +/** Löscht nur, wenn keine Assignments mehr diesen Typ referenzieren (Cascade auf phaseTemplates sonst). */ +export const deleteAssignmentType = (id: number): Promise<{ ok: true } | { ok: false; assignmentCount: number }> => + rpc('assignmentTypes', 'deleteAssignmentType', id) + +export const listPhaseTemplates = (): Promise => rpc('assignmentTypes', 'listPhaseTemplates') +export const getPhaseTemplate = (id: number): Promise => rpc('assignmentTypes', 'getPhaseTemplate', id) +export const listPhaseTemplatesForType = (assignmentTypeId: number): Promise => + rpc('assignmentTypes', 'listPhaseTemplatesForType', assignmentTypeId) +export const addPhaseTemplate = (tmpl: Omit): Promise => + rpc('assignmentTypes', 'addPhaseTemplate', tmpl) +export const updatePhaseTemplate = (id: number, patch: Partial): Promise => + rpc('assignmentTypes', 'updatePhaseTemplate', id, patch) +export const deletePhaseTemplate = (id: number): Promise => rpc('assignmentTypes', 'deletePhaseTemplate', id) diff --git a/src/db/queries/assignments.ts b/src/db/queries/assignments.ts new file mode 100644 index 0000000..b998646 --- /dev/null +++ b/src/db/queries/assignments.ts @@ -0,0 +1,17 @@ +import { rpc } from '../rpcClient' +import type { Assignment } from '../types' + +export const listAssignments = (): Promise => rpc('assignments', 'listAssignments') +export const listAssignmentsNewestFirst = (): Promise => rpc('assignments', 'listAssignmentsNewestFirst') +export const getAssignment = (id: number): Promise => rpc('assignments', 'getAssignment', id) +export const addAssignment = (a: Omit): Promise => rpc('assignments', 'addAssignment', a) +export const updateAssignment = (id: number, patch: Partial): Promise => + rpc('assignments', 'updateAssignment', id, patch) + +export interface AssignmentWithDetails extends Assignment { + typeName: string + instituteeNames: string[] +} + +/** Für die Dashboard-Liste: Assignments mit aufgelöstem Typ- und Institutee-Namen, neueste zuerst. */ +export const listAssignmentsWithDetails = (): Promise => rpc('assignments', 'listAssignmentsWithDetails') diff --git a/src/db/queries/consultants.ts b/src/db/queries/consultants.ts new file mode 100644 index 0000000..533cd81 --- /dev/null +++ b/src/db/queries/consultants.ts @@ -0,0 +1,12 @@ +import { rpc } from '../rpcClient' +import type { Group, Consultant } from '../types' + +export const listGroups = (): Promise => rpc('consultants', 'listGroups') +export const addGroup = (group: Omit): Promise => rpc('consultants', 'addGroup', group) + +export const listConsultants = (): Promise => rpc('consultants', 'listConsultants') +export const getConsultant = (id: number): Promise => rpc('consultants', 'getConsultant', id) +export const getConsultantsByIds = (ids: number[]): Promise => rpc('consultants', 'getConsultantsByIds', ids) +export const addConsultant = (consultant: Omit): Promise => rpc('consultants', 'addConsultant', consultant) +export const updateConsultant = (id: number, patch: Partial): Promise => rpc('consultants', 'updateConsultant', id, patch) +export const deleteConsultant = (id: number): Promise => rpc('consultants', 'deleteConsultant', id) diff --git a/src/db/queries/conversationEntries.ts b/src/db/queries/conversationEntries.ts new file mode 100644 index 0000000..c828541 --- /dev/null +++ b/src/db/queries/conversationEntries.ts @@ -0,0 +1,21 @@ +import { rpc } from '../rpcClient' +import type { ConversationEntry, ConversationSkillScore } from '../types' + +export const listConversationEntriesForMeeting = (meetingInstanceId: number): Promise => + rpc('conversationEntries', 'listConversationEntriesForMeeting', meetingInstanceId) +export const listConversationEntriesForMeetings = (meetingInstanceIds: number[]): Promise => + rpc('conversationEntries', 'listConversationEntriesForMeetings', meetingInstanceIds) +export const addConversationEntry = (e: Omit): Promise => + rpc('conversationEntries', 'addConversationEntry', e) +export const updateConversationEntry = (id: number, patch: Partial): Promise => + rpc('conversationEntries', 'updateConversationEntry', id, patch) +export const deleteConversationEntry = (id: number): Promise => rpc('conversationEntries', 'deleteConversationEntry', id) + +export const listConversationSkillScoresAll = (): Promise => + rpc('conversationEntries', 'listConversationSkillScoresAll') +export const addConversationSkillScore = (s: Omit): Promise => + rpc('conversationEntries', 'addConversationSkillScore', s) +export const updateConversationSkillScore = (id: number, patch: Partial): Promise => + rpc('conversationEntries', 'updateConversationSkillScore', id, patch) +export const deleteConversationSkillScoresForEntry = (conversationEntryId: number): Promise => + rpc('conversationEntries', 'deleteConversationSkillScoresForEntry', conversationEntryId) diff --git a/src/db/queries/feedbackStructure.ts b/src/db/queries/feedbackStructure.ts new file mode 100644 index 0000000..fa5ec0b --- /dev/null +++ b/src/db/queries/feedbackStructure.ts @@ -0,0 +1,56 @@ +import { rpc } from '../rpcClient' +import type { FeedbackDimension, FeedbackCategory, FeedbackCriterionItem } from '../types' + +export interface FeedbackStructure { + dimensions: FeedbackDimension[] + categories: FeedbackCategory[] + items: FeedbackCriterionItem[] +} + +/** Vollständige, nach `order` sortierte Feedback-Taxonomie. */ +export const loadFeedbackStructure = (): Promise => rpc('feedbackStructure', 'loadFeedbackStructure') + +/** Unsortierte Rohtabellen — für Stellen, die selbst filtern/sortieren (z.B. MeetingView, AssignmentDetail). */ +export const listFeedbackCategoriesOrdered = (): Promise => rpc('feedbackStructure', 'listFeedbackCategoriesOrdered') +export const listFeedbackCategoriesAll = (): Promise => rpc('feedbackStructure', 'listFeedbackCategoriesAll') +export const listFeedbackCriterionItemsAll = (): Promise => rpc('feedbackStructure', 'listFeedbackCriterionItemsAll') +export const getFeedbackCriterionItemsByIds = (ids: number[]): Promise => + rpc('feedbackStructure', 'getFeedbackCriterionItemsByIds', ids) + +/** + * "AssignmentType.criteriaIds restrict die sichtbaren Kriterien" — identische Filterlogik, die zuvor + * in MeetingView.tsx und AssignmentDetail.tsx dupliziert war. Leere/undefined criteriaIds = alles sichtbar. + * Reine Client-Berechnung, kein Server-Roundtrip nötig. + */ +export function filterVisibleFeedbackStructure< + C extends { id?: number }, + I extends { id?: number; categoryId: number }, +>(allCategories: C[], allItems: I[], criteriaIds: number[] | undefined): { categories: C[]; items: I[] } { + const itemFilter = criteriaIds?.length ? new Set(criteriaIds) : null + const items = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems + const visibleCatIds = new Set(items.map(i => i.categoryId)) + const categories = itemFilter ? allCategories.filter(c => visibleCatIds.has(c.id!)) : allCategories + return { categories, items } +} + +export const addFeedbackDimension = (d: Omit): Promise => + rpc('feedbackStructure', 'addFeedbackDimension', d) +export const updateFeedbackDimension = (id: number, patch: Partial): Promise => + rpc('feedbackStructure', 'updateFeedbackDimension', id, patch) + +/** Cascade: Items der Kategorien → Kategorien → Dimension. */ +export const deleteFeedbackDimension = (id: number): Promise => rpc('feedbackStructure', 'deleteFeedbackDimension', id) + +export const addFeedbackCategory = (c: Omit): Promise => + rpc('feedbackStructure', 'addFeedbackCategory', c) +export const updateFeedbackCategory = (id: number, patch: Partial): Promise => + rpc('feedbackStructure', 'updateFeedbackCategory', id, patch) + +/** Cascade: Items der Kategorie → Kategorie. */ +export const deleteFeedbackCategory = (id: number): Promise => rpc('feedbackStructure', 'deleteFeedbackCategory', id) + +export const addFeedbackCriterionItem = (i: Omit): Promise => + rpc('feedbackStructure', 'addFeedbackCriterionItem', i) +export const updateFeedbackCriterionItem = (id: number, patch: Partial): Promise => + rpc('feedbackStructure', 'updateFeedbackCriterionItem', id, patch) +export const deleteFeedbackCriterionItem = (id: number): Promise => rpc('feedbackStructure', 'deleteFeedbackCriterionItem', id) diff --git a/src/db/queries/index.ts b/src/db/queries/index.ts new file mode 100644 index 0000000..f1a832a --- /dev/null +++ b/src/db/queries/index.ts @@ -0,0 +1,9 @@ +export * from './appSettings' +export * from './consultants' +export * from './assignmentTypes' +export * from './assignments' +export * from './feedbackStructure' +export * from './meetings' +export * from './conversationEntries' +export * from './assessments' +export * from './assignmentFeedback' diff --git a/src/db/queries/meetings.ts b/src/db/queries/meetings.ts new file mode 100644 index 0000000..4d508eb --- /dev/null +++ b/src/db/queries/meetings.ts @@ -0,0 +1,39 @@ +import { rpc } from '../rpcClient' +import type { + MeetingInstance, PhaseTemplate, Assignment, Consultant, + ConversationEntry, ConversationSkillScore, Assessment, +} from '../types' + +export const getMeetingInstance = (id: number): Promise => rpc('meetings', 'getMeetingInstance', id) +export const listMeetingInstancesForAssignment = (assignmentId: number): Promise => + rpc('meetings', 'listMeetingInstancesForAssignment', assignmentId) + +/** + * Kanonischer Filter für Aggregationen über mehrere Meetings: Papierkorb-/unfertige Meetings ausschließen, + * chronologisch sortiert. Siehe CLAUDE.md — immer diese Funktion nutzen, nicht erneut inline filtern. + */ +export const listDoneMeetingsChronological = (assignmentId: number): Promise => + rpc('meetings', 'listDoneMeetingsChronological', assignmentId) + +export const addMeetingInstance = (m: Omit): Promise => rpc('meetings', 'addMeetingInstance', m) +export const updateMeetingInstance = (id: number, patch: Partial): Promise => + rpc('meetings', 'updateMeetingInstance', id, patch) +export const softDeleteMeetingInstance = (id: number): Promise => rpc('meetings', 'softDeleteMeetingInstance', id) +export const restoreMeetingInstance = (id: number): Promise => rpc('meetings', 'restoreMeetingInstance', id) + +/** Cascade: conversationSkillScores der Entries → conversationEntries → assessments → meetingInstance. */ +export const hardDeleteMeetingInstance = (id: number): Promise => rpc('meetings', 'hardDeleteMeetingInstance', id) + +export interface MeetingExportData { + meeting: MeetingInstance + template: PhaseTemplate | undefined + assignment: Assignment | undefined + consultants: Consultant[] + entries: ConversationEntry[] + allScores: ConversationSkillScore[] + assessments: Assessment[] +} + +/** Gebündelte Rohdaten für Markdown-/JSON-Export eines Meetings — vorher in meetingExport.ts dupliziert. */ +export const getMeetingExportData = (meetingId: number): Promise => + rpc('meetings', 'getMeetingExportData', meetingId) diff --git a/src/db/rpcClient.ts b/src/db/rpcClient.ts new file mode 100644 index 0000000..15d1125 --- /dev/null +++ b/src/db/rpcClient.ts @@ -0,0 +1,17 @@ +/** + * Bridge zum lokalen Server (server/index.ts) — ersetzt den bisherigen direkten Dexie-Zugriff + * in src/db/queries/*.ts. Ein Endpunkt statt ~80 REST-Routen: `{ module, fn, args }` → Server + * ruft `rpcModules[module][fn](...args)` auf. Relativer Pfad `/api/rpc`, damit derselbe Code + * unverändert über den Vite-Dev-Proxy UND später hinter einem Reverse Proxy/Docker funktioniert. + */ +export async function rpc(module: string, fn: string, ...args: unknown[]): Promise { + const res = await fetch('/api/rpc', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ module, fn, args }), + }) + if (!res.ok) throw new Error(`RPC-Fehler: ${res.status} ${res.statusText}`) + const body = await res.json() as { ok: boolean; result?: T; error?: string } + if (!body.ok) throw new Error(body.error ?? 'RPC-Fehler') + return body.result as T +} diff --git a/src/main.tsx b/src/main.tsx index 7bc7a38..ade9d64 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,13 +3,6 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' -import { seedIfEmpty, seedFeedbackStructureIfEmpty, seedCriterionMappingsIfEmpty, migrateToUnifiedCriteriaIfNeeded } from './db/index.ts' - -seedIfEmpty() -seedFeedbackStructureIfEmpty().then(() => { - seedCriterionMappingsIfEmpty() - migrateToUnifiedCriteriaIfNeeded() -}) createRoot(document.getElementById('root')!).render( diff --git a/src/pages/AssignmentCreate.tsx b/src/pages/AssignmentCreate.tsx index 206f7c1..d217e26 100644 --- a/src/pages/AssignmentCreate.tsx +++ b/src/pages/AssignmentCreate.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { db, type AssignmentType, type PhaseTemplate, type Consultant, type Group } from '../db' +import { + type AssignmentType, type PhaseTemplate, type Consultant, type Group, + listAssignmentTypes, listPhaseTemplatesForType, + listGroups, listConsultants, addGroup, addConsultant, + addAssignment, +} from '../db' export default function AssignmentCreate() { const navigate = useNavigate() @@ -24,9 +29,9 @@ export default function AssignmentCreate() { const reload = async () => { const [t, g, c] = await Promise.all([ - db.assignmentTypes.toArray(), - db.groups.toArray(), - db.consultants.toArray(), + listAssignmentTypes(), + listGroups(), + listConsultants(), ]) setTypes(t) setGroups(g) @@ -37,7 +42,7 @@ export default function AssignmentCreate() { useEffect(() => { if (!form.assignmentTypeId) { setPhases([]); return } - db.phaseTemplates.where('assignmentTypeId').equals(form.assignmentTypeId).sortBy('order').then(setPhases) + listPhaseTemplatesForType(form.assignmentTypeId).then(setPhases) }, [form.assignmentTypeId]) const groupConsultants = consultants.filter(c => c.groupId === form.selectedGroupId) @@ -55,10 +60,10 @@ export default function AssignmentCreate() { let groupId = form.selectedGroupId if (!groupId) { // Erstelle eine Standardgruppe falls keine vorhanden - groupId = await db.groups.add({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() }) + groupId = await addGroup({ name: 'Neue Gruppe', cohortYear: new Date().getFullYear() }) setForm(p => ({ ...p, selectedGroupId: groupId })) } - const id = await db.consultants.add({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() }) + const id = await addConsultant({ groupId, firstName: newFirst.trim(), lastName: newLast.trim() }) await reload() setForm(p => ({ ...p, instituteeIds: [...p.instituteeIds, id] })) setNewFirst('') @@ -68,7 +73,7 @@ export default function AssignmentCreate() { const create = async () => { if (!form.title.trim() || !form.client.trim() || !form.assignmentNumber.trim() || !form.assignmentTypeId || form.instituteeIds.length === 0) return - const id = await db.assignments.add({ + const id = await addAssignment({ title: form.title, client: form.client, leadName: form.leadName, diff --git a/src/pages/AssignmentDetail.tsx b/src/pages/AssignmentDetail.tsx index 2c606dd..797e516 100644 --- a/src/pages/AssignmentDetail.tsx +++ b/src/pages/AssignmentDetail.tsx @@ -1,7 +1,16 @@ import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { db, type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem } from '../db' +import { + type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem, + getAssignment, getAssignmentType, + listPhaseTemplatesForType, + listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance, + getConsultantsByIds, + listAssessmentsForMeetings, + listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure, +} from '../db' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport' +import { weightedAverageRating } from '../utils/ratingTrend' const RATING_BG: Record = { na: 'bg-gray-400 text-white', @@ -11,10 +20,6 @@ const RATING_BG: Record = { client_ready: 'bg-green-700 text-white', } -const NUM_MAP: Record = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 } -const avgToRating = (avg: number): FeedbackRating => - avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready' - interface PhaseRow { template: PhaseTemplate instances: MeetingInstance[] @@ -41,15 +46,15 @@ export default function AssignmentDetail() { const [fbItems, setFbItems] = useState([]) const load = async () => { - const a = await db.assignments.get(assignmentId) + const a = await getAssignment(assignmentId) if (!a) return setAssignment(a) const [aType, templates, instances, cons] = await Promise.all([ - db.assignmentTypes.get(a.assignmentTypeId), - db.phaseTemplates.where('assignmentTypeId').equals(a.assignmentTypeId).sortBy('order'), - db.meetingInstances.where('assignmentId').equals(assignmentId).toArray(), - db.consultants.where('id').anyOf(a.instituteeIds).toArray(), + getAssignmentType(a.assignmentTypeId), + listPhaseTemplatesForType(a.assignmentTypeId), + listMeetingInstancesForAssignment(assignmentId), + getConsultantsByIds(a.instituteeIds), ]) setAssignmentType(aType ?? null) @@ -64,23 +69,21 @@ export default function AssignmentDetail() { }))) const instIds = active.map(i => i.id!) const [asmts, allCats, allItems, aType2] = await Promise.all([ - instIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(instIds).toArray() : Promise.resolve([]), - db.feedbackCategories.toArray(), - db.feedbackCriterionItems.toArray(), - db.assignmentTypes.get(a.assignmentTypeId), + listAssessmentsForMeetings(instIds), + listFeedbackCategoriesAll(), + listFeedbackCriterionItemsAll(), + getAssignmentType(a.assignmentTypeId), ]) setAllAssessments(asmts) - const itemFilter = aType2?.criteriaIds?.length ? new Set(aType2.criteriaIds) : null - const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems - setFbItems(filteredItems) - const visibleCatIds = new Set(filteredItems.map(i => i.categoryId)) - setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats) + const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType2?.criteriaIds) + setFbItems(visibleItems) + setFbCategories(visibleCats) } useEffect(() => { load() }, [assignmentId]) const startMeeting = async (templateId: number, existingCount: number) => { - const mid = await db.meetingInstances.add({ + const mid = await addMeetingInstance({ assignmentId, phaseTemplateId: templateId, phaseIndex: existingCount + 1, @@ -113,24 +116,18 @@ export default function AssignmentDetail() { } const softDeleteMeeting = async (instId: number) => { - await db.meetingInstances.update(instId, { deletedAt: new Date().toISOString() }) + await softDeleteMeetingInstance(instId) setConfirmSoftDelete(null) await load() } const restoreMeeting = async (instId: number) => { - await db.meetingInstances.update(instId, { deletedAt: undefined }) + await restoreMeetingInstance(instId) await load() } const hardDeleteMeeting = async (instId: number) => { - await db.conversationSkillScores - .where('conversationEntryId') - .anyOf((await db.conversationEntries.where('meetingInstanceId').equals(instId).primaryKeys())) - .delete() - await db.conversationEntries.where('meetingInstanceId').equals(instId).delete() - await db.assessments.where('meetingInstanceId').equals(instId).delete() - await db.meetingInstances.delete(instId) + await hardDeleteMeetingInstance(instId) setConfirmHardDelete(null) await load() } @@ -258,19 +255,15 @@ export default function AssignmentDetail() { {institutees.map(c => { const chips = fbCategories.map(cat => { const catItems = fbItems.filter(i => i.categoryId === cat.id) - const scored = catItems.map(item => { + const mode = weightedAverageRating(catItems, itemId => { const asmt = allAssessments.find(a => a.meetingInstanceId === inst.id && a.instituteeId === c.id && - a.criteriaId === item.id && - a.score !== null && a.score !== 'na' + a.criteriaId === itemId ) - return asmt ? { score: asmt.score as FeedbackRating, w: item.weight ?? 1 } : null - }).filter(Boolean) as { score: FeedbackRating; w: number }[] - if (scored.length === 0) return null - const wSum = scored.reduce((s, x) => s + x.w, 0) - const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum - return { cat, mode: avgToRating(avg) } + return asmt?.score ?? null + }) + return mode ? { cat, mode } : null }).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[] if (chips.length === 0) return null diff --git a/src/pages/AssignmentEdit.tsx b/src/pages/AssignmentEdit.tsx index b6ef846..3590e27 100644 --- a/src/pages/AssignmentEdit.tsx +++ b/src/pages/AssignmentEdit.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { db, type Assignment, type AssignmentType, type Consultant } from '../db' +import { + type Assignment, type AssignmentType, type Consultant, + getAssignment, updateAssignment, listAssignmentTypes, listConsultants, +} from '../db' export default function AssignmentEdit() { const { id } = useParams() @@ -27,9 +30,9 @@ export default function AssignmentEdit() { useEffect(() => { const loadData = async () => { const [a, types, cons] = await Promise.all([ - db.assignments.get(assignmentId), - db.assignmentTypes.toArray(), - db.consultants.toArray(), + getAssignment(assignmentId), + listAssignmentTypes(), + listConsultants(), ]) if (!a) return setAssignment(a) @@ -50,7 +53,7 @@ export default function AssignmentEdit() { }, [assignmentId]) const handleSave = async () => { - await db.assignments.update(assignmentId, { + await updateAssignment(assignmentId, { assignmentNumber: assignmentNumber.trim() || undefined, title, client, diff --git a/src/pages/AssignmentFeedbackPage.tsx b/src/pages/AssignmentFeedbackPage.tsx index 8fe0405..adc1a81 100644 --- a/src/pages/AssignmentFeedbackPage.tsx +++ b/src/pages/AssignmentFeedbackPage.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { - db, type Assignment, type Consultant, type FeedbackDimension, @@ -11,8 +10,16 @@ import { type FeedbackCategoryRating, type FeedbackDimensionText, type FeedbackRating, + getAssignment, getConsultant, + loadFeedbackStructure, + listMeetingInstancesForAssignment, listDoneMeetingsChronological, + listAssessmentsForMeetings, listConversationEntriesForMeetings, + getRatingTrendWeightStep, + getOrCreateAssignmentFeedback, updateAssignmentFeedback, + listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, + listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText, } from '../db' -import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' +import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants' import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend' import { splitNoteLines } from '../utils/notationParser' @@ -51,12 +58,12 @@ async function buildAiPrompt( ratings: FeedbackCategoryRating[], catTrends: Map, ): Promise { - const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray() + const meetings = await listMeetingInstancesForAssignment(assignment.id!) const meetingIds = meetings.map(m => m.id!) const [allAssessments, allEntries] = await Promise.all([ - db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(), - db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(), + listAssessmentsForMeetings(meetingIds), + listConversationEntriesForMeetings(meetingIds), ]) const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id) @@ -187,28 +194,24 @@ export default function AssignmentFeedbackPage() { const saveTimer = useRef>>(new Map()) const load = useCallback(async () => { - const [a, cons, dims, cats, itms] = await Promise.all([ - db.assignments.get(assignmentId), - db.consultants.get(instId), - db.feedbackDimensions.orderBy('order').toArray(), - db.feedbackCategories.orderBy('order').toArray(), - db.feedbackCriterionItems.orderBy('order').toArray(), + const [a, cons, structure] = await Promise.all([ + getAssignment(assignmentId), + getConsultant(instId), + loadFeedbackStructure(), ]) setAssignment(a ?? null) setInstitutee(cons ?? null) - setDimensions(dims) - setCategories(cats) - setItems(itms) + setDimensions(structure.dimensions) + setCategories(structure.categories) + setItems(structure.items) + const { dimensions: dims, categories: cats, items: itms } = structure - const meetings = (await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray()) - .filter(m => !m.deletedAt && m.status === 'done') - .sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime()) + const meetings = await listDoneMeetingsChronological(assignmentId) const meetingIds = meetings.map(m => m.id!) - const [meetingAssessments, settings] = await Promise.all([ - meetingIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray() : Promise.resolve([]), - db.appSettings.get(1), + const [meetingAssessments, weightStep] = await Promise.all([ + listAssessmentsForMeetings(meetingIds), + getRatingTrendWeightStep(), ]) - const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId) const getScore = (meetingId: number, itemId: number): FeedbackRating | null => instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null @@ -221,23 +224,13 @@ export default function AssignmentFeedbackPage() { } setCatTrends(trends) - let fb = await db.assignmentFeedbacks - .where('assignmentId').equals(assignmentId) - .filter(f => f.instituteeId === instId) - .first() - if (!fb) { - const fbId = await db.assignmentFeedbacks.add({ - assignmentId, instituteeId: instId, - status: 'draft', aiModel: '', updatedAt: new Date().toISOString(), - }) - fb = await db.assignmentFeedbacks.get(fbId) - } + const fb = await getOrCreateAssignmentFeedback(assignmentId, instId) setFeedback(fb ?? null) if (fb?.id) { const [rs, txts] = await Promise.all([ - db.feedbackCategoryRatings.where('assignmentFeedbackId').equals(fb.id).toArray(), - db.feedbackDimensionTexts.where('assignmentFeedbackId').equals(fb.id).toArray(), + listFeedbackCategoryRatings(fb.id), + listFeedbackDimensionTexts(fb.id), ]) setRatings(rs) setDimTexts(txts) @@ -255,10 +248,10 @@ export default function AssignmentFeedbackPage() { if (!feedback?.id) return const existing = ratings.find(r => r.feedbackCategoryId === categoryId) if (existing?.id) { - await db.feedbackCategoryRatings.update(existing.id, { rating }) + await updateFeedbackCategoryRating(existing.id, { rating }) setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r)) } else { - const newId = await db.feedbackCategoryRatings.add({ + const newId = await addFeedbackCategoryRating({ assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating, }) setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }]) @@ -281,14 +274,11 @@ export default function AssignmentFeedbackPage() { if (prev) clearTimeout(prev) saveTimer.current.set(key, setTimeout(async () => { if (!feedback?.id) return - const existing = await db.feedbackDimensionTexts - .where('assignmentFeedbackId').equals(feedback.id) - .filter(t => t.feedbackDimensionId === dimId) - .first() + const existing = await getFeedbackDimensionText(feedback.id, dimId) if (existing?.id) { - await db.feedbackDimensionTexts.update(existing.id, { [field]: value }) + await updateFeedbackDimensionText(existing.id, { [field]: value }) } else { - await db.feedbackDimensionTexts.add({ + await addFeedbackDimensionText({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dimId, achievements: field === 'achievements' ? value : '', @@ -349,17 +339,14 @@ export default function AssignmentFeedbackPage() { for (const item of parsed.dimensions) { const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0])) if (!dim?.id) continue - const existing = await db.feedbackDimensionTexts - .where('assignmentFeedbackId').equals(feedback.id) - .filter(t => t.feedbackDimensionId === dim.id) - .first() + const existing = await getFeedbackDimensionText(feedback.id, dim.id) if (existing?.id) { - await db.feedbackDimensionTexts.update(existing.id, { + await updateFeedbackDimensionText(existing.id, { achievements: item.achievements, developmentNeeds: item.developmentNeeds, }) } else { - await db.feedbackDimensionTexts.add({ + await addFeedbackDimensionText({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dim.id, achievements: item.achievements, @@ -367,7 +354,7 @@ export default function AssignmentFeedbackPage() { }) } } - await db.assignmentFeedbacks.update(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() }) + await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() }) await load() } catch (e) { setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler') @@ -379,7 +366,7 @@ export default function AssignmentFeedbackPage() { const toggleFinal = async () => { if (!feedback?.id) return const next = feedback.status === 'final' ? 'draft' : 'final' - await db.assignmentFeedbacks.update(feedback.id, { status: next, updatedAt: new Date().toISOString() }) + await updateAssignmentFeedback(feedback.id, { status: next, updatedAt: new Date().toISOString() }) setFeedback(prev => prev ? { ...prev, status: next } : null) } diff --git a/src/pages/AssignmentTypeConfig.tsx b/src/pages/AssignmentTypeConfig.tsx index ddc3bfe..eaf8faf 100644 --- a/src/pages/AssignmentTypeConfig.tsx +++ b/src/pages/AssignmentTypeConfig.tsx @@ -1,5 +1,10 @@ import { useEffect, useState } from 'react' -import { db, type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db' +import { + type AssignmentType, type PhaseTemplate, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, + listAssignmentTypes, listPhaseTemplates, loadFeedbackStructure, + addAssignmentType, updateAssignmentType, deleteAssignmentType, + addPhaseTemplate, updatePhaseTemplate, deletePhaseTemplate, +} from '../db' export default function AssignmentTypeConfig() { const [types, setTypes] = useState([]) @@ -18,18 +23,16 @@ export default function AssignmentTypeConfig() { }>>({}) const load = async () => { - const [t, tmpl, dims, cats, itms] = await Promise.all([ - db.assignmentTypes.toArray(), - db.phaseTemplates.toArray(), - db.feedbackDimensions.orderBy('order').toArray(), - db.feedbackCategories.orderBy('order').toArray(), - db.feedbackCriterionItems.orderBy('order').toArray(), + const [t, tmpl, structure] = await Promise.all([ + listAssignmentTypes(), + listPhaseTemplates(), + loadFeedbackStructure(), ]) setTypes(t) setTemplates(tmpl) - setDimensions(dims) - setCategories(cats) - setItems(itms) + setDimensions(structure.dimensions) + setCategories(structure.categories) + setItems(structure.items) } useEffect(() => { load() }, []) @@ -39,25 +42,23 @@ export default function AssignmentTypeConfig() { const addType = async () => { const name = newTypeName.trim() if (!name) return - const id = await db.assignmentTypes.add({ name, defaultPhaseKeys: [], criteriaIds: [] }) + const id = await addAssignmentType({ name, defaultPhaseKeys: [], criteriaIds: [] }) setNewTypeName('') await load() setExpanded(id) } const updateTypeName = async (id: number, name: string) => { - await db.assignmentTypes.update(id, { name }) + await updateAssignmentType(id, { name }) setTypes(prev => prev.map(t => t.id === id ? { ...t, name } : t)) } const deleteType = async (id: number) => { - const count = await db.assignments.where('assignmentTypeId').equals(id).count() - if (count > 0) { - setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${count} Assignment(s) verwenden diesen Typ.` })) + const result = await deleteAssignmentType(id) + if (!result.ok) { + setTypeError(prev => ({ ...prev, [id]: `Kann nicht gelöscht werden — ${result.assignmentCount} Assignment(s) verwenden diesen Typ.` })) return } - await db.phaseTemplates.where('assignmentTypeId').equals(id).delete() - await db.assignmentTypes.delete(id) await load() } @@ -66,7 +67,7 @@ export default function AssignmentTypeConfig() { const toggleItem = async (type: AssignmentType, itemId: number) => { const current = type.criteriaIds ?? [] const next = current.includes(itemId) ? current.filter(id => id !== itemId) : [...current, itemId] - await db.assignmentTypes.update(type.id!, { criteriaIds: next }) + await updateAssignmentType(type.id!, { criteriaIds: next }) setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t)) } @@ -77,7 +78,7 @@ export default function AssignmentTypeConfig() { const next = allSelected ? current.filter(id => !catItemIds.includes(id)) : [...new Set([...current, ...catItemIds])] - await db.assignmentTypes.update(type.id!, { criteriaIds: next }) + await updateAssignmentType(type.id!, { criteriaIds: next }) setTypes(prev => prev.map(t => t.id === type.id ? { ...t, criteriaIds: next } : t)) } @@ -87,7 +88,7 @@ export default function AssignmentTypeConfig() { const np = newPhase[typeId] if (!np?.label.trim()) return const existing = templates.filter(t => t.assignmentTypeId === typeId) - await db.phaseTemplates.add({ + await addPhaseTemplate({ assignmentTypeId: typeId, phaseKey: np.label.trim().toLowerCase().replace(/\s+/g, '_'), label: np.label.trim(), @@ -103,12 +104,12 @@ export default function AssignmentTypeConfig() { } const updatePhase = async (id: number, patch: Partial) => { - await db.phaseTemplates.update(id, patch) + await updatePhaseTemplate(id, patch) setTemplates(prev => prev.map(t => t.id === id ? { ...t, ...patch } : t)) } const deletePhase = async (id: number) => { - await db.phaseTemplates.delete(id) + await deletePhaseTemplate(id) await load() } diff --git a/src/pages/Consultants.tsx b/src/pages/Consultants.tsx index 43ec0b5..9b3a80c 100644 --- a/src/pages/Consultants.tsx +++ b/src/pages/Consultants.tsx @@ -1,35 +1,55 @@ import { useEffect, useState } from 'react' -import { db, type Consultant, type Group } from '../db' +import { + type Consultant, type Group, + listGroups, listConsultants, addGroup as addGroupQuery, addConsultant as addConsultantQuery, + updateConsultant as updateConsultantQuery, deleteConsultant as deleteConsultantQuery, +} from '../db' export default function Consultants() { const [groups, setGroups] = useState([]) const [consultants, setConsultants] = useState([]) const [newGroup, setNewGroup] = useState({ name: '', cohortYear: new Date().getFullYear() }) const [newCons, setNewCons] = useState({ groupId: 0, firstName: '', lastName: '' }) + const [editingId, setEditingId] = useState(null) + const [editValues, setEditValues] = useState({ firstName: '', lastName: '' }) const load = async () => { - setGroups(await db.groups.toArray()) - setConsultants(await db.consultants.toArray()) + setGroups(await listGroups()) + setConsultants(await listConsultants()) } useEffect(() => { load() }, []) const addGroup = async () => { if (!newGroup.name.trim()) return - await db.groups.add({ ...newGroup }) + await addGroupQuery({ ...newGroup }) setNewGroup({ name: '', cohortYear: new Date().getFullYear() }) load() } const addConsultant = async () => { if (!newCons.firstName.trim() || !newCons.lastName.trim() || !newCons.groupId) return - await db.consultants.add({ ...newCons }) + await addConsultantQuery({ ...newCons }) setNewCons(p => ({ ...p, firstName: '', lastName: '' })) load() } const deleteConsultant = async (id: number) => { - await db.consultants.delete(id) + await deleteConsultantQuery(id) + load() + } + + const startEdit = (c: Consultant) => { + setEditingId(c.id!) + setEditValues({ firstName: c.firstName, lastName: c.lastName }) + } + + const cancelEdit = () => setEditingId(null) + + const saveEdit = async () => { + if (!editValues.firstName.trim() || !editValues.lastName.trim() || editingId === null) return + await updateConsultantQuery(editingId, { ...editValues }) + setEditingId(null) load() } @@ -77,9 +97,36 @@ export default function Consultants() {

{g.name}

{members.map(c => ( -
- {c.firstName} {c.lastName} - +
+ {editingId === c.id ? ( + <> +
+ setEditValues(p => ({ ...p, firstName: e.target.value }))} + onKeyDown={e => e.key === 'Enter' && saveEdit()} + autoFocus + /> + setEditValues(p => ({ ...p, lastName: e.target.value }))} + onKeyDown={e => e.key === 'Enter' && saveEdit()} + /> +
+ + + + ) : ( + <> + {c.firstName} {c.lastName} +
+ + +
+ + )}
))}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 9e29726..29e92e0 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,35 +1,15 @@ import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { db, type Assignment, type AssignmentType, type Consultant } from '../db' - -interface AssignmentRow extends Assignment { - typeName: string - instituteeNames: string[] -} +import { listAssignmentsWithDetails, type AssignmentWithDetails } from '../db' export default function Dashboard() { - const [assignments, setAssignments] = useState([]) + const [assignments, setAssignments] = useState([]) const [query, setQuery] = useState('') const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'done'>('all') const navigate = useNavigate() useEffect(() => { - const load = async () => { - const raw = await db.assignments.orderBy('createdAt').reverse().toArray() - const types = await db.assignmentTypes.toArray() - const consultants = await db.consultants.toArray() - const typeMap = new Map(types.map(t => [t.id!, t])) - const consMap = new Map(consultants.map(c => [c.id!, c])) - setAssignments(raw.map(a => ({ - ...a, - typeName: typeMap.get(a.assignmentTypeId)?.name ?? '—', - instituteeNames: a.instituteeIds.map(id => { - const c = consMap.get(id) - return c ? `${c.firstName} ${c.lastName}` : '?' - }), - }))) - } - load() + listAssignmentsWithDetails().then(setAssignments) }, []) const filtered = useMemo(() => { diff --git a/src/pages/Evaluation.tsx b/src/pages/Evaluation.tsx index 9700963..24d4a52 100644 --- a/src/pages/Evaluation.tsx +++ b/src/pages/Evaluation.tsx @@ -1,12 +1,16 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { - db, type Assignment, type Consultant, type FeedbackDimension, type FeedbackCategory, type FeedbackRating, + listAssignments, listConsultants, + loadFeedbackStructure, + listAssessmentsAll, + getRatingTrendWeightStep, + listDoneMeetingsChronological, } from '../db' -import { RATING_OPTIONS, DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' +import { RATING_OPTIONS } from '../config/constants' import { buildGroupMeetingScores, computeTrend, type TrendResult } from '../utils/ratingTrend' interface CatScore { @@ -57,25 +61,20 @@ export default function Evaluation() { useEffect(() => { const load = async () => { - const [assignments, consultants, dims, cats, items, assessments, settings] = await Promise.all([ - db.assignments.toArray(), - db.consultants.toArray(), - db.feedbackDimensions.orderBy('order').toArray(), - db.feedbackCategories.orderBy('order').toArray(), - db.feedbackCriterionItems.orderBy('order').toArray(), - db.assessments.toArray(), - db.appSettings.get(1), + const [assignments, consultants, structure, assessments, weightStep] = await Promise.all([ + listAssignments(), + listConsultants(), + loadFeedbackStructure(), + listAssessmentsAll(), + getRatingTrendWeightStep(), ]) + const { dimensions: dims, categories: cats, items } = structure setDimensions(dims) setCategories(cats) - const weightStep = settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP const rows: InstScore[] = [] for (const a of assignments) { - const meetings = (await db.meetingInstances - .where('assignmentId').equals(a.id!).toArray() - ).filter(m => !m.deletedAt && m.status === 'done') - .sort((x, y) => new Date(x.date).getTime() - new Date(y.date).getTime()) + const meetings = await listDoneMeetingsChronological(a.id!) const meetingIds = meetings.map(m => m.id!) for (const instId of a.instituteeIds) { diff --git a/src/pages/FeedbackDraftPage.tsx b/src/pages/FeedbackDraftPage.tsx index d283ada..d66b917 100644 --- a/src/pages/FeedbackDraftPage.tsx +++ b/src/pages/FeedbackDraftPage.tsx @@ -1,13 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { - db, type Assignment, type Consultant, type FeedbackCategory, type FeedbackCriterionItem, type FeedbackDraft, type FeedbackRating, + getAssignment, getConsultant, + listFeedbackCategoriesOrdered, getFeedbackCriterionItemsByIds, + listMeetingInstancesForAssignment, + listAssessmentsForMeetings, + listConversationEntriesForMeetings, + getFeedbackDraft, addFeedbackDraft, updateFeedbackDraft, } from '../db' import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants' import { numberToRating } from '../utils/ratingTrend' @@ -49,20 +54,20 @@ export default function FeedbackDraftPage() { const load = useCallback(async () => { const [a, cons, cats] = await Promise.all([ - db.assignments.get(assignmentId), - db.consultants.get(instId), - db.feedbackCategories.orderBy('order').toArray(), + getAssignment(assignmentId), + getConsultant(instId), + listFeedbackCategoriesOrdered(), ]) setAssignment(a ?? null) setInstitutee(cons ?? null) setCategories(cats) - const meetings = await db.meetingInstances.where('assignmentId').equals(assignmentId).toArray() + const meetings = await listMeetingInstancesForAssignment(assignmentId) const meetingIds = meetings.map(m => m.id!) const [allAssessments, allEntries] = await Promise.all([ - db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(), - db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(), + listAssessmentsForMeetings(meetingIds), + listConversationEntriesForMeetings(meetingIds), ]) const instAssessments = allAssessments.filter(a => a.instituteeId === instId) @@ -73,9 +78,7 @@ export default function FeedbackDraftPage() { ...instAssessments.map(a => a.criteriaId), ...allLineTags.map(t => t.criterionItemId), ])] - const crits = criteriaIds.length > 0 - ? await db.feedbackCriterionItems.where('id').anyOf(criteriaIds).toArray() - : [] + const crits = await getFeedbackCriterionItemsByIds(criteriaIds) const sum: CriterionSummary[] = crits.map(crit => { const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null && a.score !== 'na') @@ -90,10 +93,7 @@ export default function FeedbackDraftPage() { }) setSummary(sum) - const existing = await db.feedbackDrafts - .where('assignmentId').equals(assignmentId) - .filter(d => d.instituteeId === instId) - .first() + const existing = await getFeedbackDraft(assignmentId, instId) if (existing) { setDraft(existing) setContent(existing.content) @@ -134,10 +134,10 @@ export default function FeedbackDraftPage() { const now = new Date().toISOString() if (draft?.id) { - await db.feedbackDrafts.update(draft.id, { content: text, updatedAt: now }) + await updateFeedbackDraft(draft.id, { content: text, updatedAt: now }) setDraft(prev => prev ? { ...prev, content: text, updatedAt: now } : null) } else { - const newId = await db.feedbackDrafts.add({ + const newId = await addFeedbackDraft({ assignmentId, instituteeId: instId, content: text, updatedAt: now, }) setDraft({ id: newId, assignmentId, instituteeId: instId, content: text, updatedAt: now }) @@ -150,9 +150,9 @@ export default function FeedbackDraftPage() { saveTimer.current = setTimeout(async () => { const now = new Date().toISOString() if (draft?.id) { - await db.feedbackDrafts.update(draft.id, { content: val, updatedAt: now }) + await updateFeedbackDraft(draft.id, { content: val, updatedAt: now }) } else { - const newId = await db.feedbackDrafts.add({ + const newId = await addFeedbackDraft({ assignmentId, instituteeId: instId, content: val, updatedAt: now, }) setDraft({ id: newId, assignmentId, instituteeId: instId, content: val, updatedAt: now }) diff --git a/src/pages/FeedbackStructureConfig.tsx b/src/pages/FeedbackStructureConfig.tsx index 96f0167..575d5ac 100644 --- a/src/pages/FeedbackStructureConfig.tsx +++ b/src/pages/FeedbackStructureConfig.tsx @@ -1,5 +1,11 @@ import { useEffect, useState } from 'react' -import { db, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem } from '../db' +import { + type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, + loadFeedbackStructure, + addFeedbackDimension, updateFeedbackDimension, deleteFeedbackDimension, + addFeedbackCategory, updateFeedbackCategory, deleteFeedbackCategory, + addFeedbackCriterionItem, updateFeedbackCriterionItem, deleteFeedbackCriterionItem, +} from '../db' export default function FeedbackStructureConfig() { const [dimensions, setDimensions] = useState([]) @@ -14,14 +20,10 @@ export default function FeedbackStructureConfig() { const [newItemName, setNewItemName] = useState>({}) const load = async () => { - const [dims, cats, itms] = await Promise.all([ - db.feedbackDimensions.orderBy('order').toArray(), - db.feedbackCategories.orderBy('order').toArray(), - db.feedbackCriterionItems.orderBy('order').toArray(), - ]) - setDimensions(dims) - setCategories(cats) - setItems(itms) + const structure = await loadFeedbackStructure() + setDimensions(structure.dimensions) + setCategories(structure.categories) + setItems(structure.items) } useEffect(() => { load() }, []) @@ -31,24 +33,19 @@ export default function FeedbackStructureConfig() { const addDimension = async () => { if (!newDimName.trim()) return const maxOrder = dimensions.reduce((m, d) => Math.max(m, d.order), 0) - const id = await db.feedbackDimensions.add({ name: newDimName.trim(), order: maxOrder + 1 }) + const id = await addFeedbackDimension({ name: newDimName.trim(), order: maxOrder + 1 }) setNewDimName('') await load() setExpanded(prev => new Set([...prev, id])) } const updateDimension = async (id: number, name: string) => { - await db.feedbackDimensions.update(id, { name }) + await updateFeedbackDimension(id, { name }) setDimensions(prev => prev.map(d => d.id === id ? { ...d, name } : d)) } const deleteDimension = async (id: number) => { - const cats = await db.feedbackCategories.where('dimensionId').equals(id).toArray() - for (const cat of cats) { - await db.feedbackCriterionItems.where('categoryId').equals(cat.id!).delete() - } - await db.feedbackCategories.where('dimensionId').equals(id).delete() - await db.feedbackDimensions.delete(id) + await deleteFeedbackDimension(id) await load() } @@ -57,8 +54,8 @@ export default function FeedbackStructureConfig() { const idx = sorted.findIndex(d => d.id === dim.id) const other = sorted[idx + dir] if (!other) return - await db.feedbackDimensions.update(dim.id!, { order: other.order }) - await db.feedbackDimensions.update(other.id!, { order: dim.order }) + await updateFeedbackDimension(dim.id!, { order: other.order }) + await updateFeedbackDimension(other.id!, { order: dim.order }) await load() } @@ -69,20 +66,19 @@ export default function FeedbackStructureConfig() { if (!name) return const dimCats = categories.filter(c => c.dimensionId === dimId) const maxOrder = dimCats.reduce((m, c) => Math.max(m, c.order), 0) - const id = await db.feedbackCategories.add({ dimensionId: dimId, name, order: maxOrder + 1 }) + const id = await addFeedbackCategory({ dimensionId: dimId, name, order: maxOrder + 1 }) setNewCatName(prev => ({ ...prev, [dimId]: '' })) await load() setExpandedCat(prev => new Set([...prev, id])) } const updateCategory = async (id: number, name: string) => { - await db.feedbackCategories.update(id, { name }) + await updateFeedbackCategory(id, { name }) setCategories(prev => prev.map(c => c.id === id ? { ...c, name } : c)) } const deleteCategory = async (id: number) => { - await db.feedbackCriterionItems.where('categoryId').equals(id).delete() - await db.feedbackCategories.delete(id) + await deleteFeedbackCategory(id) await load() } @@ -93,23 +89,23 @@ export default function FeedbackStructureConfig() { if (!name) return const catItems = items.filter(i => i.categoryId === catId) const maxOrder = catItems.reduce((m, i) => Math.max(m, i.order), 0) - await db.feedbackCriterionItems.add({ categoryId: catId, name, order: maxOrder + 1 }) + await addFeedbackCriterionItem({ categoryId: catId, name, order: maxOrder + 1 }) setNewItemName(prev => ({ ...prev, [catId]: '' })) await load() } const updateItem = async (id: number, name: string) => { - await db.feedbackCriterionItems.update(id, { name }) + await updateFeedbackCriterionItem(id, { name }) setItems(prev => prev.map(i => i.id === id ? { ...i, name } : i)) } const deleteItem = async (id: number) => { - await db.feedbackCriterionItems.delete(id) + await deleteFeedbackCriterionItem(id) setItems(prev => prev.filter(i => i.id !== id)) } const updateItemWeight = async (id: number, weight: number) => { - await db.feedbackCriterionItems.update(id, { weight }) + await updateFeedbackCriterionItem(id, { weight }) setItems(prev => prev.map(i => i.id === id ? { ...i, weight } : i)) } diff --git a/src/pages/RatingWeightConfig.tsx b/src/pages/RatingWeightConfig.tsx index d389390..ea241b1 100644 --- a/src/pages/RatingWeightConfig.tsx +++ b/src/pages/RatingWeightConfig.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { db } from '../db' +import { getOrSeedRatingTrendWeightStep, saveRatingTrendWeightStep } from '../db' import { DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants' import { previewWeights } from '../utils/ratingTrend' @@ -8,19 +8,11 @@ export default function RatingWeightConfig() { const [saved, setSaved] = useState(false) useEffect(() => { - const load = async () => { - let settings = await db.appSettings.get(1) - if (!settings) { - await db.appSettings.add({ id: 1, ratingTrendWeightStep: DEFAULT_RATING_TREND_WEIGHT_STEP }) - settings = await db.appSettings.get(1) - } - setWeightStep(settings?.ratingTrendWeightStep ?? DEFAULT_RATING_TREND_WEIGHT_STEP) - } - load() + getOrSeedRatingTrendWeightStep().then(setWeightStep) }, []) const save = async () => { - await db.appSettings.put({ id: 1, ratingTrendWeightStep: weightStep }) + await saveRatingTrendWeightStep(weightStep) setSaved(true) setTimeout(() => setSaved(false), 2500) } diff --git a/src/pages/meeting/AssessmentTab.tsx b/src/pages/meeting/AssessmentTab.tsx index a199e8b..8b354e6 100644 --- a/src/pages/meeting/AssessmentTab.tsx +++ b/src/pages/meeting/AssessmentTab.tsx @@ -5,7 +5,7 @@ import type { } from '../../db' import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants' import { detectNotationRating } from '../../utils/notationParser' -import { numberToRating } from '../../utils/ratingTrend' +import { numberToRating, weightedAverageRating } from '../../utils/ratingTrend' interface Props { institutees: Consultant[] @@ -27,18 +27,7 @@ function catMode( fbItems: FeedbackCriterionItem[], getScore: (itemId: number) => FeedbackRating | null, ): FeedbackRating | null { - const scored = fbItems - .filter(i => i.categoryId === cat.id) - .map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 })) - .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') - if (scored.length === 0) return null - const wSum = scored.reduce((s, x) => s + x.w, 0) - if (wSum === 0) return null // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch - const avg = scored.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum - if (avg < 1.5) return 'not_client_ready' - if (avg < 2.5) return 'partially_client_ready' - if (avg < 3.5) return 'nearly_client_ready' - return 'client_ready' + return weightedAverageRating(fbItems.filter(i => i.categoryId === cat.id), getScore) } function suggestedRating(entries: ConversationEntry[], instituteeId: number, itemId: number): FeedbackRating | null { diff --git a/src/pages/meeting/MeetingView.tsx b/src/pages/meeting/MeetingView.tsx index 15dc917..4655ac6 100644 --- a/src/pages/meeting/MeetingView.tsx +++ b/src/pages/meeting/MeetingView.tsx @@ -1,10 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { - db, type Assignment, type MeetingInstance, type PhaseTemplate, type FeedbackCategory, type FeedbackCriterionItem, type FeedbackRating, type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment, + getMeetingInstance, updateMeetingInstance, softDeleteMeetingInstance, + getAssignmentType, + getAssignment, + getPhaseTemplate, + getConsultantsByIds, + listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure, + listConversationEntriesForMeeting, addConversationEntry, updateConversationEntry, deleteConversationEntry, + listConversationSkillScoresAll, addConversationSkillScore, updateConversationSkillScore, deleteConversationSkillScoresForEntry, + listAssessmentsForMeeting, addAssessment, updateAssessment, } from '../../db' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport' import { NotationText, splitNoteLines } from '../../utils/notationParser' @@ -52,38 +60,36 @@ export default function MeetingView() { entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0) const loadData = useCallback(async () => { - const m = await db.meetingInstances.get(meetingId) + const m = await getMeetingInstance(meetingId) if (!m) return setMeeting(m) setGeneralNotes(m.generalNotes ?? '') const [tmpl, a] = await Promise.all([ - db.phaseTemplates.get(m.phaseTemplateId), - db.assignments.get(m.assignmentId), + getPhaseTemplate(m.phaseTemplateId), + getAssignment(m.assignmentId), ]) setTemplate(tmpl ?? null) setAssignment(a ?? null) if (a) { - const cons = await db.consultants.where('id').anyOf(a.instituteeIds).toArray() + const cons = await getConsultantsByIds(a.instituteeIds) setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean)) } const [allCats, allItems, aType] = await Promise.all([ - db.feedbackCategories.toArray(), - db.feedbackCriterionItems.toArray(), - a ? db.assignmentTypes.get(a.assignmentTypeId) : Promise.resolve(undefined), + listFeedbackCategoriesAll(), + listFeedbackCriterionItemsAll(), + a ? getAssignmentType(a.assignmentTypeId) : Promise.resolve(undefined), ]) - const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null - const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems - const visibleCatIds = new Set(filteredItems.map(i => i.categoryId)) - setFbItems(filteredItems) - setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats) + const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType?.criteriaIds) + setFbItems(visibleItems) + setFbCategories(visibleCats) const [convEntries, convScores, asmts] = await Promise.all([ - db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'), - db.conversationSkillScores.toArray(), - db.assessments.where('meetingInstanceId').equals(meetingId).toArray(), + listConversationEntriesForMeeting(meetingId), + listConversationSkillScoresAll(), + listAssessmentsForMeeting(meetingId), ]) setEntries(convEntries) setScores(convScores) @@ -97,13 +103,13 @@ export default function MeetingView() { const activateInstitutee = async (instId: number) => { if (activeEntryId !== null) { if (entryNote.trim() === '') { - await db.conversationEntries.delete(activeEntryId) + await deleteConversationEntry(activeEntryId) } else { - await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) + await updateConversationEntry(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) } } const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0) - const eid = await db.conversationEntries.add({ + const eid = await addConversationEntry({ meetingInstanceId: meetingId, instituteeId: instId, sequenceIndex: maxSeq + 1, note: '', fillerCount: 0, updatedAt: new Date().toISOString(), }) @@ -126,7 +132,7 @@ export default function MeetingView() { const saveEntryNote = async () => { if (activeEntryId === null) return - await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) + await updateConversationEntry(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() }) setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e)) } @@ -134,13 +140,13 @@ export default function MeetingView() { if (activeEntryId === null) return const entry = entries.find(e => e.id === activeEntryId) const newCount = (entry?.fillerCount ?? 0) + 1 - await db.conversationEntries.update(activeEntryId, { fillerCount: newCount }) + await updateConversationEntry(activeEntryId, { fillerCount: newCount }) setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e)) } const closeActiveEntry = async () => { if (activeEntryId !== null && entryNote.trim() === '') { - await db.conversationEntries.delete(activeEntryId) + await deleteConversationEntry(activeEntryId) } else { await saveEntryNote() } @@ -156,7 +162,7 @@ export default function MeetingView() { const entry = entries.find(e => e.id === entryId) const currentLines = splitNoteLines(editNote) const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text)) - await db.conversationEntries.update(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() }) + await updateConversationEntry(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() }) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e)) setEditingId(null) } @@ -167,24 +173,24 @@ export default function MeetingView() { const currentLines = splitNoteLines(entry.note) const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text)) const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }] - await db.conversationEntries.update(entryId, { lineTags: nextTags }) + await updateConversationEntry(entryId, { lineTags: nextTags }) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e)) } const deleteEntry = async (entryId: number) => { - await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete() - await db.conversationEntries.delete(entryId) + await deleteConversationSkillScoresForEntry(entryId) + await deleteConversationEntry(entryId) setEntries(prev => prev.filter(e => e.id !== entryId)) } const adjustFiller = async (entry: ConversationEntry, delta: number) => { const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta) - await db.conversationEntries.update(entry.id!, { fillerCount: newCount }) + await updateConversationEntry(entry.id!, { fillerCount: newCount }) setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e)) } const changeEntrySpeaker = async (entryId: number, instituteeId: number) => { - await db.conversationEntries.update(entryId, { instituteeId }) + await updateConversationEntry(entryId, { instituteeId }) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e)) } @@ -194,11 +200,11 @@ export default function MeetingView() { const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => { const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId) if (existing?.id) { - await db.conversationSkillScores.update(existing.id, { score }) + await updateConversationSkillScore(existing.id, { score }) } else { - await db.conversationSkillScores.add({ conversationEntryId: entryId, criteriaId: categoryId, score }) + await addConversationSkillScore({ conversationEntryId: entryId, criteriaId: categoryId, score }) } - setScores(await db.conversationSkillScores.toArray()) + setScores(await listConversationSkillScoresAll()) } // ── Assessment handlers ─────────────────────────────────────────────────── @@ -210,22 +216,22 @@ export default function MeetingView() { const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId) const now = new Date().toISOString() if (existing?.id) { - await db.assessments.update(existing.id, { score, updatedAt: now }) + await updateAssessment(existing.id, { score, updatedAt: now }) } else { - await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now }) + await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now }) } - setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray()) + setAssessments(await listAssessmentsForMeeting(meetingId)) } const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => { const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId) const now = new Date().toISOString() if (existing?.id) { - await db.assessments.update(existing.id, { note, updatedAt: now }) + await updateAssessment(existing.id, { note, updatedAt: now }) } else { - await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now }) + await addAssessment({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now }) } - setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray()) + setAssessments(await listAssessmentsForMeeting(meetingId)) } const autoCalcAssessment = async (instituteeId: number) => { @@ -248,7 +254,7 @@ export default function MeetingView() { const saveGeneralNotes = (val: string) => { setGeneralNotes(val) if (notesTimer.current) clearTimeout(notesTimer.current) - notesTimer.current = setTimeout(() => db.meetingInstances.update(meetingId, { generalNotes: val }), 600) + notesTimer.current = setTimeout(() => updateMeetingInstance(meetingId, { generalNotes: val }), 600) } const buildFilename = (ext: string) => { @@ -273,13 +279,13 @@ export default function MeetingView() { const handleDeleteMeeting = async () => { await saveEntryNote() - await db.meetingInstances.update(meetingId, { deletedAt: new Date().toISOString() }) + await softDeleteMeetingInstance(meetingId) navigate(`/assignment/${assignment?.id}`) } const finishMeeting = async () => { await saveEntryNote() - await db.meetingInstances.update(meetingId, { status: 'done' }) + await updateMeetingInstance(meetingId, { status: 'done' }) navigate(`/assignment/${assignment?.id}`) } diff --git a/src/utils/dbBackup.ts b/src/utils/dbBackup.ts index f2c6c71..fc5662b 100644 --- a/src/utils/dbBackup.ts +++ b/src/utils/dbBackup.ts @@ -1,32 +1,6 @@ -import { db } from '../db' +import { rpc } from '../db/rpcClient' import { downloadFile } from './meetingExport' -// Muss mit den Tabellen in db/schema.ts synchron gehalten werden — eine neue Tabelle dort -// landet nicht automatisch im Backup, sondern muss hier manuell ergänzt werden. -const TABLE_NAMES = [ - 'categories', - 'criteria', - 'assignmentTypes', - 'phaseTemplates', - 'groups', - 'consultants', - 'assignments', - 'meetingInstances', - 'conversationEntries', - 'conversationSkillScores', - 'assessments', - 'feedbackDrafts', - 'feedbackDimensions', - 'feedbackCategories', - 'feedbackCriterionItems', - 'assignmentFeedbacks', - 'feedbackCategoryRatings', - 'feedbackDimensionTexts', - 'criterionCategoryMappings', - 'criterionLevelDescriptions', - 'appSettings', -] as const - export interface DbBackup { version: number exportedAt: string @@ -34,16 +8,7 @@ export interface DbBackup { } export async function exportFullBackup(): Promise { - const tables: Record = {} - for (const name of TABLE_NAMES) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tables[name] = await (db as any)[name].toArray() - } - const backup: DbBackup = { - version: 1, - exportedAt: new Date().toISOString(), - tables, - } + const backup = await rpc('backup', 'exportAll') const date = new Date().toLocaleDateString('de-DE').replace(/\./g, '-') downloadFile(JSON.stringify(backup, null, 2), `assignment-monitor-backup-${date}.json`, 'application/json') } @@ -57,19 +22,7 @@ export async function importFullBackup(file: File): Promise<{ ok: boolean; error return { ok: false, error: 'Ungültiges Backup-Format' } } - // Clear all tables and reimport in dependency order - await db.transaction('rw', TABLE_NAMES.map(n => (db as any)[n]), async () => { - for (const name of [...TABLE_NAMES].reverse()) { - await (db as any)[name].clear() - } - for (const name of TABLE_NAMES) { - const rows = backup.tables[name] - if (Array.isArray(rows) && rows.length > 0) { - await (db as any)[name].bulkAdd(rows) - } - } - }) - + await rpc('backup', 'importAll', backup.tables) return { ok: true } } catch (e) { return { ok: false, error: e instanceof Error ? e.message : String(e) } diff --git a/src/utils/meetingExport.ts b/src/utils/meetingExport.ts index 18b530c..5cb1c71 100644 --- a/src/utils/meetingExport.ts +++ b/src/utils/meetingExport.ts @@ -1,24 +1,9 @@ -import { db } from '../db' +import { getMeetingExportData, getFeedbackCriterionItemsByIds } from '../db' export async function exportMeetingAsMarkdown(meetingId: number): Promise { - const meeting = await db.meetingInstances.get(meetingId) - if (!meeting) throw new Error('Meeting nicht gefunden') - - const [template, assignment] = await Promise.all([ - db.phaseTemplates.get(meeting.phaseTemplateId), - db.assignments.get(meeting.assignmentId), - ]) + const { meeting, template, assignment, consultants, entries, allScores, assessments } = await getMeetingExportData(meetingId) const instituteeIds = assignment?.instituteeIds ?? [] - const [consultants, entries, allScores, assessments] = await Promise.all([ - instituteeIds.length > 0 - ? db.consultants.where('id').anyOf(instituteeIds).toArray() - : Promise.resolve([]), - db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'), - db.conversationSkillScores.toArray(), - db.assessments.where('meetingInstanceId').equals(meetingId).toArray(), - ]) - const critIds = [ ...new Set([ ...(template?.criteriaIds ?? []), @@ -26,9 +11,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise (e.lineTags ?? []).map(t => t.criterionItemId)), ]), ] - const criteria = critIds.length > 0 - ? await db.feedbackCriterionItems.where('id').anyOf(critIds).toArray() - : [] + const criteria = await getFeedbackCriterionItemsByIds(critIds) const institutees = instituteeIds .map(id => consultants.find(c => c.id === id)) @@ -124,23 +107,7 @@ export async function exportMeetingAsMarkdown(meetingId: number): Promise { - const meeting = await db.meetingInstances.get(meetingId) - if (!meeting) throw new Error('Meeting nicht gefunden') - - const [template, assignment] = await Promise.all([ - db.phaseTemplates.get(meeting.phaseTemplateId), - db.assignments.get(meeting.assignmentId), - ]) - - const instituteeIds = assignment?.instituteeIds ?? [] - const [consultants, entries, allScores, assessments] = await Promise.all([ - instituteeIds.length > 0 - ? db.consultants.where('id').anyOf(instituteeIds).toArray() - : Promise.resolve([]), - db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'), - db.conversationSkillScores.toArray(), - db.assessments.where('meetingInstanceId').equals(meetingId).toArray(), - ]) + const { meeting, template, assignment, consultants, entries, allScores, assessments } = await getMeetingExportData(meetingId) const entryIds = entries.map(e => e.id!) const entryScores = allScores.filter(s => entryIds.includes(s.conversationEntryId)) diff --git a/src/utils/ratingTrend.ts b/src/utils/ratingTrend.ts index 0eec4b1..c5296c1 100644 --- a/src/utils/ratingTrend.ts +++ b/src/utils/ratingTrend.ts @@ -14,6 +14,33 @@ export interface TrendResult { delta: number | null } +/** + * Gewichteter Rohwert (0–4) über eine Menge von Kriterien, N/A ausgeschlossen. + * `null` wenn nichts bewertet ist oder Σweight===0 (sonst NaN < x überall false → fällt auf "Fully" durch). + * Einzige Implementierung dieser Regel — nicht pro Callsite neu schreiben, siehe CLAUDE.md. + */ +export function weightedAverageScore( + items: { id?: number; weight?: number }[], + getScore: (itemId: number) => FeedbackRating | null, +): number | null { + const rated = items + .map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 })) + .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') + if (rated.length === 0) return null + const wSum = rated.reduce((s, x) => s + x.w, 0) + if (wSum === 0) return null + return rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum +} + +/** Wie weightedAverageScore, aber als FeedbackRating-Bucket statt Rohwert. */ +export function weightedAverageRating( + items: { id?: number; weight?: number }[], + getScore: (itemId: number) => FeedbackRating | null, +): FeedbackRating | null { + const avg = weightedAverageScore(items, getScore) + return avg === null ? null : numberToRating(avg) +} + /** * Ein Wert pro Meeting für eine Gruppe von Kriterien (eine Kategorie oder alle Items für "overall"). * Meetings ohne bewertetes Item aus der Gruppe werden übersprungen — ein Delivery-Call, der eine @@ -21,19 +48,14 @@ export interface TrendResult { */ export function buildGroupMeetingScores( items: FeedbackCriterionItem[], - meetingsChronological: { id: number; date: string }[], + meetingsChronological: { id?: number; date: string }[], getScore: (meetingId: number, itemId: number) => FeedbackRating | null, ): MeetingScorePoint[] { const points: MeetingScorePoint[] = [] for (const meeting of meetingsChronological) { - const rated = items - .map(i => ({ score: getScore(meeting.id, i.id!), w: i.weight ?? 1 })) - .filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na') - if (rated.length === 0) continue - const wSum = rated.reduce((s, x) => s + x.w, 0) - if (wSum === 0) continue // alle beteiligten Kriterien auf Gewicht 0 → keine Aussage möglich, sonst NaN < x ist überall false und fällt auf "Fully" durch - const score = rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum - points.push({ meetingId: meeting.id, date: meeting.date, score }) + const score = weightedAverageScore(items, itemId => getScore(meeting.id!, itemId)) + if (score === null) continue + points.push({ meetingId: meeting.id!, date: meeting.date, score }) } return points } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..5991839 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "files": [], "references": [ { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.server.json" } ] } diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..0e66066 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["server", "src/db/types.ts"] +} diff --git a/vite.config.ts b/vite.config.ts index 07a6409..d8eba41 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,9 @@ export default defineConfig({ server: { port: process.env.PORT ? Number(process.env.PORT) : 5173, strictPort: !!process.env.PORT, + proxy: { + '/api': process.env.API_URL ?? 'http://localhost:4000', + }, }, plugins: [ react(),