From 82d1b7eab447c4342c5e748ba741cbac8335cf20 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Thu, 6 Aug 2026 15:08:59 +0200 Subject: [PATCH] feat: Projekt Scanner integrieren, Whisper in die Einstellungen Der eigenstaendige Projekt Scanner zieht komplett ins CRM ein und laeuft parallel zur bestehenden App weiter - jede geteilte Ressource ist verdoppelt statt geteilt, damit die alte Installation unberuehrt bleibt: - eigene Datenbank perinco_scanner, legt sich beim Start selbst an - eigener SCANNER_ENCRYPTION_KEY, eigenes Upload-Verzeichnis - eigener Scheduler mit Zeitversatz, damit nicht beide Instanzen gleichzeitig mit denselben Portal-Cookies scrapen (Rate-Limits, CAPTCHA, doppelter RAM) - Sperre gegen ueberlappende Laeufe: Scheduler und "Jetzt scannen" teilen sie, ein zweiter Aufruf bekommt 409 statt doppelter OpenAI-Kosten Backend unter /api/scanner hinter der CRM-Anmeldung; die HTTP-Basic-Auth des Originals entfaellt. CV-PDFs kommen als Stream mit Token statt aus einem statischen Verzeichnis. Bestandsdaten holt "npm run scanner:import" aus der alten Datenbank, ohne sie zu veraendern. Oberflaeche in Tailwind und CI-Farben neu gebaut: Menuepunkt Scanner mit aufklappbaren Unterpunkten, Einstellungen als eigener Reiter. Whisper hat keinen eigenen Menuepunkt mehr, sondern ist ein Reiter in den Einstellungen. Damit diese Geraeteeinstellung jeder Rolle offensteht, sind die Einstellungen jetzt fuer alle sichtbar - welche Reiter erscheinen, entscheidet die Rolle. Ausserdem: kein festes Admin-Passwort mehr im Quelltext, playwright exakt auf 1.60.0 gepinnt (nutzt den vorhandenen Chromium-Build des Servers), technische Dokumentation mit Funktionszeichnungen unter docs/. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 12 + README.md | 658 ++++ deploy/deploy.ps1 | 60 + deploy/pcrm.perinco.de-le-ssl.conf | 27 + deploy/pcrm.perinco.de.conf | 35 + deploy/perinco-crm-backend.service | 23 + deploy/scanner-setup.sh | 109 + docs/01-architektur.md | 98 + docs/02-datenmodell.md | 183 + docs/03-scanner.md | 184 + docs/04-diktat.md | 85 + docs/05-frontend.md | 111 + docs/06-betrieb.md | 134 + docs/README.md | 59 + index.html | 36 + package-lock.json | 3426 +++++++++++++++++ package.json | 29 + public/favicon.png | Bin 0 -> 3042 bytes public/logo.png | Bin 0 -> 162277 bytes server/.env.example | 71 + server/package-lock.json | 1796 +++++++++ server/package.json | 33 + server/src/db.js | 82 + server/src/middleware/auth.js | 54 + server/src/routes/auth.js | 128 + server/src/routes/connections.js | 154 + server/src/routes/events.js | 287 ++ server/src/routes/leads.js | 205 + server/src/routes/opportunities.js | 427 ++ server/src/routes/scanner.js | 552 +++ server/src/routes/users.js | 237 ++ server/src/scanner/db/config.js | 33 + server/src/scanner/db/ensureDatabase.js | 35 + server/src/scanner/importLegacy.js | 287 ++ server/src/scanner/models/Agent.js | 51 + server/src/scanner/models/Match.js | 85 + server/src/scanner/models/Portal.js | 44 + server/src/scanner/models/Project.js | 61 + server/src/scanner/models/Setting.js | 50 + server/src/scanner/models/index.js | 86 + server/src/scanner/services/cleanup.js | 23 + server/src/scanner/services/matchingEngine.js | 79 + server/src/scanner/services/pdfParser.js | 22 + server/src/scanner/services/pipeline.js | 286 ++ server/src/scanner/services/scheduler.js | 125 + server/src/scanner/services/scraper.js | 720 ++++ server/src/scanner/uploadDir.js | 29 + server/src/scanner/utils/crypto.js | 48 + server/src/schema.sql | 153 + server/src/server.js | 119 + server/src/userFields.js | 36 + src/App.tsx | 200 + src/components/Header.tsx | 62 + src/components/LoginScreen.tsx | 145 + src/components/Sidebar.tsx | 315 ++ src/components/Toast.tsx | 78 + src/components/calendar/Calendar.tsx | 770 ++++ src/components/calendar/EventDetailModal.tsx | 169 + src/components/calendar/EventModal.tsx | 286 ++ src/components/calendar/UserDetailModal.tsx | 148 + src/components/calendar/calendarUtils.ts | 199 + src/components/calendar/mentions.tsx | 333 ++ src/components/leads/LeadGrid.tsx | 157 + src/components/leads/LeadList.tsx | 153 + src/components/leads/LeadModal.tsx | 508 +++ src/components/leads/LeadNetwork.tsx | 1001 +++++ src/components/leads/Leads.tsx | 301 ++ src/components/leads/leadUi.tsx | 56 + .../opportunities/Opportunities.tsx | 427 ++ .../opportunities/OpportunityGrid.tsx | 206 + .../opportunities/OpportunityList.tsx | 260 ++ .../opportunities/OpportunityModal.tsx | 632 +++ .../opportunities/OpportunityPipeline.tsx | 194 + .../opportunities/opportunityUi.tsx | 136 + src/components/scanner/MatchDetailModal.tsx | 195 + src/components/scanner/ScannerAgents.tsx | 384 ++ src/components/scanner/ScannerDashboard.tsx | 679 ++++ src/components/scanner/ScannerPortals.tsx | 305 ++ src/components/scanner/ScannerRejected.tsx | 158 + src/components/scanner/scannerUi.tsx | 151 + src/components/settings/ColorPicker.tsx | 72 + src/components/settings/ScannerSettings.tsx | 328 ++ src/components/settings/Settings.tsx | 222 ++ src/components/settings/UserManagement.tsx | 363 ++ src/components/ui/AvatarPicker.tsx | 87 + src/components/ui/DictationField.tsx | 243 ++ src/components/ui/Modal.tsx | 115 + src/components/ui/UserAvatar.tsx | 59 + src/components/whisper/WhisperSettings.tsx | 239 ++ src/index.css | 184 + src/lib/api.ts | 197 + src/lib/audio.ts | 43 + src/lib/auth.tsx | 83 + src/lib/dictation.tsx | 142 + src/lib/imageCompress.ts | 101 + src/lib/navigation.tsx | 30 + src/lib/scannerApi.ts | 218 ++ src/lib/theme.tsx | 97 + src/lib/whisper.tsx | 487 +++ src/lib/whisper.worker.ts | 137 + src/lib/whisperModels.ts | 58 + src/main.tsx | 26 + src/types.ts | 272 ++ src/vite-env.d.ts | 1 + tsconfig.json | 25 + vite.config.ts | 29 + 106 files changed, 24133 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/deploy.ps1 create mode 100644 deploy/pcrm.perinco.de-le-ssl.conf create mode 100644 deploy/pcrm.perinco.de.conf create mode 100644 deploy/perinco-crm-backend.service create mode 100644 deploy/scanner-setup.sh create mode 100644 docs/01-architektur.md create mode 100644 docs/02-datenmodell.md create mode 100644 docs/03-scanner.md create mode 100644 docs/04-diktat.md create mode 100644 docs/05-frontend.md create mode 100644 docs/06-betrieb.md create mode 100644 docs/README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.png create mode 100644 public/logo.png create mode 100644 server/.env.example create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/db.js create mode 100644 server/src/middleware/auth.js create mode 100644 server/src/routes/auth.js create mode 100644 server/src/routes/connections.js create mode 100644 server/src/routes/events.js create mode 100644 server/src/routes/leads.js create mode 100644 server/src/routes/opportunities.js create mode 100644 server/src/routes/scanner.js create mode 100644 server/src/routes/users.js create mode 100644 server/src/scanner/db/config.js create mode 100644 server/src/scanner/db/ensureDatabase.js create mode 100644 server/src/scanner/importLegacy.js create mode 100644 server/src/scanner/models/Agent.js create mode 100644 server/src/scanner/models/Match.js create mode 100644 server/src/scanner/models/Portal.js create mode 100644 server/src/scanner/models/Project.js create mode 100644 server/src/scanner/models/Setting.js create mode 100644 server/src/scanner/models/index.js create mode 100644 server/src/scanner/services/cleanup.js create mode 100644 server/src/scanner/services/matchingEngine.js create mode 100644 server/src/scanner/services/pdfParser.js create mode 100644 server/src/scanner/services/pipeline.js create mode 100644 server/src/scanner/services/scheduler.js create mode 100644 server/src/scanner/services/scraper.js create mode 100644 server/src/scanner/uploadDir.js create mode 100644 server/src/scanner/utils/crypto.js create mode 100644 server/src/schema.sql create mode 100644 server/src/server.js create mode 100644 server/src/userFields.js create mode 100644 src/App.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/LoginScreen.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/components/calendar/Calendar.tsx create mode 100644 src/components/calendar/EventDetailModal.tsx create mode 100644 src/components/calendar/EventModal.tsx create mode 100644 src/components/calendar/UserDetailModal.tsx create mode 100644 src/components/calendar/calendarUtils.ts create mode 100644 src/components/calendar/mentions.tsx create mode 100644 src/components/leads/LeadGrid.tsx create mode 100644 src/components/leads/LeadList.tsx create mode 100644 src/components/leads/LeadModal.tsx create mode 100644 src/components/leads/LeadNetwork.tsx create mode 100644 src/components/leads/Leads.tsx create mode 100644 src/components/leads/leadUi.tsx create mode 100644 src/components/opportunities/Opportunities.tsx create mode 100644 src/components/opportunities/OpportunityGrid.tsx create mode 100644 src/components/opportunities/OpportunityList.tsx create mode 100644 src/components/opportunities/OpportunityModal.tsx create mode 100644 src/components/opportunities/OpportunityPipeline.tsx create mode 100644 src/components/opportunities/opportunityUi.tsx create mode 100644 src/components/scanner/MatchDetailModal.tsx create mode 100644 src/components/scanner/ScannerAgents.tsx create mode 100644 src/components/scanner/ScannerDashboard.tsx create mode 100644 src/components/scanner/ScannerPortals.tsx create mode 100644 src/components/scanner/ScannerRejected.tsx create mode 100644 src/components/scanner/scannerUi.tsx create mode 100644 src/components/settings/ColorPicker.tsx create mode 100644 src/components/settings/ScannerSettings.tsx create mode 100644 src/components/settings/Settings.tsx create mode 100644 src/components/settings/UserManagement.tsx create mode 100644 src/components/ui/AvatarPicker.tsx create mode 100644 src/components/ui/DictationField.tsx create mode 100644 src/components/ui/Modal.tsx create mode 100644 src/components/ui/UserAvatar.tsx create mode 100644 src/components/whisper/WhisperSettings.tsx create mode 100644 src/index.css create mode 100644 src/lib/api.ts create mode 100644 src/lib/audio.ts create mode 100644 src/lib/auth.tsx create mode 100644 src/lib/dictation.tsx create mode 100644 src/lib/imageCompress.ts create mode 100644 src/lib/navigation.tsx create mode 100644 src/lib/scannerApi.ts create mode 100644 src/lib/theme.tsx create mode 100644 src/lib/whisper.tsx create mode 100644 src/lib/whisper.worker.ts create mode 100644 src/lib/whisperModels.ts create mode 100644 src/main.tsx create mode 100644 src/types.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08ff838 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +.env +.env.local +server/.env +server/node_modules/ +# Hochgeladene CV-PDFs des Scanners (Standardpfad, wenn SCANNER_UPLOAD_DIR leer ist) +server/uploads/ +# Urspruengliche Aufgabenstellung - enthaelt Server- und Admin-Passwoerter im Klartext +prompt.txt +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..98cb77f --- /dev/null +++ b/README.md @@ -0,0 +1,658 @@ +# perinco CRM + +CRM im perinco Corporate Design. Vite/React-Frontend, Express-Backend, MariaDB. +Enthaelt den portierten *Projekt Scanner*. + +> **Technische Dokumentation mit Funktionszeichnungen:** [`docs/`](docs/README.md) +> — Architektur, Datenmodell, Scanner-Ablauf, Diktat, Frontend, Betrieb. + +## Corporate Identity + +Direkt aus dem perinco Logo gesampelt: + +| Rolle | Wert | +| --- | --- | +| Primaerfarbe (Gruen) | `#6BA04A`, Akzent `#68A33F` | +| Sekundaerfarbe (Grau) | `#6E6E6E` | +| Schrift | Avenir LT (wie perinco.de); Fallback Nunito Sans | + +Die Farbskalen liegen als Tailwind-Theme in `src/index.css` (`brand-*`, `ink-*`) +sowie die Flaechen `canvas` (Seite) und `surface` (Karten, Leisten, Dialoge). + +## Dark Mode + +Umschalter rechts oben im Header. Die Wahl liegt in `localStorage` +(`perinco_crm_theme`); ohne Wahl folgt die App der Systemeinstellung, ein +Doppelklick auf den Umschalter stellt das wieder her. + +Umgesetzt ist der Dark Mode **ohne `dark:`-Varianten im Markup**: die Klasse +`dark` auf `` tauscht in `src/index.css` nur die Werte der Variablen +`canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. Die +Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch. + +Drei Dinge bleiben in beiden Themes gleich und sind deshalb bewusst so gewaehlt: + +| Was | Warum | +| --- | --- | +| `ink-950` | liegt nur unter Dialog-Overlays, muss dunkel bleiben | +| `text-white` | steht immer auf farbigen Flaechen (Buttons, Termin-Chips) | +| `brand-500` | das CI-Gruen der Buttons; nur die Tints darum kippen | + +Das Netzwerkdiagramm kann im SVG keine Tailwind-Klassen stapeln und benutzt +deshalb `var(--color-*)` direkt. Das Logo ist fuer helle Hintergruende gemacht - +die Utility `.logo-adaptive` dreht im Dark Mode die Helligkeit und holt per +`hue-rotate` das Gruen zurueck. + +Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst +blitzt beim Laden die helle Variante auf. + +## Architektur + +``` +Browser ──► Apache (:443/:80, pcrm.perinco.de) + ├── / → /var/www/perinco_CRM/dist (SPA, FallbackResource) + └── /api → 127.0.0.1:5006 (Express) + ├──► MariaDB perinco_CRM (CRM) + └──► MariaDB project_scanner (Scanner) +``` + +Der Scanner haengt unter `/api/scanner` und braucht damit **keine** eigene +Apache-Regel. + +Der Browser spricht **nie** direkt mit MariaDB - deshalb das Express-Backend. +Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber +den Apache-Proxy erreichbar. + +### Datenmodell + +| Tabelle | Inhalt | +| --- | --- | +| `crm_users` | Benutzer, Rolle `admin` oder `user`, bcrypt-Hash, `color` (Kalenderfarbe), `avatar` (Foto) | +| `crm_leads` | Kontakte inkl. `created_by` (Ersteller) und `owner_id` (zustaendig) | +| `crm_opportunities` | Vertriebschancen: Phase, Tagessatz, Laufzeit, letzte Interaktion, Wahrscheinlichkeit | +| `crm_opportunity_contacts` | Ansprechpartner je Opportunity (n:m auf `crm_leads`) mit Rolle | +| `crm_lead_connections` | Wer steht mit wem in Kontakt; Paar aufsteigend gespeichert, Unique-Key verhindert Duplikate | +| `crm_events` | Termine des gemeinsamen Teamkalenders; `description` kann Mention-Tokens enthalten | +| `crm_event_participants` | Teilnehmer je Termin (n:m), Primaerschluessel verhindert Doppeleintraege | + +### Rollen + +* `admin` - sieht den Menuepunkt Einstellungen inkl. Nutzerverwaltung +* `user` - sieht Einstellungen nicht; die API weist Zugriffe zusaetzlich mit 403 ab + +Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator +degradiert, deaktiviert oder geloescht wird. + +Ausnahme von der Admin-Regel ist `GET /api/users/directory`: die Benutzerliste +brauchen alle Rollen fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, +Teilnehmerauswahl und Farben. Passwort-Hashes liefert der Endpunkt nie. +Termine darf jeder anlegen; aendern und loeschen nur der Ersteller oder ein +Administrator. + +## Struktur + +``` +src/ + App.tsx Layout, Datenladen, View-Routing + types.ts Gemeinsame Typen und Konstanten + lib/api.ts Fetch-Wrapper + typisierte Endpunkte + lib/auth.tsx Auth-Context (JWT im localStorage) + lib/theme.tsx Theme-Context (hell/dunkel/System) + lib/navigation.tsx Ansichtswechsel fuer tief liegende Komponenten + lib/whisper.tsx Modelleinstellungen, Worker-Lebenszyklus, Zustand + lib/whisper.worker.ts Whisper im Web Worker (Laden und Erkennen) + lib/audio.ts Aufnahme zu 16-kHz-Mono-PCM + lib/dictation.tsx Aufnahme-Hook (Mikrofon, Dauer, Abbruch) + lib/imageCompress.ts Bilder im Browser auf 256 px verkleinern + components/ + Sidebar.tsx Menue links (Opportunities, Kalender, Kontakte, + Scanner mit aufklappbaren Unterpunkten); + unten Whisper (alle) und Einstellungen (admin only) + Header.tsx Titel, Menue-Toggle, Umschalter fuer den Dark Mode + Toast.tsx, LoginScreen.tsx + ui/Modal.tsx Modal + Bestaetigungsdialog + ui/UserAvatar.tsx Benutzerfoto, sonst Initiale auf der Benutzerfarbe + ui/AvatarPicker.tsx Foto-Upload mit Vorschau (Kontakte und Benutzer) + ui/DictationField.tsx Mikrofon-Button, Hinweisbox, Textfeld mit Diktat + whisper/ + WhisperSettings.tsx Reiter Whisper: Geraetecheck, Modellwahl, Probe + opportunities/ + Opportunities.tsx Suche, Phasenfilter, Sortierung, Ansichtswechsel + OpportunityGrid.tsx Kachelansicht + OpportunityList.tsx Sortierbare Tabelle + OpportunityPipeline.tsx Kanban nach Phase mit Drag & Drop + OpportunityModal.tsx Formular inkl. Ansprechpartnern und Diktat + opportunityUi.tsx Phasenfarben, Aufklapptext, Chancenbalken + calendar/ + Calendar.tsx Monat/Woche/Tag + Liste der naechsten Termine + EventModal.tsx Terminformular inkl. Teilnehmerauswahl + EventDetailModal.tsx Leseansicht mit klickbaren @-Mentions + UserDetailModal.tsx Detailfenster fuer markierte Benutzer + mentions.tsx Mention-Parser, Renderer, @-Autocomplete + calendarUtils.ts Datums-, Raster- und Layoutlogik + leads/ + Leads.tsx Suche, Statusfilter, Ansichtswechsel + LeadGrid.tsx Kachelansicht + LeadList.tsx Listenansicht (sortierbar) + LeadNetwork.tsx Interaktives Netzwerkdiagramm (d3-force + SVG) + LeadModal.tsx Kontaktformular inkl. Verbindungspflege + leadUi.tsx Statusfarben, Eingabestile, Avatar + scanner/ + ScannerDashboard.tsx Kennzahlen, Filter, Match-Tabelle, Signalton + ScannerRejected.tsx Reiter Unpassend (status = rejected) + ScannerAgents.tsx Suchagenten inkl. CV-Upload + ScannerPortals.tsx Boersen-Konten, Cookie-Import, Verbindungstest + MatchDetailModal.tsx Detailfenster eines Treffers (beide Listen) + scannerUi.tsx Prozent-Badge, Status-Pillen, Formatierung + settings/ + Settings.tsx Tabs Nutzerverwaltung / Mein Konto / Scanner + UserManagement.tsx Benutzer anlegen, bearbeiten, loeschen + ScannerSettings.tsx Alle Einstellungen des Projekt Scanners + ColorPicker.tsx Auswahl der persoenlichen Kalenderfarbe + lib/scannerApi.ts Typisierte Endpunkte unter /api/scanner +server/ + src/server.js Express-App + src/db.js Pool + Schema-Init + Admin-Seed + src/schema.sql Idempotentes Schema + src/middleware/auth.js JWT-Pruefung, Admin-Guard + src/userFields.js Pruefung von Farbe und Foto (von auth und users genutzt) + src/routes/ auth, users, leads, connections, events, opportunities, + scanner + src/scanner/ Portierter Projekt Scanner: Sequelize-Modelle, + Scraper (Playwright), Matching (OpenAI), Scheduler +deploy/ + perinco-crm-backend.service systemd-Unit + pcrm.perinco.de.conf Apache-Vhost +``` + +## Opportunities + +Erster Reiter im Menue und Startansicht. Drei Ansichten wie bei den Kontakten, +umgeschaltet oben rechts: + +| Ansicht | Inhalt | +| --- | --- | +| **Kachel** | Karten mit Volumen, Tagessatz, Laufzeit, Zeitraum, letzter Interaktion und naechstem Schritt | +| **Liste** | sortierbare Tabelle, Spaltenkopf klicken kehrt die Richtung um | +| **Pipeline** | Kanban nach Phase, Karten per Drag & Drop verschiebbar | + +Die dritte Ansicht ist bewusst eine Pipeline statt eines Netzwerks: bei einer +Vertriebschance interessiert der Fortschritt durch die Phasen. Wer nicht ziehen +mag oder auf dem Tablet arbeitet, aendert die Phase ueber das Auswahlfeld auf der +Karte. + +**Felder:** Titel, Firma, Projektbeschreibung (in Kachel und Liste +aufklappbar), Phase, Abschlusswahrscheinlichkeit, Tagessatz, Laufzeit in +Personentagen (halbe Tage moeglich), Projektstart und -ende, letzte Interaktion +mit Notiz, naechster Schritt, Tags, interne Notizen, Zustaendigkeit sowie +beliebig viele Ansprechpartner aus den Kontakten - jeder mit eigener Rolle. + +**Volumen** rechnet die App aus Tagessatz x Personentagen, **gewichtet** zusaetzlich +mit der Wahrscheinlichkeit. Beides steht live im Formular, pro Karte, als Summe +ueber der Liste, je Pipeline-Spalte und in der Kopfzeile. + +**Sortieren** geht doppelt: ueber das Auswahlfeld in der Steuerleiste (wirkt in +allen Ansichten) und in der Liste ueber die Spaltenkoepfe. Geld- und Datumsfelder +starten absteigend, Text aufsteigend; leere Werte landen immer hinten. + +Aendern und loeschen darf der Ersteller, der Zustaendige und jeder Administrator. + +## Projekt Scanner + +Der eigenstaendige *Projekt Scanner* ist vollstaendig hier eingezogen und soll +die alte App ersetzen. Die laeuft weiter unter `scanner.perinco.de`: + +``` +/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005 +/var/www/Projekt Scanner/frontend/dist DocumentRoot des Vhosts +systemd-Unit: project-scanner-backend (nicht "project-scanner") +``` + +Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene +Datenbank, eigener Verschluesselungsschluessel, eigenes Upload-Verzeichnis, +eigener Scheduler. Die alte App bleibt unveraendert - weder ihre Dateien noch +ihre Datenbank werden angefasst. + +Menuepunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter +auf: + +| Reiter | Inhalt | +| --- | --- | +| **Dashboard** | Kennzahlen, Filter (Zeitraum, Agent, Schwellenwerte), sortierbare Match-Tabelle, *Jetzt scannen* | +| **Unpassend** | als „passt nicht" markierte Treffer; zurueckholen oder loeschen | +| **Suchagenten** | Beraterprofile: Name, E-Mail, Keywords, Ausschlussbegriffe, CV-PDF, aktiv/inaktiv | +| **Boersen-Konten** | Verbindungsstatus je Portal, Zugangsdaten, Cookie-Import, Verbindungstest | + +Die **Einstellungen** des Scanners stehen nicht im Untermenue, sondern als Reiter +*Scanner* unter *Einstellungen* - also nur fuer Rolle `admin`. Die Reiter selbst +sieht jede Rolle. Anzeige- und Benachrichtigungs-Schwellenwert lassen sich an +beiden Stellen aendern; es ist derselbe Wert in der Datenbank. + +### Was portiert wurde + +| Teil | Wohin | +| --- | --- | +| Sequelize-Modelle (`agents`, `portals`, `projects`, `matches`, `settings`) | `server/src/scanner/models/` | +| Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` | +| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` | +| Oberflaeche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut | +| Uebernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) | + +Drei bewusste Unterschiede zum Original: + +1. **Anmeldung** Die HTTP-Basic-Auth des Scanners entfaellt - alle Endpunkte + liegen hinter `requireAuth`, also der CRM-Sitzung. +2. **CV-PDFs** kommen ueber `GET /api/scanner/agents/:id/cv` statt aus einem + statischen Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token + mitschicken, deshalb holt das Frontend die Datei und oeffnet sie als Blob. +3. **Meldungen** laufen als Toast statt `alert()`, Loeschabfragen als Dialog. + +### Parallelbetrieb - alles doppelt + +Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu beruehren. +Dafuer ist hier jede geteilte Ressource verdoppelt: + +| | alte App | diese Instanz | +| --- | --- | --- | +| Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) | +| Verschluesselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert | +| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` | +| Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` | +| Aufraeumen | 03:00 | 03:30 | + +Die Scanner-Datenbank legt das Backend beim Start **selbst an** +(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`, +Portale und Standardeinstellungen werden geseedet. Es genuegt also, das Backend +zu starten. + +| Variable | Bedeutung | +| --- | --- | +| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen | +| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten | +| `SCANNER_ENCRYPTION_KEY` | eigener Schluessel fuer die Portal-Passwoerter; ohne ihn wird ein bekannter Ersatzschluessel benutzt und beim Start gewarnt | +| `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `/uploads` | +| `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab | +| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard `30` | + +> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhaengig +> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App +> laeuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen +> will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert +> dann weiter. + +Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben +Portal-Sessions (aus derselben Cookie-Quelle) und wuerden nach einem Neustart +gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei +Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den +ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschraenkt. +Wird der Wert >= Suchintervall gesetzt, wird er ignoriert. + +Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler +und *Jetzt scannen* teilen sich eine Sperre (`isPipelineRunning`), ein zweiter +Aufruf bekommt 409 und den Hinweis *„Ein Suchlauf läuft bereits"*. Das Dashboard +zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche, +pausiert, Suchlauf läuft - samt Namen der Datenbank. + +### Daten aus der alten App uebernehmen + +Damit die neue Instanz nicht bei Null anfaengt, holt ein Skript Agenten, Portale +und Einstellungen herueber. Es liest die alte Datenbank nur, geschrieben wird +ausschliesslich in `perinco_scanner`: + +```bash +cd /var/www/perinco_CRM/server +npm run scanner:import -- --dry-run # nur anzeigen +npm run scanner:import # Einstellungen, Portale, Agenten + CVs +npm run scanner:import -- --with-matches # zusaetzlich Projekte und Treffer +npm run scanner:import -- --force # vorhandene Zieldaten vorher loeschen +``` + +Quelle kommt aus `LEGACY_*` in `server/.env`: + +| Variable | Wofuer | +| --- | --- | +| `LEGACY_DB_NAME` | Standard `project_scanner` | +| `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten | +| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anfuehrungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen | +| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwoerter uebernehmen: entschluesseln mit dem alten, neu verschluesseln mit dem eigenen Schluessel | + +Uebernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der +DB) - der neue Scanner ist damit sofort scharf. + +Agenten, Projekte und Treffer behalten ihre Ids, damit uebernommene Treffer +weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript +ab, wenn im Ziel schon Agenten stehen - `--force` raeumt vorher auf. + +> **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank +> gespeicherten Portal-Passwoerter lassen sich mit dem dortigen +> `ENCRYPTION_KEY` **nicht** mehr entschluesseln - der Schluessel wurde nach dem +> Speichern getauscht. Die alte App kann sie damit ebenso wenig lesen; es ging +> also nichts verloren. Wer den Notfall-Login des Scrapers will, gibt die +> Passwoerter unter *Boersen-Konten* neu ein. Die Sessions (Cookies) sind davon +> unberuehrt und wurden uebernommen. + +### Playwright + +`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende +Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits +in `/root/.cache/ms-playwright`, also faellt kein zweiter Browser-Download an und +gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der +Version wuerde beides aufgeben, deshalb steht in `server/package.json` ein +`comments`-Feld mit dieser Begruendung. + +## Diktat (Whisper im Browser) + +Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz, +naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung +laeuft **vollstaendig im Browser des Benutzers**: Whisper als ONNX-Modell ueber +`@huggingface/transformers`, gerechnet auf der Grafikkarte (WebGPU) oder +ersatzweise auf der CPU (WASM). + +``` +MediaRecorder ──► AudioContext (16 kHz Mono) ──► Web Worker ──► Whisper (WebGPU/WASM) +``` + +Das Backend ist daran **nicht beteiligt** und sieht keine Audiodaten - es gibt +keinen API-Key, keine Kosten pro Minute und keinen Datenabfluss. Aufnahmen +werden nirgends gespeichert. + +* Die Erkennung laeuft in einem **Web Worker** (`src/lib/whisper.worker.ts`), + damit die Oberflaeche waehrenddessen nicht einfriert. +* Die **ONNX-Laufzeit** wird mit ausgeliefert (Vite bundelt die `.wasm`), die + **Modelldateien** kommen beim ersten Laden vom Hugging-Face-CDN und bleiben + danach im Browser-Cache. Wer auch das vermeiden will, kann die Modelle auf den + eigenen Server legen und `env.localModelPath` setzen. + +### Reiter "Whisper" + +Unten im Menue, ueber den Einstellungen, fuer **alle Rollen** sichtbar. Es gibt +dort **nichts zu konfigurieren** - genau ein Knopf: + +1. Reiter oeffnen, dort steht *"Einmalig werden 299 MB Sprachmodell geladen."* +2. **Sprachmodell laden (299 MB)** klicken, Fortschrittsbalken abwarten +3. Fertig - ab jetzt sind die Mikrofon-Buttons in den Formularen aktiv, auch + nach jeder weiteren Anmeldung + +Dazu gibt es eine Probeaufnahme und **Sprachmodell loeschen**, falls man den +Platz zurueckhaben will. Sonst nichts: keine Modellauswahl, keine Genauigkeit, +kein Rechenweg, keine Geraetetabelle. + +Das ist Absicht. Modell, Genauigkeit, Rechenweg und Sprache stehen als +Konstanten in `src/lib/whisper.tsx` - `small`, `q4`, Grafikkarte wenn vorhanden, +Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder +kaputt (siehe unten); eine Auswahl haette also nur Gelegenheit geboten, etwas +falsch zu machen. + +Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in +diesem Browser liegt - ein Download von 759 MB startet nie von allein. Der +Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht, +bekommt es nicht sofort wieder heruntergeladen. + +### Wo das Modell liegt und wie lange + +Nicht in Cookies - dort passen nur wenige Kilobyte. Die Dateien liegen im +**Cache-Speicher der Seite** (`caches`, Name `transformers-cache`), der +Sitzungen und Browserneustarts ueberlebt. Beim Laden bittet die App zusaetzlich +per `navigator.storage.persist()` um dauerhaften Speicher; damit darf der Browser +die Dateien bei Platznot nicht mehr selbst wegraeumen. Chrome entscheidet darueber +nach eigenen Kriterien und lehnt haeufig ab - dann funktioniert alles genauso, nur +ohne diese Zusicherung. + +Geraet oder Browserprofil gewechselt? Dann faellt der Download erneut an; der +Speicher gehoert zum Profil, nicht zum Benutzerkonto. Findet die App das Modell +nach dem Laden nicht im Cache, sagt sie es ausdruecklich. + +### Warum small und q4 - die Messungen + +Die folgenden Zahlen sind der Grund, warum es keine Auswahl gibt. Gemessen mit +derselben deutschen Testaufnahme (Windows-Sprachausgabe, also schwieriger als +eine echte Stimme) auf einem Rechner mit NVIDIA-GPU, jeweils in q4: + +| Modell | Erkennung des Satzes | Download | Laden | +| --- | --- | --- | --- | +| tiny | "auf es vier Hanna", "Der Tag ist satscht bei L-10 Euro" | 99 MB | 4 s | +| small | "auf Esfihana", "Der Tagessatz liegt bei 1100 Euro" | 299 MB | 10 s | +| **large-v3-turbo** | **"auf S4 HANA"**, "Der Tagessatz liegt bei 1100 Euro" | 759 MB | 13 s | + +Alle drei erkennen etwa 4x schneller als Echtzeit; auf Geraeten ohne WebGPU +verschiebt sich das Tempo erheblich. + +Festgelegt ist **large-v3-turbo**: nur dieses Modell bekommt Fachbegriffe wie +"S4 HANA" richtig, und genau daran scheitern die kleineren im Alltag. Die 759 MB +fallen einmal pro Browser an - danach liegt das Modell im Cache-Speicher der +Seite und ueberlebt Browserneustarts (gemessen: 762 MB, zweite Sitzung ohne +jeden Download bereit). + +`small` ist der Kompromiss: turbo erkennt besser, wird vom Browser aber nicht +zwischengespeichert (siehe Groessengrenzen unten) und laedt darum bei jedem Start +erneut 759 MB. + +### Welche Genauigkeit auf welchem Rechenweg + +Deshalb ist `q4` festgelegt: + +| | q4 | q8 | fp16 | fp32 | +| --- | --- | --- | --- | --- | +| **WebGPU** | tiny 1,4 s · small 1,9 s · turbo 3,8 s | Kauderwelsch oder keine Antwort | tiny laeuft, small blockiert den Tab | Einzeldatei zu gross | +| **WASM** | laeuft | small 10,3 s | ungetestet | Einzeldatei zu gross | + +Der wichtigste Grund fuer diese Beschraenkung: bei `small` in fp16 auf WebGPU +reagiert der ganze Browser-Tab nicht mehr - **kein Timeout in der Seite kann das +abfangen**, weil das Skript selbst nicht mehr laeuft. + +Genau deshalb ist die Auswahl verschwunden: `q4` steht fest, und damit kann diese +Klasse von Fehlern nicht mehr auftreten. Als Netz bleibt trotzdem eine Frist pro +Erkennung und eine Haenger-Erkennung beim Laden - falls ein Geraet trotzdem etwas +Unerwartetes tut. + +Zusaetzlich hat jede Erkennung eine Frist (15x Aufnahmedauer, mindestens 45 s, +hoechstens 5 min). Laeuft sie ab, wird der Worker verworfen und der Fehler +gemeldet, statt dauerhaft "Erkenne..." zu zeigen. + +### Die Groessengrenze des Browsers + +Nicht die Gesamtgroesse entscheidet, sondern die **groesste Einzeldatei** - der +Browser muss sie in einem zusammenhaengenden Speicherblock halten. Ueber etwa +500 MB bricht das Laden mit "Array buffer allocation failed" ab. Belege: 425 MB +(turbo in q4) laden und laufen, 688 MB (turbo in fp32) scheitern. + +Genau daran scheitern fuer turbo alle Genauigkeiten ausser q4 - ein weiterer +Grund, warum es keine Auswahl gibt. + +Die Downloadgroesse im Reiter kommt live von Hugging Face +(`src/lib/whisperModels.ts`), damit dort eine echte Zahl steht. + +> **Korrektur:** In einer frueheren Fassung stand hier eine zweite Grenze von +> etwa 300 MB pro Cache-Eintrag, oberhalb derer das Modell angeblich nicht +> zwischengespeichert wird. Das war falsch - ein Artefakt der Testumgebung. +> Von Playwright gestartete Chrome-Instanzen schreiben den Cache-Speicher nicht, +> ein normal gestartetes Chrome dagegen sehr wohl: turbo/q4 liegt dort mit +> 762 MB im Cache und ueberlebt einen Browserneustart. Deshalb prueft +> `scratchpad/realchrome.mjs` die Persistenz gegen ein normal gestartetes +> Chrome und nicht gegen den Automatisierungskontext. + +> **Version festgehalten:** `@huggingface/transformers` ist auf `^3.8.1` +> gepinnt. Version 4.2.0 zieht eine andere ONNX-Laufzeit (`asyncify` statt +> `jsep`, letztere ist die WebGPU-Variante) - vor einem Wechsel also erst die +> Modelle real gegentesten. + +### Wenn das Modell fehlt + +Solange nichts geladen ist, erscheint im Opportunity-Formular ein Hinweis mit +Knopf **Zu Whisper**; die einzelnen Mikrofon-Buttons werden zu *Diktat +einrichten*. Beides schliesst den Dialog und springt in den Reiter. Auf Geraeten +ohne Mikrofon oder ohne HTTPS erscheint gar kein Diktat. + +## Kalender + +Ein gemeinsamer Teamkalender: alle Benutzer sehen dieselben Termine, jeder darf +Eintraege anlegen. + +* **Ansichten** Monat, Woche, Tag; `Heute` und die Pfeile blaettern. Klick auf + einen Monatstag oder eine Stundenzelle legt direkt einen Termin an, Klick auf + einen Wochentagskopf springt in die Tagesansicht. +* **Farben** Jeder Termin hat die Farbe seines Erstellers. Die Farbe kommt aus + `crm_users.color`; ist sie leer, wird sie stabil aus der Benutzer-ID + abgeleitet (Palette in `src/types.ts`). Aenderbar unter *Einstellungen → Mein + Konto* und - fuer Admins fuer alle - in der Nutzerverwaltung. Die Legende + unter dem Kalender zeigt, welche Farbe zu wem gehoert. +* **Teilnehmer** Ein Termin kann beliebig viele Benutzer haben; sie erscheinen + mit Foto - oder, wenn keines hinterlegt ist, als Initiale auf ihrer + Kalenderfarbe - und sind anklickbar. +* **@-Mentions** In der Beschreibung `@` tippen, um Kontakte *und* Benutzer zu + markieren (Pfeiltasten, Enter/Tab zum Uebernehmen). Gespeichert wird ein Token + `@[Anzeigename](lead:12)` bzw. `@[Anzeigename](user:3)`. In der Leseansicht + sind Mentions klickbar: Kontakte oeffnen das Kontaktfenster, Benutzer ein + Detailfenster mit Farbe, naechsten Terminen und zugeordneten Kontakten. + Existiert die Person nicht mehr, bleibt der Name als grauer Text stehen. +* **Naechste Termine** Rechts neben dem Kalender, nach Tagen gruppiert und + zeitlich sortiert - der naechste anstehende Termin steht oben. Vergangene + Termine fallen aus der Liste, bleiben im Kalender aber erhalten. + +## Fotos + +Fotos von Kontakten und Benutzern liegen als Data-URL in der Datenbank +(`crm_leads.avatar`, `crm_users.avatar`). Damit das die Nutzlast nicht aufblaeht, +verkleinert der Browser jedes Bild **vor** dem Upload +(`src/lib/imageCompress.ts`): + +* mittiger Ausschnitt auf ein Quadrat von 256 px - mehr braucht keine Ansicht, + die groesste ist 80 px +* WebP mit Qualitaet 0,82, Fallback JPEG, dann PNG +* aus einem 2,9-MB-Foto werden so einige Kilobyte + +Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die +Laenge; die Quelldatei darf bis 25 MB gross sein, weil sie ohnehin +komprimiert wird. + +Benutzerfotos pflegt jeder fuer sich unter *Einstellungen → Mein +Konto*, Admins fuer alle in der Nutzerverwaltung. Angezeigt werden sie in der +Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den +@-Vorschlaegen, in der Nutzerverwaltung und als Benutzerknoten im +Netzwerkdiagramm. Ohne Foto erscheint ueberall die Initiale auf der +Kalenderfarbe (`UserAvatar`). + +Ueberlappende Termine teilen sich im Zeitraster die Spaltenbreite, mehrtaegige +Termine erscheinen an jedem betroffenen Tag, und eine rote Linie markiert die +aktuelle Uhrzeit. + +## Netzwerkansicht + +Kraftgerichtetes Layout (`d3-force`), gerendert als SVG. + +* **Kreise** = Kontakte, Farbe nach Status, Groesse nach Anzahl Verbindungen +* **Quadrate** = Benutzer, die Kontakte angelegt haben +* **Gruene Linie** = Kontakt ↔ Kontakt (Dicke nach Verbindungsstaerke) +* **Graue gestrichelte Linie** = Ersteller → Kontakt + +Bedienung: Hintergrund ziehen verschiebt, Mausrad zoomt auf den Cursor, +Knoten ziehen fixiert ihn (gruener Punkt), Doppelklick loest die Fixierung. +Ueber *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen; +Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter. + +## Lokale Entwicklung + +```bash +npm install +cd server && npm install && cp .env.example .env # Werte anpassen +npm start # Backend :5006 +cd .. && npm run dev # Frontend :3100, /api wird geproxied +``` + +Fuer lokale Entwicklung braucht es eine erreichbare MariaDB mit der Datenbank +`perinco_CRM`; das Schema legt das Backend beim Start selbst an. Die +Scanner-Datenbank `perinco_scanner` legt es komplett selbst an, inklusive +Tabellen, Portalen und Standardeinstellungen. + +## Deployment + +Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen - +der Server hat wenig freien RAM und volles Swap. + +```powershell +npm run build +powershell -File deploy/deploy.ps1 # laedt dist/ und server/src hoch +powershell -File deploy/deploy.ps1 -Deps # zusaetzlich npm install auf dem Server +``` + +Ein Rollout mit neuen Backend-Paketen braucht `-Deps`. + +### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt) + +```bash +# 1. .env ergaenzen: erzeugt SCANNER_ENCRYPTION_KEY, holt die LEGACY_*-Werte +# aus "/var/www/Projekt Scanner/backend/.env", legt vorher ein .env.bak an +bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh + +# 2. Bestand uebernehmen +cd /var/www/perinco_CRM/server +npm run scanner:import -- --dry-run +npm run scanner:import -- --with-matches + +# 3. MemoryMax anheben und neu starten +cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/ +systemctl daemon-reload && systemctl restart perinco-crm-backend +journalctl -u perinco-crm-backend -n 40 +``` + +Die Datenbank `perinco_scanner` muss **nicht** von Hand angelegt werden, das +erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe +Abschnitt *Playwright* oben. + +> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten +> App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die +> Aenderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb +> direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im +> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos). +> Wer das vermeiden will, macht Schritt 3 vor Schritt 2. + +Das hoehere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet +Chromium als Kindprozess in derselben cgroup, mit 512M wuerde der OOM-Killer das +Backend mitten im Scrapen beenden. `deploy.ps1` laedt die Unit nicht mit hoch - +daher der `cp` oben. + +> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner +> gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET` +> verschiebt die Laeufe gegeneinander, garantiert aber nichts - der alte Dienst +> hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap +> ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht. + +Manuell entspricht das: + +```powershell +pscp -r -P 4345 dist root@212.227.135.41:/var/www/perinco_CRM/ +pscp -r -P 4345 server\src root@212.227.135.41:/var/www/perinco_CRM/server/ +pscp -P 4345 server\package.json root@212.227.135.41:/var/www/perinco_CRM/server/ +plink -P 4345 root@212.227.135.41 "systemctl restart perinco-crm-backend" +``` + +### Serverseitige Einrichtung (einmalig, bereits erledigt) + +```bash +mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS perinco_CRM + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mkdir -p /var/www/perinco_CRM/server +# .env anlegen (chmod 600), Abhaengigkeiten installieren +cd /var/www/perinco_CRM/server && npm install --omit=dev +cp deploy/perinco-crm-backend.service /etc/systemd/system/ +systemctl daemon-reload && systemctl enable --now perinco-crm-backend +cp deploy/pcrm.perinco.de.conf /etc/apache2/sites-available/ +a2ensite pcrm.perinco.de && apache2ctl configtest && systemctl reload apache2 +``` + +### HTTPS (offen - braucht DNS) + +Sobald `pcrm.perinco.de` per A-Record auf `212.227.135.41` zeigt: + +```bash +certbot --apache -d pcrm.perinco.de +``` + +certbot legt den `:443`-Block selbst an. Bei anderer Subdomain den `ServerName` +in `deploy/pcrm.perinco.de.conf` und den certbot-Aufruf anpassen. + +## Zugang + +Beim ersten Backend-Start wird ein Administrator aus den `ADMIN_*`-Variablen in +`server/.env` angelegt - aber nur, wenn `crm_users` leer ist. Das Passwort ist +danach ausschliesslich ueber die Nutzerverwaltung bzw. *Mein Konto* aenderbar. diff --git a/deploy/deploy.ps1 b/deploy/deploy.ps1 new file mode 100644 index 0000000..23b610d --- /dev/null +++ b/deploy/deploy.ps1 @@ -0,0 +1,60 @@ +# perinco CRM - Deployment auf 212.227.135.41 +# +# Baut lokal und laedt nur dist/ plus Backend-Quellen hoch. Auf dem Server wird +# absichtlich nicht gebaut: dort sind RAM und Swap knapp. +# +# Aufruf aus dem Projektverzeichnis: +# powershell -File deploy/deploy.ps1 +# powershell -File deploy/deploy.ps1 -SkipBuild +# powershell -File deploy/deploy.ps1 -Deps # bei neuen Backend-Paketen + +param( + [string]$ServerHost = '212.227.135.41', + [int]$Port = 4345, + [string]$User = 'root', + [string]$Password = $env:PERINCO_CRM_SSH_PASSWORD, + [string]$RemotePath = '/var/www/perinco_CRM', + [switch]$SkipBuild, + [switch]$Deps +) + +$ErrorActionPreference = 'Stop' + +if (-not $Password) { + throw 'Kein Passwort. Setze $env:PERINCO_CRM_SSH_PASSWORD oder uebergib -Password.' +} + +$plink = 'C:\Program Files\PuTTY\plink.exe' +$pscp = 'C:\Program Files\PuTTY\pscp.exe' +foreach ($tool in @($plink, $pscp)) { + if (-not (Test-Path $tool)) { throw "Nicht gefunden: $tool" } +} + +$projectRoot = Split-Path -Parent $PSScriptRoot +Set-Location $projectRoot + +if (-not $SkipBuild) { + Write-Host '==> Frontend bauen' -ForegroundColor Green + npm run build + if ($LASTEXITCODE -ne 0) { throw 'Build fehlgeschlagen - es wird nichts hochgeladen.' } +} + +$target = "$User@$ServerHost" + +Write-Host '==> dist/ hochladen' -ForegroundColor Green +& $pscp -r -batch -P $Port -pw $Password 'dist' "${target}:$RemotePath/" + +Write-Host '==> Backend hochladen' -ForegroundColor Green +& $pscp -r -batch -P $Port -pw $Password 'server\src' "${target}:$RemotePath/server/" +& $pscp -batch -P $Port -pw $Password 'server\package.json' "${target}:$RemotePath/server/" + +$remoteCommand = "systemctl restart perinco-crm-backend; sleep 3; systemctl is-active perinco-crm-backend; curl -s http://127.0.0.1:5006/api/health" +if ($Deps) { + $remoteCommand = "cd $RemotePath/server && npm install --omit=dev --no-audit --no-fund; $remoteCommand" +} + +Write-Host '==> Backend neu starten und pruefen' -ForegroundColor Green +& $plink -ssh -batch -P $Port -pw $Password $target $remoteCommand + +Write-Host '' +Write-Host 'Fertig.' -ForegroundColor Green diff --git a/deploy/pcrm.perinco.de-le-ssl.conf b/deploy/pcrm.perinco.de-le-ssl.conf new file mode 100644 index 0000000..60d7eb8 --- /dev/null +++ b/deploy/pcrm.perinco.de-le-ssl.conf @@ -0,0 +1,27 @@ + + + ServerName pcrm.perinco.de + DocumentRoot /var/www/perinco_CRM/dist + + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + # Single Page Application: alle unbekannten Pfade auf index.html + FallbackResource /index.html + + + # API laeuft lokal auf 127.0.0.1:5006 und ist von aussen nur hierueber + # erreichbar. + ProxyPreserveHost On + ProxyPass /api http://127.0.0.1:5006/api + ProxyPassReverse /api http://127.0.0.1:5006/api + + ErrorLog ${APACHE_LOG_DIR}/perinco_crm_error.log + CustomLog ${APACHE_LOG_DIR}/perinco_crm_access.log combined + +SSLCertificateFile /etc/letsencrypt/live/pcrm.perinco.de/fullchain.pem +SSLCertificateKeyFile /etc/letsencrypt/live/pcrm.perinco.de/privkey.pem +Include /etc/letsencrypt/options-ssl-apache.conf + + diff --git a/deploy/pcrm.perinco.de.conf b/deploy/pcrm.perinco.de.conf new file mode 100644 index 0000000..d52006a --- /dev/null +++ b/deploy/pcrm.perinco.de.conf @@ -0,0 +1,35 @@ +# perinco CRM - Apache Vhost +# Ablage: /etc/apache2/sites-available/pcrm.perinco.de.conf +# Aktivieren: a2ensite pcrm.perinco.de && systemctl reload apache2 +# +# Reihenfolge beim Aufsetzen: +# 1. DNS A-Record pcrm.perinco.de -> 212.227.135.41 +# 2. Diese Datei aktivieren (HTTP-Teil), Apache neu laden +# 3. certbot --apache -d pcrm.perinco.de (legt den :443-Block selbst an) +# +# ServerName bei anderer Subdomain hier UND im certbot-Aufruf anpassen. + + + ServerName pcrm.perinco.de + DocumentRoot /var/www/perinco_CRM/dist + + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + # Single Page Application: alle unbekannten Pfade auf index.html + FallbackResource /index.html + + + # API laeuft lokal auf 127.0.0.1:5006 und ist von aussen nur hierueber + # erreichbar. + ProxyPreserveHost On + ProxyPass /api http://127.0.0.1:5006/api + ProxyPassReverse /api http://127.0.0.1:5006/api + + ErrorLog ${APACHE_LOG_DIR}/perinco_crm_error.log + CustomLog ${APACHE_LOG_DIR}/perinco_crm_access.log combined +RewriteEngine on +RewriteCond %{SERVER_NAME} =pcrm.perinco.de +RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent] + diff --git a/deploy/perinco-crm-backend.service b/deploy/perinco-crm-backend.service new file mode 100644 index 0000000..5f45898 --- /dev/null +++ b/deploy/perinco-crm-backend.service @@ -0,0 +1,23 @@ +[Unit] +Description=perinco CRM Backend (Express + MariaDB) +After=network.target mariadb.service +Wants=mariadb.service + +[Service] +Type=simple +User=root +WorkingDirectory=/var/www/perinco_CRM/server +EnvironmentFile=/var/www/perinco_CRM/server/.env +ExecStart=/usr/bin/node src/server.js +Restart=on-failure +RestartSec=5 +# Der Server hostet weitere Dienste - Speicher bewusst begrenzen, damit ein +# Leak im CRM nicht n8n/Docker/Apache verdraengt. +# +# 512M reichten fuer das reine CRM. Mit dem portierten Projekt Scanner startet +# ein Scan-Lauf Chromium als Kindprozess, und der zaehlt in dieselbe cgroup - +# mit 512M wuerde der OOM-Killer das Backend mitten im Scrapen abschiessen. +MemoryMax=1536M + +[Install] +WantedBy=multi-user.target diff --git a/deploy/scanner-setup.sh b/deploy/scanner-setup.sh new file mode 100644 index 0000000..d4e42da --- /dev/null +++ b/deploy/scanner-setup.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# --------------------------------------------------------------------------- +# Einmalige Einrichtung des portierten Projekt Scanners im CRM-Backend. +# +# Ergaenzt /var/www/perinco_CRM/server/.env um die SCANNER_*- und LEGACY_*- +# Variablen. Idempotent: vorhandene Werte werden nicht ueberschrieben, der +# Verschluesselungsschluessel wird nur erzeugt, wenn noch keiner drinsteht. +# +# Geheimnisse werden nie ausgegeben - nur, ob ein Wert gesetzt ist. +# +# Aufruf auf dem Server: +# bash /tmp/scanner-setup.sh +# --------------------------------------------------------------------------- +set -euo pipefail + +CRM_ENV=/var/www/perinco_CRM/server/.env +# Achtung: der Pfad der laufenden Scanner-App enthaelt ein Leerzeichen, deshalb +# ueberall in Anfuehrungszeichen. Die Unit heisst project-scanner-backend und +# hoert auf Port 5005. +LEGACY_DIR="/var/www/Projekt Scanner/backend" +LEGACY_ENV="$LEGACY_DIR/.env" + +if [ ! -f "$CRM_ENV" ]; then + echo "FEHLER: $CRM_ENV fehlt." + exit 1 +fi + +cp -a "$CRM_ENV" "$CRM_ENV.bak.$(date +%Y%m%d%H%M%S)" + +# Wert nur setzen, wenn der Schluessel fehlt oder leer ist. +# +# Werte mit Leerzeichen werden gequotet: systemd liest diese Datei als +# EnvironmentFile und trennt dort an Leerzeichen - ein ungequoteter Pfad wie +# "/var/www/Projekt Scanner/backend/uploads" kaeme abgeschnitten an. dotenv +# entfernt die Quotes beim Einlesen wieder. +upsert() { + local key="$1" value="$2" + if grep -qE "^${key}=.+" "$CRM_ENV"; then + echo " $key: bleibt unveraendert" + return + fi + # Leere Zeile des Schluessels entfernen, dann neu anhaengen. + sed -i "/^${key}=/d" "$CRM_ENV" + case "$value" in + *[[:space:]]*) printf '%s="%s"\n' "$key" "$value" >> "$CRM_ENV" ;; + *) printf '%s=%s\n' "$key" "$value" >> "$CRM_ENV" ;; + esac + echo " $key: gesetzt" +} + +# Aus der alten .env lesen, ohne sie in die eigene Umgebung zu uebernehmen. +legacy_value() { + local key="$1" + [ -f "$LEGACY_ENV" ] || return 0 + sed -n "s/^${key}=//p" "$LEGACY_ENV" | tail -n1 | tr -d '\r' +} + +echo "== Scanner-Konfiguration in $CRM_ENV ==" + +# Kopfzeile nur beim ersten Lauf, sonst sammelt sich bei jedem Aufruf eine an. +if ! grep -q '^# --- Projekt Scanner' "$CRM_ENV"; then + printf '\n# --- Projekt Scanner (eingerichtet durch deploy/scanner-setup.sh) ---\n' >> "$CRM_ENV" +fi + +upsert SCANNER_DB_NAME perinco_scanner +upsert SCANNER_UPLOAD_DIR /var/www/perinco_CRM/server/uploads +upsert SCANNER_SCHEDULER true +upsert SCANNER_SCHEDULER_OFFSET 30 + +if grep -qE '^SCANNER_ENCRYPTION_KEY=.+' "$CRM_ENV"; then + echo " SCANNER_ENCRYPTION_KEY: bleibt unveraendert" +else + sed -i '/^SCANNER_ENCRYPTION_KEY=/d' "$CRM_ENV" + printf 'SCANNER_ENCRYPTION_KEY=%s\n' "$(openssl rand -hex 32)" >> "$CRM_ENV" + echo " SCANNER_ENCRYPTION_KEY: neu erzeugt (64 Hex-Zeichen)" +fi + +echo "== Uebernahme aus der alten App ==" + +if [ ! -f "$LEGACY_ENV" ]; then + echo " $LEGACY_ENV nicht gefunden - LEGACY_* bleibt leer, Import faellt aus." +else + LEGACY_DB_NAME_VALUE="$(legacy_value DB_NAME)" + LEGACY_DB_USER_VALUE="$(legacy_value DB_USER)" + LEGACY_DB_PASSWORD_VALUE="$(legacy_value DB_PASSWORD)" + LEGACY_DB_HOST_VALUE="$(legacy_value DB_HOST)" + LEGACY_DB_PORT_VALUE="$(legacy_value DB_PORT)" + LEGACY_KEY_VALUE="$(legacy_value ENCRYPTION_KEY)" + + upsert LEGACY_DB_NAME "${LEGACY_DB_NAME_VALUE:-project_scanner}" + upsert LEGACY_DB_HOST "${LEGACY_DB_HOST_VALUE:-localhost}" + upsert LEGACY_DB_PORT "${LEGACY_DB_PORT_VALUE:-3306}" + upsert LEGACY_DB_USER "${LEGACY_DB_USER_VALUE:-root}" + upsert LEGACY_DB_PASSWORD "${LEGACY_DB_PASSWORD_VALUE:-}" + upsert LEGACY_UPLOAD_DIR "$LEGACY_DIR/uploads" + + if [ -n "$LEGACY_KEY_VALUE" ]; then + upsert LEGACY_ENCRYPTION_KEY "$LEGACY_KEY_VALUE" + else + echo " LEGACY_ENCRYPTION_KEY: in der alten .env nicht gefunden - Portal-Passwoerter werden nicht uebernommen" + fi +fi + +chmod 600 "$CRM_ENV" + +echo "== Ergebnis (Werte maskiert) ==" +grep -E '^(SCANNER|LEGACY)_' "$CRM_ENV" | sed -E 's/=(.+)/= /' | sort -u + +echo "Fertig." diff --git a/docs/01-architektur.md b/docs/01-architektur.md new file mode 100644 index 0000000..671205d --- /dev/null +++ b/docs/01-architektur.md @@ -0,0 +1,98 @@ +# 01 Architektur + +## Bausteine + +Drei Teile, keiner davon spricht direkt mit einem anderen ausser ueber HTTP bzw. +SQL: + +| Teil | Technik | Wo | +| --- | --- | --- | +| Frontend | React 19, TypeScript, Tailwind 4, Vite | statisches Build in `/var/www/perinco_CRM/dist` | +| Backend | Express 4, mysql2 (CRM), Sequelize (Scanner) | `/var/www/perinco_CRM/server`, `127.0.0.1:5006` | +| Datenbank | MariaDB 10.11 | `perinco_CRM` und `perinco_scanner` | + +Der Browser spricht **nie** direkt mit MariaDB - deshalb ueberhaupt ein Backend. +Das Backend bindet ausschliesslich auf `127.0.0.1` und ist von aussen nur ueber +den Apache-Reverse-Proxy erreichbar. + +## Topologie + +```mermaid +flowchart LR + U(("Browser")) -->|"HTTPS"| A["Apache :443
pcrm.perinco.de"] + A -->|"/"| D["dist/
SPA, FallbackResource"] + A -->|"/api"| E["Express :5006"] + E --> C[("perinco_CRM")] + E --> S[("perinco_scanner")] + + U2(("Browser")) -->|"HTTPS"| A2["Apache :443
scanner.perinco.de"] + A2 -->|"/api, /uploads, /health"| E2["alte Scanner-App :5005"] + E2 --> S2[("project_scanner")] + + classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3 + class U2,A2,E2,S2 alt +``` + +Der Scanner im CRM haengt unter `/api/scanner` und braucht damit **keine eigene +Apache-Regel**: alles laeuft ueber den bestehenden `/api`-Proxy. + +## Anfrageweg mit Anmeldung + +```mermaid +sequenceDiagram + actor B as Browser + participant A as Apache + participant E as Express + participant M as MariaDB + + B->>A: POST /api/auth/login + A->>E: weiterleiten + E->>M: SELECT crm_users WHERE username + M-->>E: Zeile mit bcrypt-Hash + E->>E: bcrypt.compare + E-->>B: { token (JWT, 12 h), user } + Note over B: Token im localStorage + + B->>A: GET /api/scanner/matches
Authorization: Bearer ... + A->>E: weiterleiten + E->>E: requireAuth: JWT pruefen + E->>M: SELECT crm_users WHERE id (ist der noch aktiv?) + E->>M: SELECT matches JOIN agents, projects + M-->>E: Treffer + E-->>B: JSON +``` + +Bei `401` loescht das Frontend das Token und springt zurueck auf den +Anmeldeschirm (`setUnauthorizedHandler` in `src/lib/api.ts`). Das Token wird bei +jeder Anfrage neu gegen die Datenbank geprueft - ein deaktivierter Benutzer ist +sofort draussen, ohne auf den Ablauf des Tokens zu warten. + +## Rollen + +```mermaid +flowchart TD + L["Anmeldung"] --> R{"Rolle"} + R -->|admin| A["Opportunities, Kalender, Kontakte, Scanner
Einstellungen: Nutzerverwaltung, Mein Konto, Scanner, Whisper"] + R -->|user| U["Opportunities, Kalender, Kontakte, Scanner
Einstellungen: Mein Konto, Whisper"] +``` + +Die Rollenpruefung sitzt **doppelt**: das Menue blendet aus, was nicht erlaubt +ist, und das Backend weist Zugriffe zusaetzlich mit `403` ab (`requireAdmin`). +Ausnahme ist `GET /api/users/directory` - die Benutzerliste brauchen alle Rollen +fuer Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und +Kalenderfarben. Passwort-Hashes liefert der Endpunkt nie. + +Ein Sicherheitsnetz im Backend verhindert, dass der letzte aktive Administrator +degradiert, deaktiviert oder geloescht wird. + +## Belegte Ports auf dem Server + +Der Rechner hostet weitere Dienste (n8n, Docker, ~15 Apache-Vhosts). Vor jeder +Portbelegung `ss -tlnp` pruefen. Relevant hier: + +| Port | Dienst | +| --- | --- | +| 5006 | perinco CRM Backend (nur `127.0.0.1`) | +| 5005 | alte Scanner-App (`0.0.0.0`) | +| 3000 | weitere Vite-App | +| 5678 | n8n | diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md new file mode 100644 index 0000000..ca3b993 --- /dev/null +++ b/docs/02-datenmodell.md @@ -0,0 +1,183 @@ +# 02 Datenmodell + +Zwei Datenbanken, absichtlich getrennt: + +| Datenbank | Inhalt | Zugriff | +| --- | --- | --- | +| `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine | `mysql2` direkt, Schema aus `server/src/schema.sql` | +| `perinco_scanner` | Suchagenten, Portale, Projekte, Treffer, Scanner-Einstellungen | Sequelize, Schema aus den Modellen (`sequelize.sync()`) | + +Die Trennung ist kein Zufall: die Scanner-Tabellen sind eine 1:1-Uebernahme aus +der eigenstaendigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer +eigenen Datenbank, damit beide Anwendungen parallel laufen koennen. + +## perinco_CRM + +```mermaid +erDiagram + crm_users ||--o{ crm_leads : "owner_id / created_by" + crm_users ||--o{ crm_opportunities : "owner_id / created_by" + crm_users ||--o{ crm_events : created_by + crm_users ||--o{ crm_event_participants : nimmt_teil + crm_events ||--o{ crm_event_participants : hat + crm_leads ||--o{ crm_lead_connections : from_lead_id + crm_leads ||--o{ crm_opportunity_contacts : ist + crm_opportunities ||--o{ crm_opportunity_contacts : hat + + crm_users { + int id PK + varchar username UK + varchar email UK + varchar name + enum role "admin | user" + varchar color "Kalenderfarbe, NULL = aus Id abgeleitet" + longtext avatar "Data-URL" + varchar password_hash "bcrypt" + tinyint is_active + } + crm_leads { + int id PK + varchar first_name + varchar last_name + varchar company + enum status "Neu .. Verloren" + longtext tags "JSON-Array als Text" + longtext avatar + int owner_id FK + int created_by FK + } + crm_lead_connections { + int id PK + int from_lead_id FK + int to_lead_id FK + enum type "Kollege .. Sonstige" + tinyint strength "1 lose, 2 normal, 3 eng" + } + crm_opportunities { + int id PK + varchar title + enum stage "Neu .. Verloren" + tinyint probability "0-100" + decimal daily_rate + decimal duration_days "Personentage, halbe moeglich" + date last_interaction_at + varchar next_step + int owner_id FK + } + crm_opportunity_contacts { + int opportunity_id PK,FK + int lead_id PK,FK + varchar role "z.B. Entscheider" + } + crm_events { + int id PK + varchar title + text description "kann @[Name](lead:12) enthalten" + datetime starts_at + datetime ends_at + tinyint all_day + int created_by FK + } + crm_event_participants { + int event_id PK,FK + int user_id PK,FK + } +``` + +Drei Eigenheiten, die man kennen muss: + +- **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf aelteren + MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert. +- **`crm_lead_connections` speichert das Paar sortiert** (kleinere Id in + `from_lead_id`). Der Unique-Key verhindert damit auch die Umkehrung als + Duplikat, obwohl die Verbindung ungerichtet gedacht ist. +- **`avatar` ist eine Data-URL in der Datenbank.** Der Browser verkleinert jedes + Bild vor dem Upload auf 256 px (`src/lib/imageCompress.ts`), aus 2,9 MB werden + wenige Kilobyte. + +## perinco_scanner + +```mermaid +erDiagram + agents ||--o{ matches : bewertet_fuer + projects ||--o{ matches : bewertet + portals + + agents { + int id PK + varchar name + varchar email "fuer Benachrichtigungen" + longtext cv_text "aus dem PDF extrahiert" + varchar cv_file_path "Pfad im Upload-Verzeichnis" + varchar keywords "kommagetrennt, Suchbegriffe" + text exclude_keywords "kommagetrennt, Ausschluss" + tinyint is_active + } + projects { + int id PK + varchar portal "freelancermap.de, hays.de, ..." + varchar portal_project_id "Id der Boerse" + varchar title + longtext description + varchar location + varchar rate + varchar url + datetime published_at + timestamp created_at "gescrapt am, Aufbewahrung 7 Tage" + } + matches { + int id PK + int agent_id FK + int project_id FK + int match_percentage "0-100, von OpenAI" + text pros "JSON-Array" + text cons "JSON-Array" + varchar status "new | reviewed | applied | rejected" + tinyint notification_sent + } + portals { + int id PK + varchar name UK + varchar username + varchar password_encrypted "AES, SCANNER_ENCRYPTION_KEY" + longtext session_state "Playwright storageState als JSON" + varchar connection_status "connected | expired | error | disconnected" + datetime last_login_attempt + } + settings { + int id PK + varchar key_name UK + text value "alles als Text, auch Zahlen und Schalter" + } +``` + +- **`matches` hat einen Unique-Key auf `(agent_id, project_id)`.** Dadurch wird + jedes Projekt pro Agent genau einmal von OpenAI bewertet - das ist der Grund, + warum ein zweiter Scan-Lauf ueber dieselben Projekte nichts kostet. +- **`projects` wird nach 7 Tagen aufgeraeumt** (`cleanupOldProjects`), die + Treffer verschwinden per `ON DELETE CASCADE` mit. +- **`settings` ist ein Schluessel-Wert-Speicher.** Geheimnisse (`openai_api_key`, + `smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder + maskiertes Feld beim Speichern laesst den gespeicherten Wert unberuehrt. + +## Statuswege + +```mermaid +stateDiagram-v2 + [*] --> new : Treffer wird angelegt + new --> reviewed : "Als Gesehen markieren" + new --> applied : "Als Beworben markieren" + reviewed --> applied + applied --> new : "Zurueck auf Neu" + reviewed --> new + new --> rejected : "Passt nicht" + reviewed --> rejected + applied --> rejected + rejected --> new : "Zurueck ins Dashboard" + rejected --> [*] : loeschen + new --> [*] : loeschen +``` + +`rejected` ist der einzige Status, der aus dem Dashboard verschwindet und +stattdessen im Reiter *Unpassend* auftaucht. Solche Treffer zaehlen auch in den +Kennzahlen nicht mit. diff --git a/docs/03-scanner.md b/docs/03-scanner.md new file mode 100644 index 0000000..4d3dbf3 --- /dev/null +++ b/docs/03-scanner.md @@ -0,0 +1,184 @@ +# 03 Projekt Scanner + +Der Scanner durchsucht Projektboersen nach Auftraegen und bewertet jeden Fund mit +OpenAI gegen die Lebenslaeufe der Berater. Er ist eine vollstaendige Portierung +der eigenstaendigen App und laeuft parallel zu ihr. + +## Ein Suchlauf + +```mermaid +sequenceDiagram + participant C as node-cron
jede Minute + participant P as Pipeline + participant PW as Playwright + participant B as Projektboersen + participant DB as perinco_scanner + participant AI as OpenAI gpt-4o-mini + participant M as SMTP + + C->>P: faellig? (Intervall, Mo-Fr, 07-22 Uhr, Sperrfrist) + P->>P: laeuft schon einer? dann abbrechen + P->>DB: aktive Agenten laden + P->>P: Keywords aller Agenten zusammenfuehren + P->>DB: Portal-Sessions laden + + loop je Portal + P->>PW: Chromium starten, Cookies setzen + PW->>B: Suchseite je Keyword + B-->>PW: Projektkarten + PW-->>P: Projekte (Titel, Beschreibung, Ort, Rate, URL) + end + + loop je Projekt x Agent + P->>DB: Treffer schon vorhanden? dann weiter + P->>P: Ausschlussbegriff im Text? dann weiter + P->>AI: Lebenslauf vs. Projektbeschreibung + AI-->>P: { matchPercentage, pros[], cons[] } + P->>DB: Treffer speichern + alt Prozent >= Benachrichtigungs-Schwelle + P->>M: E-Mail an Agent oder Standardempfaenger + end + end +``` + +Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene +Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein +Projekt, das „Arbeitnehmerueberlassung" enthaelt, kostet damit keinen Cent. + +## Wann laeuft er + +```mermaid +flowchart TD + T["Cron-Tick, jede Minute"] --> A{"scanning_active?"} + A -->|nein| STOP1["nichts tun"] + A -->|ja| B{"Intervall geaendert?"} + B -->|ja| RE["neu planen"] + B -->|nein| C{"Sperrfrist nach Start
SCANNER_SCHEDULER_OFFSET"} + C -->|noch aktiv| STOP2["nichts tun"] + C -->|abgelaufen| D{"Mo-Fr?"} + D -->|nein| STOP3["nichts tun"] + D -->|ja| E{"07:00-21:59?"} + E -->|nein| STOP4["nichts tun"] + E -->|ja| F{"Intervall seit
letztem Lauf um?"} + F -->|nein| STOP5["nichts tun"] + F -->|ja| G["Pipeline starten"] +``` + +Die **Sperrfrist** ist die Antwort auf ein konkretes Problem: beide Instanzen +benutzen dieselben Portal-Cookies. Ohne Versatz ziehen nach einem Neustart beide +gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium +gleichzeitig belasten den Speicher doppelt. + +> Fruehere Fassung war fehlerhaft: der Versatz wurde ueber einen vorgealterten +> `lastRunTime` gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, +> nie verzoegern - bei Intervall 15 und Versatz 30 lief er deshalb sofort. Jetzt +> ist es eine eigene Frist (`notBefore`). + +## Parallelbetrieb - alles verdoppelt + +```mermaid +flowchart LR + subgraph alt["alte App (scanner.perinco.de)"] + A1["Express :5005"] --> A2[("project_scanner")] + A1 --> A3["uploads/ in
/var/www/Projekt Scanner/backend"] + A4["ENCRYPTION_KEY"] --- A1 + end + + subgraph neu["CRM (pcrm.perinco.de)"] + N1["Express :5006
/api/scanner"] --> N2[("perinco_scanner")] + N1 --> N3["uploads/ in
/var/www/perinco_CRM/server"] + N4["SCANNER_ENCRYPTION_KEY"] --- N1 + end + + B["Projektboersen"] + O["OpenAI"] + A1 --> B + N1 --> B + A1 --> O + N1 --> O + + IMP["npm run scanner:import"] -.->|"liest"| A2 + IMP -.->|"schreibt"| N2 + IMP -.->|"kopiert"| N3 +``` + +| | alte App | CRM | +| --- | --- | --- | +| Datenbank | `project_scanner` | `perinco_scanner` | +| Schluessel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` | +| CV-Dateien | eigener Ordner | eigener Ordner | +| Aufraeumen | 03:00 | 03:30 | +| Anmeldung | HTTP Basic | CRM-Sitzung (JWT) | + +**Der Preis:** Beide bewerten dieselben Projekte unabhaengig voneinander, die +OpenAI-Kosten fallen also doppelt an. Wer das senken will, stellt im CRM ein +groesseres Suchintervall ein - die Einstellung gilt nur fuer diese Instanz. + +## Uebernahme aus der alten Datenbank + +`server/src/scanner/importLegacy.js`, aufrufbar als `npm run scanner:import`: + +```mermaid +flowchart TD + S["Start"] --> C{"Zieltabelle agents leer?"} + C -->|nein, ohne --force| AB["Abbruch"] + C -->|ja oder --force| E["Einstellungen kopieren
inkl. OpenAI-Key"] + E --> P["Portale ueber den Namen zuordnen:
Benutzername, Session-Cookies, Status"] + P --> PW{"LEGACY_ENCRYPTION_KEY gesetzt?"} + PW -->|ja| PW2["Passwort entschluesseln
und neu verschluesseln"] + PW -->|nein| PW3["Passwort auslassen"] + PW2 --> A["Agenten anlegen, Ids behalten
CV-PDF kopieren"] + PW3 --> A + A --> M{"--with-matches?"} + M -->|ja| M2["Projekte und Treffer
mit Original-Ids"] + M -->|nein| F["fertig"] + M2 --> F +``` + +Die Ids bleiben erhalten, damit uebernommene Treffer weiter auf die richtigen +Agenten und Projekte zeigen. Deshalb bricht das Skript bei bereits vorhandenen +Agenten ab. + +> **Beim Rollout am 06.08.2026 festgestellt:** Die Portal-Passwoerter der alten +> Datenbank lassen sich mit dem dortigen `ENCRYPTION_KEY` nicht mehr +> entschluesseln - der Schluessel wurde nach dem Speichern getauscht. Die alte App +> kann sie damit ebenso wenig lesen, es ging also nichts verloren. Die +> Session-Cookies liegen im Klartext und wurden uebernommen; damit scrapt die +> neue Instanz sofort. + +## Oberflaeche + +| Reiter | Datei | Inhalt | +| --- | --- | --- | +| Dashboard | `ScannerDashboard.tsx` | Kennzahlen, Filter, Trefferliste, *Jetzt scannen*, Ton bei neuen Treffern | +| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` | +| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload | +| Boersen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import | +| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, OpenAI, Ton, E-Mail | + +**Wichtig zum Ton:** Er haengt an der Dashboard-Komponente. Wer auf einen anderen +Menuepunkt wechselt, hoert nichts - die Komponente wird ausgebaut und die +30-Sekunden-Abfrage endet. In einem anderen Browsertab bleibt der Ton dagegen +aktiv, nur langsamer (Chrome bremst Timer versteckter Tabs auf etwa einmal pro +Minute) und nur, wenn zuvor irgendwo auf der Seite geklickt wurde +(Autoplay-Regel). Browserunabhaengig meldet nur die E-Mail. + +## Endpunkte + +Alle unter `/api/scanner`, alle hinter `requireAuth`: + +| Methode | Pfad | Zweck | +| --- | --- | --- | +| GET | `/agents` | Liste ohne `cvText` (Nutzlast) | +| POST/PUT | `/agents[/:id]` | anlegen/aendern, `multipart` mit CV | +| DELETE | `/agents/:id` | Agent samt CV-Datei | +| GET | `/agents/:id/cv` | PDF als Stream | +| GET | `/portals` | Status ohne Geheimnisse | +| POST | `/portals/credentials` | Zugangsdaten, AES-verschluesselt | +| POST | `/portals/sync-session` | Cookie-Import, normalisiert fuer Playwright | +| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Pruefung | +| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` | +| PUT/DELETE | `/matches/:id` | Status setzen, loeschen | +| GET/POST | `/settings` | Geheimnisse maskiert | +| POST | `/scan` | Lauf starten, `409` wenn schon einer laeuft | +| GET | `/status` | laeuft ein Lauf, Scheduler an, Datenbankname | diff --git a/docs/04-diktat.md b/docs/04-diktat.md new file mode 100644 index 0000000..975421e --- /dev/null +++ b/docs/04-diktat.md @@ -0,0 +1,85 @@ +# 04 Diktat (Whisper im Browser) + +Ueberall, wo laengerer Text entsteht - Projektbeschreibung, Interaktionsnotiz, +naechster Schritt, interne Notizen - sitzt ein Mikrofon-Button. Die Erkennung +laeuft **vollstaendig im Browser des Benutzers**. + +Das Backend ist daran nicht beteiligt und sieht keine Audiodaten: kein API-Key, +keine Kosten pro Minute, kein Datenabfluss. Aufnahmen werden nirgends gespeichert. + +## Weg der Aufnahme + +```mermaid +flowchart LR + MIC(("Mikrofon")) --> MR["MediaRecorder"] + MR --> AC["AudioContext
16 kHz Mono PCM"] + AC --> W["Web Worker"] + W --> ONNX["Whisper als ONNX
WebGPU, sonst WASM"] + ONNX --> T["Text"] + T --> F["Formularfeld"] + + HF["Hugging-Face-CDN"] -.->|"einmalig 759 MB"| CACHE[("Cache-Speicher
der Seite")] + CACHE --> ONNX +``` + +Die Erkennung liegt im Web Worker (`src/lib/whisper.worker.ts`), damit die +Oberflaeche waehrenddessen nicht einfriert. Die ONNX-Laufzeit wird mit +ausgeliefert (Vite bundelt die `.wasm`), nur die Modelldateien kommen beim ersten +Laden vom CDN und bleiben danach im Cache-Speicher der Seite. + +## Lebenszyklus des Modells + +```mermaid +stateDiagram-v2 + [*] --> idle : Anmeldung + idle --> loading : "Sprachmodell laden" oder
automatisch, wenn schon im Cache + loading --> ready : im Worker initialisiert + loading --> error : Abbruch, Haenger, kein Speicher + error --> loading : erneut versuchen + ready --> idle : "Sprachmodell loeschen" + ready --> ready : Diktat benutzen + + note right of loading + Phase download: Dateien holen + Phase init: Modell im Worker aufbauen + end note +``` + +Beim Anmelden wird das Modell automatisch bereitgestellt, **wenn** es schon in +diesem Browser liegt. Ein Download von 759 MB startet nie von allein, und der +Automatismus greift bewusst nur einmal pro Seitenaufruf: wer das Modell loescht, +bekommt es nicht sofort wieder heruntergeladen. + +## Wo es sitzt + +Reiter **Whisper** in den Einstellungen - fuer **jede Rolle** sichtbar, denn jeder +richtet sein Diktat auf seinem eigenen Geraet ein. Genau deshalb sind die +Einstellungen ueberhaupt fuer alle Rollen zugaenglich; welche Reiter erscheinen, +entscheidet die Rolle: + +```mermaid +flowchart LR + E["Einstellungen"] --> A["Mein Konto
(alle)"] + E --> W["Whisper
(alle)"] + E --> N["Nutzerverwaltung
(admin)"] + E --> S["Scanner
(admin)"] +``` + +Ist das Modell noch nicht geladen, wird der Mikrofon-Button zu *Diktat +einrichten* und springt per `goTo('Einstellungen', { tab: 'whisper' })` direkt +dorthin - ein offener Dialog wird vorher geschlossen (`onBeforeNavigate`). + +## Warum keine Auswahl + +Modell, Genauigkeit, Rechenweg und Sprache stehen als Konstanten in +`src/lib/whisper.tsx`: `large-v3-turbo`, `q4`, Grafikkarte wenn vorhanden, +Deutsch. Jede andere Kombination war in den Messungen entweder schlechter oder +kaputt - bei `small` in fp16 auf WebGPU reagiert der ganze Browser-Tab nicht mehr, +und **kein Timeout in der Seite kann das abfangen**, weil das Skript selbst nicht +mehr laeuft. + +Eine Auswahl haette also nur Gelegenheit geboten, etwas falsch zu machen. Die +Messwerte stehen im [README](../README.md#warum-small-und-q4---die-messungen). + +Als Netz bleibt eine Frist pro Erkennung (15x Aufnahmedauer, mindestens 45 s, +hoechstens 5 min) und eine Haenger-Erkennung beim Laden. diff --git a/docs/05-frontend.md b/docs/05-frontend.md new file mode 100644 index 0000000..dcc2de7 --- /dev/null +++ b/docs/05-frontend.md @@ -0,0 +1,111 @@ +# 05 Frontend + +Eine einzige React-Anwendung ohne Router: `App.tsx` haelt die aktuelle Ansicht in +einem Zustand und rendert sie. Das genuegt, weil die App hinter einer Anmeldung +sitzt und keine tiefen Links braucht. + +## Menue und Ansichten + +```mermaid +flowchart TD + SB["Seitenleiste"] --> K["Kategorien"] + K --> O["Opportunities
(Startansicht)"] + K --> C["Kalender"] + K --> L["Kontakte"] + K --> SC["Scanner"] + SC --> SC1["Dashboard"] + SC --> SC2["Unpassend"] + SC --> SC3["Suchagenten"] + SC --> SC4["Boersen-Konten"] + SB --> E["Einstellungen"] + E --> E1["Nutzerverwaltung (admin)"] + E --> E2["Mein Konto"] + E --> E3["Scanner (admin)"] + E --> E4["Whisper"] +``` + +Der Menuepunkt *Scanner* klappt seine Unterpunkte auf; *Scanner* selbst ist das +Dashboard. Bei eingeklappter Leiste (Symbolbreite) erscheinen nur die Symbole, +die Unterpunkte kommen mit den Beschriftungen zurueck. + +## Zustand und Datenfluss + +```mermaid +flowchart TB + subgraph Kontexte["Kontexte in main.tsx"] + TH["ThemeProvider
hell/dunkel/System"] + TO["ToastProvider"] + AU["AuthProvider
JWT, Benutzer"] + WH["WhisperProvider
Modell, Worker"] + end + + APP["App.tsx
currentView, settingsTab"] + NAV["NavigationProvider
goTo(view, {tab})"] + DATA["loadData()
leads, connections, events, users, opportunities"] + + Kontexte --> APP + APP --> NAV + APP --> DATA + DATA --> V1["Opportunities"] + DATA --> V2["Kalender"] + DATA --> V3["Kontakte"] + NAV --> DF["DictationField
springt nach Whisper"] + + SCV["Scanner-Ansichten"] -->|"laden selbst"| API["/api/scanner"] +``` + +Die CRM-Daten werden **einmal zentral** geladen und als Props durchgereicht; die +Scanner-Ansichten laden dagegen selbst, weil sie eine andere Datenbank und eine +eigene Auffrischung im 30-Sekunden-Takt haben. + +`reload={() => loadData(true)}` laedt still nach - ohne Ladeanzeige, damit ein +Speichern im Dialog nicht die ganze Liste flackern laesst. + +## Komponentenkarte + +``` +src/ + App.tsx Layout, Datenladen, Ansichtswechsel + types.ts Gemeinsame Typen, Farbpalette, Rechenhilfen + lib/ + api.ts Fetch-Wrapper (JWT, 401-Behandlung) + CRM-Endpunkte + scannerApi.ts Typisierte Endpunkte unter /api/scanner + auth.tsx Anmeldung, Benutzer, Rolle + theme.tsx Hell/Dunkel/System + navigation.tsx Ansichtswechsel fuer tiefe Komponenten + whisper.tsx Modell, Worker-Lebenszyklus + dictation.tsx Aufnahme-Hook + audio.ts Aufnahme zu 16-kHz-Mono-PCM + imageCompress.ts Bilder vor dem Upload verkleinern + components/ + Sidebar.tsx Menue inkl. aufklappbarer Scanner-Gruppe + Header.tsx Titel, Untertitel, Dark-Mode-Schalter + opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular + calendar/ Monat/Woche/Tag, Termine, @-Mentions + leads/ Kachel, Liste, Netzwerkdiagramm (d3-force) + scanner/ Dashboard, Unpassend, Suchagenten, Boersen-Konten + settings/ Reiter: Nutzerverwaltung, Konto, Scanner, Whisper + whisper/ Einrichtung der Spracherkennung + ui/ Modal, Avatare, Diktatfeld +``` + +## Dark Mode ohne `dark:`-Varianten + +Die Klasse `dark` auf `` tauscht in `src/index.css` nur die Werte der +Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getoenten Statusfarben. +Die Komponenten benutzen durchgaengig diese Skalen und folgen damit automatisch - +kein `dark:` im Markup. + +```mermaid +flowchart LR + T["Umschalter im Header"] --> P["localStorage
perinco_crm_theme"] + P --> H["class 'dark' auf html"] + H --> V["Variablen in index.css
canvas, surface, ink-*, brand-*"] + V --> U["Utility-Klassen der Komponenten"] + SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H +``` + +Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst +blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi +gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf +farbigen Flaechen) und `brand-500` (das CI-Gruen der Buttons). diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md new file mode 100644 index 0000000..c88265b --- /dev/null +++ b/docs/06-betrieb.md @@ -0,0 +1,134 @@ +# 06 Betrieb + +## Deployment + +Gebaut wird **lokal**, auf den Server geht nur `dist/` plus Backend-Quellen. Der +Server hat wenig freien RAM und volles Swap - dort wird nicht gebaut. + +```mermaid +flowchart LR + L["Arbeitsplatz"] -->|"npm run build"| D["dist/"] + D -->|pscp| S1["/var/www/perinco_CRM/dist"] + L -->|pscp| S2["/var/www/perinco_CRM/server/src"] + L -->|pscp| S3["server/package.json"] + S3 -.->|"nur mit -Deps"| NI["npm install --omit=dev"] + NI --> R["systemctl restart
perinco-crm-backend"] + S2 --> R + R --> H["curl /api/health"] +``` + +```powershell +npm run build +powershell -File deploy/deploy.ps1 # dist + Backend +powershell -File deploy/deploy.ps1 -Deps # zusaetzlich npm install +powershell -File deploy/deploy.ps1 -SkipBuild # ohne neuen Build +``` + +Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`. + +`deploy.ps1` laedt die systemd-Unit **nicht** mit hoch. Nach Aenderungen an +`deploy/perinco-crm-backend.service` von Hand kopieren und `daemon-reload`. + +## Dienste auf dem Server + +| Unit | Verzeichnis | Port | Anmerkung | +| --- | --- | --- | --- | +| `perinco-crm-backend` | `/var/www/perinco_CRM/server` | 5006 | `MemoryMax=1536M` | +| `project-scanner-backend` | `/var/www/Projekt Scanner/backend` | 5005 | alte App, laeuft bis zur Ablosung | + +Der Verzeichnisname der alten App enthaelt ein **Leerzeichen**. In Skripten und in +der `.env` immer in Anfuehrungszeichen setzen - systemd liest `.env` als +`EnvironmentFile` und trennt sonst am Leerzeichen. + +`MemoryMax=1536M` statt der urspruenglichen 512M: ein Scan-Lauf startet Chromium +als Kindprozess in derselben cgroup. Mit 512M wuerde der OOM-Killer das Backend +mitten im Scrapen beenden. + +## Umgebungsvariablen + +`server/.env`, Rechte `600`. Vorlage: `server/.env.example`. + +| Variable | Bedeutung | +| --- | --- | +| `PORT`, `HOST` | `5006`, `127.0.0.1` - von aussen nur ueber Apache | +| `DB_*` | Verbindung zu `perinco_CRM` | +| `JWT_SECRET`, `JWT_EXPIRES_IN` | Signatur der Sitzungen, Standard 12 h | +| `ADMIN_*` | wird beim ersten Start angelegt, nur wenn `crm_users` leer ist | +| `SCANNER_DB_NAME` | eigene Scanner-Datenbank, Standard `perinco_scanner` | +| `SCANNER_ENCRYPTION_KEY` | AES-Schluessel fuer Portal-Passwoerter | +| `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `/uploads` | +| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab | +| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz fuer den ersten Lauf, Standard 30 | +| `LEGACY_*` | Quelle fuer `npm run scanner:import` | + +Die Scanner-Datenbank wird beim Start automatisch angelegt +(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale +und Standardeinstellungen werden geseedet. + +## Playwright + +`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App. +Deren Chromium-Build (`chromium-1223`) liegt in `/root/.cache/ms-playwright`, also +faellt kein zweiter Browser-Download an. Ein `^` in der Version wuerde das +aufgeben; die Begruendung steht als `comments`-Feld in `server/package.json`. + +Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest: + +```bash +cd /var/www/perinco_CRM/server && npx playwright install chromium +``` + +## Erststart des Scanners + +```bash +bash /var/www/perinco_CRM/deploy/scanner-setup.sh # .env ergaenzen, Schluessel erzeugen +cd /var/www/perinco_CRM/server +npm run scanner:import -- --dry-run +npm run scanner:import -- --with-matches +systemctl restart perinco-crm-backend +``` + +> **Reihenfolge:** Der Import schreibt `search_interval` aus der alten App in die +> Einstellungen. Der laufende Scheduler merkt die Aenderung binnen einer Minute +> und plant sich neu - beim Rollout hat er direkt nach dem Import einen Scan +> gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber +> vermeidbar: erst neu starten, dann importieren. + +## Stoerungssuche + +| Symptom | Blick darauf | +| --- | --- | +| Anmeldung schlaegt fehl | `journalctl -u perinco-crm-backend -n 50`, `JWT_SECRET` gesetzt? | +| Scanner-Reiter leer, 500er | `[Scanner] Datenbank nicht erreichbar` im Journal | +| Kein Treffer trotz Scan | `scanning_active`, aktive Agenten mit CV, OpenAI-Key gesetzt | +| Portal `expired` | Cookies neu importieren (Cookie-Editor) | +| `Target page ... has been closed` | Dienst wurde mitten im Scrapen neu gestartet | +| Backend stirbt beim Scrapen | `MemoryMax` zu klein, `systemctl show ... -p MemoryCurrent` | +| Ton bleibt aus | Schwellenwert, Dashboard-Ansicht offen, einmal auf der Seite geklickt? | + +Nuetzliche Abfragen: + +```bash +systemctl status perinco-crm-backend +journalctl -u perinco-crm-backend -f +curl -s http://127.0.0.1:5006/api/health + +set -a; . /var/www/perinco_CRM/server/.env; set +a +mysql -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" -e \ + "SELECT COUNT(*) FROM matches; SELECT key_name, value FROM settings" +``` + +## Sicherung + +Beide Datenbanken einzeln, dazu die CV-Dateien: + +```bash +set -a; . /var/www/perinco_CRM/server/.env; set +a +mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" > crm_$(date +%F).sql +mysqldump -u"$DB_USER" -p"$DB_PASSWORD" "$SCANNER_DB_NAME" > scanner_$(date +%F).sql +tar czf uploads_$(date +%F).tar.gz -C /var/www/perinco_CRM/server uploads +``` + +Die `.env` gehoert **nicht** ins Repository, aber in die Sicherung - ohne +`SCANNER_ENCRYPTION_KEY` sind die Portal-Passwoerter verloren. Genau das ist der +alten App passiert. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..926c72e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,59 @@ +# Dokumentation perinco CRM + +Technische Dokumentation mit Funktionszeichnungen. Die Diagramme sind Mermaid und +werden von Gitea direkt gerendert. + +| Kapitel | Inhalt | +| --- | --- | +| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen | +| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme | +| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Boerse bis zum Treffer, Parallelbetrieb | +| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus | +| [05 Frontend](05-frontend.md) | Ansichten, Menue, Komponentenkarte, Zustand | +| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Stoerungssuche | + +Die Anwendungsdokumentation - Bedienung, Designentscheidungen, Messwerte zur +Spracherkennung - steht im [README des Projekts](../README.md). + +## Das Ganze in einem Bild + +```mermaid +flowchart TB + subgraph Browser + SPA["React-SPA
Vite-Build"] + WSP["Whisper im Web Worker
WebGPU / WASM"] + end + + subgraph Server["Server 212.227.135.41"] + AP["Apache
:443"] + API["Express-Backend
127.0.0.1:5006"] + SCH["Scanner-Scheduler
node-cron"] + PW["Playwright
Chromium"] + DB1[("MariaDB
perinco_CRM")] + DB2[("MariaDB
perinco_scanner")] + ALT["alte Scanner-App
:5005"] + DB3[("MariaDB
project_scanner")] + end + + OAI["OpenAI
gpt-4o-mini"] + BOERSE["Projektboersen
freelancermap, hays, ..."] + + SPA -->|"HTTPS /api"| AP + AP --> API + API --> DB1 + API --> DB2 + SCH --> PW + PW --> BOERSE + SCH --> OAI + SCH --> DB2 + API -.-> SCH + ALT --> DB3 + ALT --> BOERSE + SPA -.->|"Audio bleibt im Browser"| WSP + + classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3 + class ALT,DB3 alt +``` + +Gestrichelt: die urspruengliche Scanner-App, die bis zur Ablosung parallel +weiterlaeuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst. diff --git a/index.html b/index.html new file mode 100644 index 0000000..b4990ed --- /dev/null +++ b/index.html @@ -0,0 +1,36 @@ + + + + + + + + perinco CRM + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9dc7169 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3426 @@ +{ + "name": "perinco-crm", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perinco-crm", + "version": "0.1.0", + "dependencies": { + "@huggingface/transformers": "^3.8.1", + "@tailwindcss/vite": "^4.2.0", + "d3-force": "^3.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", + "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", + "integrity": "sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "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": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..edbab1d --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "perinco-crm", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@huggingface/transformers": "^3.8.1", + "@tailwindcss/vite": "^4.2.0", + "d3-force": "^3.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10", + "@types/node": "^22.14.0", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "@vitejs/plugin-react": "^5.0.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a36208ce5f6b56e7913b722eab2b4c9e5a60aea8 GIT binary patch literal 3042 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 literal 0 HcmV?d00001 diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a94749718daccd31cd5186e53c562009926d27fc GIT binary patch literal 162277 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

AOKR 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%0A{7sNOtmmK;ZKR(7sd53 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 literal 0 HcmV?d00001 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..19e769f --- /dev/null +++ b/server/.env.example @@ -0,0 +1,71 @@ +# perinco CRM Backend +# Kopie als .env anlegen und Werte setzen. .env niemals committen. + +PORT=5006 +# Nur lokal binden - von aussen erreichbar ist die App ausschliesslich +# ueber den Apache-Reverse-Proxy. +HOST=127.0.0.1 + +DB_HOST=localhost +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=changeme +DB_NAME=perinco_CRM + +# Signiert die JWT-Sessions. Muss ein langer Zufallswert sein. +JWT_SECRET=changeme-langer-zufallswert +JWT_EXPIRES_IN=12h + +# Wird beim ersten Start angelegt, falls noch kein Benutzer existiert. +ADMIN_USERNAME=admin +ADMIN_NAME=Administrator +ADMIN_EMAIL=admin@perinco.de +ADMIN_PASSWORD=changeme-starkes-passwort + +# Das Diktat braucht hier keine Konfiguration: Whisper laeuft im Browser des +# Benutzers, eingestellt wird es im Reiter "Whisper" der Anwendung. + +# --------------------------------------------------------------------------- +# Projekt Scanner (portiert, laeuft parallel zur alten App) +# --------------------------------------------------------------------------- +# Diese Instanz ist vollstaendig eigenstaendig: eigene Datenbank, eigener +# Schluessel, eigenes Upload-Verzeichnis, eigener Scheduler. Sie fasst die Daten +# des alten Scanners nicht an. + +# Eigene Datenbank - wird beim Start automatisch angelegt, falls sie fehlt. +# NICHT auf project_scanner zeigen lassen, das ist die Datenbank der alten App. +SCANNER_DB_NAME=perinco_scanner +# SCANNER_DB_HOST=localhost +# SCANNER_DB_PORT=3306 +# SCANNER_DB_USER=root +# SCANNER_DB_PASSWORD= + +# Eigener Schluessel fuer die Portal-Passwoerter, unabhaengig von der alten App. +# Langer Zufallswert, z. B.: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +SCANNER_ENCRYPTION_KEY= + +# Eigenes Verzeichnis fuer die CV-PDFs. Leer = /uploads. +SCANNER_UPLOAD_DIR= + +# Hintergrund-Scraper (Playwright + OpenAI). Standard ist AN; nur "false" +# schaltet ihn ab. Achtung: solange die alte App laeuft, bewerten beide Instanzen +# dieselben Projekte unabhaengig voneinander - die OpenAI-Kosten fallen doppelt an. +SCANNER_SCHEDULER=true + +# Minuten Versatz fuer den ersten Lauf nach einem Neustart. Verhindert, dass +# beide Scanner gleichzeitig mit denselben Portal-Sessions losziehen (CAPTCHA). +# 0 = sofort loslegen. Wird ignoriert, wenn der Wert >= Suchintervall ist. +SCANNER_SCHEDULER_OFFSET=30 + +# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen. +# OPENAI_API_KEY= + +# --- Einmalige Uebernahme aus der alten Scanner-Datenbank --- +# npm run scanner:import [-- --with-matches] [-- --force] [-- --dry-run] +LEGACY_DB_NAME=project_scanner +# LEGACY_DB_HOST / _PORT / _USER / _PASSWORD (leer = CRM-Werte von oben) +# Verzeichnis der CV-PDFs der alten App - von dort werden die Dateien kopiert. +# LEGACY_UPLOAD_DIR=/opt/project-scanner/backend/uploads +# ENCRYPTION_KEY der alten App - nur damit koennen die Portal-Passwoerter +# uebernommen (und hier neu verschluesselt) werden. +# LEGACY_ENCRYPTION_KEY= diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..4179d42 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1796 @@ +{ + "name": "perinco-crm-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "perinco-crm-backend", + "version": "0.1.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "crypto-js": "^4.2.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "mysql2": "^3.11.5", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.13", + "openai": "^4.47.1", + "pdf-parse": "^1.1.1", + "playwright": "1.60.0", + "sequelize": "^6.37.3" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "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/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "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/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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-apply-helpers": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "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/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/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/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "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/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz", + "integrity": "sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/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/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "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/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "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==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "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/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "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/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, + "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/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-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "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/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "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/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mysql2": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz", + "integrity": "sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "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-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "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-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/pdf-parse": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz", + "integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==", + "license": "MIT", + "dependencies": { + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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/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/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, + "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/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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/sequelize": { + "version": "6.37.8", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz", + "integrity": "sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/sequelize/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "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/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "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==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "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/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..6a47ec7 --- /dev/null +++ b/server/package.json @@ -0,0 +1,33 @@ +{ + "name": "perinco-crm-backend", + "version": "0.1.0", + "private": true, + "description": "REST-Backend fuer perinco CRM (MariaDB perinco_CRM) inkl. portiertem Projekt Scanner", + "comments": { + "playwright": "Exakt 1.60.0 - dieselbe Version wie die laufende Scanner-App. Deren Chromium-Build (chromium-1223) liegt auf dem Server schon im Cache, ein Caret wuerde eine neuere Version und damit einen zweiten Browser-Download ziehen." + }, + "main": "src/server.js", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "migrate": "node src/migrate.js", + "scanner:import": "node src/scanner/importLegacy.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "crypto-js": "^4.2.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "mysql2": "^3.11.5", + "node-cron": "^3.0.3", + "nodemailer": "^6.9.13", + "openai": "^4.47.1", + "pdf-parse": "^1.1.1", + "playwright": "1.60.0", + "sequelize": "^6.37.3" + } +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..04c5d2b --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,82 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import bcrypt from 'bcryptjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const pool = mysql.createPool({ + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 3306), + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || '', + database: process.env.DB_NAME || 'perinco_CRM', + waitForConnections: true, + // Bewusst klein gehalten: der Server hostet weitere Dienste auf derselben + // MariaDB-Instanz. + connectionLimit: 8, + queueLimit: 0, + charset: 'utf8mb4', + dateStrings: true, +}); + +/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */ +export async function initDatabase() { + const sqlPath = path.join(__dirname, 'schema.sql'); + const sql = await fs.readFile(sqlPath, 'utf8'); + + // schema.sql enthaelt mehrere Statements; die splitten wir an Semikolons + // am Zeilenende, damit multipleStatements nicht aktiviert werden muss. + // Kommentarzeilen fliegen vorher raus, sonst besteht ein Statement nach dem + // Split nur aus Kommentar und wuerde einen Syntaxfehler ausloesen. + const statements = sql + .split('\n') + .filter((line) => !line.trim().startsWith('--')) + .join('\n') + .split(/;\s*$/m) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + const conn = await pool.getConnection(); + try { + for (const statement of statements) { + await conn.query(statement); + } + } finally { + conn.release(); + } + + await ensureAdminUser(); +} + +async function ensureAdminUser() { + const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users'); + if (rows[0].count > 0) return; + + const username = process.env.ADMIN_USERNAME || 'admin'; + const name = process.env.ADMIN_NAME || 'Administrator'; + const email = process.env.ADMIN_EMAIL || null; + + // Kein festes Ersatzpasswort im Quelltext: ohne ADMIN_PASSWORD wird ein + // Zufallswert erzeugt und einmalig ins Log geschrieben. Ein bekannter Default + // waere sonst auf jeder Installation derselbe. + const generated = !process.env.ADMIN_PASSWORD; + const password = process.env.ADMIN_PASSWORD || randomBytes(12).toString('base64url'); + const hash = await bcrypt.hash(password, 12); + + await pool.query( + `INSERT INTO crm_users (username, email, name, role, password_hash, is_active) + VALUES (?, ?, ?, 'admin', ?, 1)`, + [username, email, name, hash] + ); + + console.log(`[perinco CRM] Admin-Benutzer "${username}" wurde initial angelegt.`); + if (generated) { + console.log( + `[perinco CRM] ADMIN_PASSWORD war nicht gesetzt - erzeugtes Passwort: ${password}\n` + + ' Jetzt notieren und in der Anwendung unter "Mein Konto" aendern.' + ); + } +} diff --git a/server/src/middleware/auth.js b/server/src/middleware/auth.js new file mode 100644 index 0000000..ff5d904 --- /dev/null +++ b/server/src/middleware/auth.js @@ -0,0 +1,54 @@ +import jwt from 'jsonwebtoken'; +import { pool } from '../db.js'; + +function secret() { + const value = process.env.JWT_SECRET; + if (!value || value.length < 16) { + throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).'); + } + return value; +} + +export function signToken(user) { + return jwt.sign({ sub: user.id, role: user.role }, secret(), { + expiresIn: process.env.JWT_EXPIRES_IN || '12h', + }); +} + +/** Prueft das Bearer-Token und laedt den aktuellen Benutzer aus der DB. */ +export async function requireAuth(req, res, next) { + const header = req.headers.authorization || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' }); + + let payload; + try { + payload = jwt.verify(token, secret()); + } catch { + return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' }); + } + + try { + const [rows] = await pool.query( + `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + FROM crm_users WHERE id = ? LIMIT 1`, + [payload.sub] + ); + const user = rows[0]; + if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' }); + if (!user.is_active) return res.status(403).json({ error: 'Benutzerkonto ist deaktiviert.' }); + + req.user = { ...user, is_active: Boolean(user.is_active) }; + next(); + } catch (err) { + next(err); + } +} + +/** Nur Rolle admin - schuetzt Einstellungen und Nutzerverwaltung. */ +export function requireAdmin(req, res, next) { + if (req.user?.role !== 'admin') { + return res.status(403).json({ error: 'Diese Aktion ist Administratoren vorbehalten.' }); + } + next(); +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js new file mode 100644 index 0000000..13c64a2 --- /dev/null +++ b/server/src/routes/auth.js @@ -0,0 +1,128 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { pool } from '../db.js'; +import { signToken, requireAuth } from '../middleware/auth.js'; +import { normalizeAvatar, normalizeColor } from '../userFields.js'; + +const router = express.Router(); + +function publicUser(row) { + return { + id: row.id, + username: row.username, + email: row.email, + name: row.name, + role: row.role, + color: row.color ?? null, + avatar: row.avatar ?? null, + is_active: Boolean(row.is_active), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +// POST /api/auth/login { identifier, password } +router.post('/login', async (req, res, next) => { + const identifier = String(req.body?.identifier || '').trim(); + const password = String(req.body?.password || ''); + + if (!identifier || !password) { + return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' }); + } + + try { + const [rows] = await pool.query( + `SELECT * FROM crm_users WHERE username = ? OR email = ? LIMIT 1`, + [identifier, identifier.toLowerCase()] + ); + const user = rows[0]; + + // Bewusst identische Meldung fuer "kein Benutzer" und "falsches Passwort", + // damit ueber die API keine Benutzernamen aufgezaehlt werden koennen. + const invalid = () => res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' }); + if (!user) return invalid(); + + const ok = await bcrypt.compare(password, user.password_hash); + if (!ok) return invalid(); + if (!user.is_active) { + return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' }); + } + + res.json({ token: signToken(user), user: publicUser(user) }); + } catch (err) { + next(err); + } +}); + +// GET /api/auth/me +router.get('/me', requireAuth, (req, res) => { + res.json({ user: publicUser(req.user) }); +}); + +// PATCH /api/auth/me { color?, avatar? } +// Jeder darf seine eigene Kalenderfarbe und sein Foto setzen - dafuer braucht +// es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch +// aus der Palette, Foto: Initiale). +router.patch('/me', requireAuth, async (req, res, next) => { + const fields = []; + const values = []; + const changed = {}; + + for (const [field, normalize] of [ + ['color', normalizeColor], + ['avatar', normalizeAvatar], + ]) { + if (req.body?.[field] === undefined) continue; + const result = normalize(req.body[field]); + if (result.error) return res.status(400).json({ error: result.error }); + fields.push(`${field} = ?`); + values.push(result.value); + changed[field] = result.value; + } + + if (fields.length === 0) { + return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); + } + + try { + await pool.query(`UPDATE crm_users SET ${fields.join(', ')} WHERE id = ?`, [ + ...values, + req.user.id, + ]); + res.json({ user: publicUser({ ...req.user, ...changed }) }); + } catch (err) { + next(err); + } +}); + +// POST /api/auth/change-password { current_password, new_password } +router.post('/change-password', requireAuth, async (req, res, next) => { + const current = String(req.body?.current_password || ''); + const next_ = String(req.body?.new_password || ''); + + if (!current || !next_) { + return res.status(400).json({ error: 'Aktuelles und neues Passwort sind erforderlich.' }); + } + if (next_.length < 8) { + return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' }); + } + + try { + const [rows] = await pool.query('SELECT password_hash FROM crm_users WHERE id = ? LIMIT 1', [ + req.user.id, + ]); + if (!rows[0]) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + + const ok = await bcrypt.compare(current, rows[0].password_hash); + if (!ok) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' }); + + const hash = await bcrypt.hash(next_, 12); + await pool.query('UPDATE crm_users SET password_hash = ? WHERE id = ?', [hash, req.user.id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; +export { publicUser }; diff --git a/server/src/routes/connections.js b/server/src/routes/connections.js new file mode 100644 index 0000000..4b30d95 --- /dev/null +++ b/server/src/routes/connections.js @@ -0,0 +1,154 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +const TYPES = new Set([ + 'Kollege', + 'Vorgesetzter', + 'Kunde', + 'Partner', + 'Empfehlung', + 'Netzwerk', + 'Sonstige', +]); + +/** + * Verbindungen sind ungerichtet gedacht. Damit "A-B" und "B-A" nicht doppelt + * in der DB landen, wird das Paar immer aufsteigend sortiert gespeichert - der + * Unique-Key auf (from_lead_id, to_lead_id) greift dann zuverlaessig. + */ +function orderPair(a, b) { + return a <= b ? [a, b] : [b, a]; +} + +async function leadsExist(ids) { + const [rows] = await pool.query( + `SELECT id FROM crm_leads WHERE id IN (${ids.map(() => '?').join(', ')})`, + ids + ); + return rows.length === ids.length; +} + +// GET /api/connections +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT id, from_lead_id, to_lead_id, type, label, strength, created_by, created_at + FROM crm_lead_connections ORDER BY id ASC` + ); + res.json(rows); + } catch (err) { + next(err); + } +}); + +// POST /api/connections +router.post('/', async (req, res, next) => { + const fromId = Number(req.body?.from_lead_id); + const toId = Number(req.body?.to_lead_id); + + if (!Number.isInteger(fromId) || !Number.isInteger(toId)) { + return res.status(400).json({ error: 'Beide Kontakte muessen angegeben werden.' }); + } + if (fromId === toId) { + return res.status(400).json({ error: 'Ein Kontakt kann nicht mit sich selbst verbunden werden.' }); + } + + const type = TYPES.has(req.body?.type) ? req.body.type : 'Netzwerk'; + const label = String(req.body?.label ?? '').trim() || null; + const strengthRaw = Number(req.body?.strength ?? 2); + const strength = [1, 2, 3].includes(strengthRaw) ? strengthRaw : 2; + const [a, b] = orderPair(fromId, toId); + + try { + if (!(await leadsExist([a, b]))) { + return res.status(400).json({ error: 'Mindestens einer der Kontakte existiert nicht.' }); + } + + const [result] = await pool.query( + `INSERT INTO crm_lead_connections (from_lead_id, to_lead_id, type, label, strength, created_by) + VALUES (?, ?, ?, ?, ?, ?)`, + [a, b, type, label, strength, req.user.id] + ); + const [rows] = await pool.query( + `SELECT id, from_lead_id, to_lead_id, type, label, strength, created_by, created_at + FROM crm_lead_connections WHERE id = ?`, + [result.insertId] + ); + res.status(201).json(rows[0]); + } catch (err) { + if (err?.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ error: 'Diese Verbindung existiert bereits.' }); + } + next(err); + } +}); + +// PATCH /api/connections/:id +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verbindungs-ID.' }); + + const fields = []; + const values = []; + + if (req.body?.type !== undefined) { + if (!TYPES.has(req.body.type)) return res.status(400).json({ error: 'Ungueltiger Verbindungstyp.' }); + fields.push('type = ?'); + values.push(req.body.type); + } + if (req.body?.label !== undefined) { + fields.push('label = ?'); + values.push(String(req.body.label).trim() || null); + } + if (req.body?.strength !== undefined) { + const strength = Number(req.body.strength); + if (![1, 2, 3].includes(strength)) { + return res.status(400).json({ error: 'Verbindungsstaerke muss 1, 2 oder 3 sein.' }); + } + fields.push('strength = ?'); + values.push(strength); + } + + if (fields.length === 0) return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); + + try { + values.push(id); + const [result] = await pool.query( + `UPDATE crm_lead_connections SET ${fields.join(', ')} WHERE id = ?`, + values + ); + if (result.affectedRows === 0) { + return res.status(404).json({ error: 'Verbindung nicht gefunden.' }); + } + const [rows] = await pool.query( + `SELECT id, from_lead_id, to_lead_id, type, label, strength, created_by, created_at + FROM crm_lead_connections WHERE id = ?`, + [id] + ); + res.json(rows[0]); + } catch (err) { + next(err); + } +}); + +// DELETE /api/connections/:id +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Verbindungs-ID.' }); + + try { + const [result] = await pool.query('DELETE FROM crm_lead_connections WHERE id = ?', [id]); + if (result.affectedRows === 0) { + return res.status(404).json({ error: 'Verbindung nicht gefunden.' }); + } + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/events.js b/server/src/routes/events.js new file mode 100644 index 0000000..014a5b4 --- /dev/null +++ b/server/src/routes/events.js @@ -0,0 +1,287 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +// Akzeptiert "YYYY-MM-DD HH:MM[:SS]" und die Variante mit T, wie sie ein +// liefert. +const DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/; + +/** Normalisiert auf das MariaDB-DATETIME-Format oder gibt null zurueck. */ +function normalizeDateTime(value) { + const match = DATETIME_RE.exec(String(value ?? '').trim()); + if (!match) return null; + + const [, year, month, day, hour, minute, second = '00'] = match; + const probe = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); + // Faengt Unsinn wie den 31. Februar ab - Date rollt sonst stillschweigend um. + if ( + probe.getFullYear() !== Number(year) || + probe.getMonth() !== Number(month) - 1 || + probe.getDate() !== Number(day) || + Number(hour) > 23 || + Number(minute) > 59 || + Number(second) > 59 + ) { + return null; + } + + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + +function emptyToNull(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +} + +/** Teilnehmer-IDs aus dem Request pruefen und deduplizieren. */ +async function normalizeParticipants(raw) { + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) return { error: 'Teilnehmer muessen als Liste uebergeben werden.' }; + + const ids = [...new Set(raw.map((value) => Number(value)))]; + if (ids.some((id) => !Number.isInteger(id) || id <= 0)) { + return { error: 'Ungueltige Teilnehmer-ID.' }; + } + if (ids.length === 0) return { ids: [] }; + + const [rows] = await pool.query( + `SELECT id FROM crm_users WHERE id IN (${ids.map(() => '?').join(', ')})`, + ids + ); + if (rows.length !== ids.length) return { error: 'Mindestens ein Teilnehmer existiert nicht.' }; + + return { ids }; +} + +const SELECT_EVENTS = ` + SELECT e.*, + creator.name AS created_by_name, + creator.color AS created_by_color, + creator.avatar AS created_by_avatar + FROM crm_events e + LEFT JOIN crm_users creator ON creator.id = e.created_by +`; + +/** Laedt die Teilnehmer der uebergebenen Termine als Map eventId -> Liste. */ +async function loadParticipants(eventIds) { + if (eventIds.length === 0) return new Map(); + + const [rows] = await pool.query( + `SELECT p.event_id, u.id, u.username, u.name, u.color, u.avatar + FROM crm_event_participants p + JOIN crm_users u ON u.id = p.user_id + WHERE p.event_id IN (${eventIds.map(() => '?').join(', ')}) + ORDER BY u.name ASC`, + eventIds + ); + + const map = new Map(); + for (const row of rows) { + const list = map.get(row.event_id) ?? []; + list.push({ + id: row.id, + username: row.username, + name: row.name, + color: row.color, + avatar: row.avatar, + }); + map.set(row.event_id, list); + } + return map; +} + +function mapEvent(row, participants) { + return { ...row, all_day: Boolean(row.all_day), participants: participants ?? [] }; +} + +async function fetchEvent(id) { + const [rows] = await pool.query(`${SELECT_EVENTS} WHERE e.id = ? LIMIT 1`, [id]); + if (!rows[0]) return null; + const participants = await loadParticipants([id]); + return mapEvent(rows[0], participants.get(id)); +} + +/** Ersetzt die Teilnehmerliste eines Termins komplett. */ +async function replaceParticipants(conn, eventId, ids) { + await conn.query('DELETE FROM crm_event_participants WHERE event_id = ?', [eventId]); + if (ids.length === 0) return; + await conn.query( + `INSERT INTO crm_event_participants (event_id, user_id) + VALUES ${ids.map(() => '(?, ?)').join(', ')}`, + ids.flatMap((userId) => [eventId, userId]) + ); +} + +// GET /api/events - der Kalender ist geteilt, jeder sieht alle Termine +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query(`${SELECT_EVENTS} ORDER BY e.starts_at ASC, e.id ASC`); + const participants = await loadParticipants(rows.map((row) => row.id)); + res.json(rows.map((row) => mapEvent(row, participants.get(row.id)))); + } catch (err) { + next(err); + } +}); + +// POST /api/events +router.post('/', async (req, res, next) => { + const title = emptyToNull(req.body?.title); + if (!title) return res.status(400).json({ error: 'Ein Titel ist erforderlich.' }); + + const startsAt = normalizeDateTime(req.body?.starts_at); + const endsAt = normalizeDateTime(req.body?.ends_at); + if (!startsAt || !endsAt) { + return res.status(400).json({ error: 'Start und Ende muessen gueltige Zeitpunkte sein.' }); + } + if (endsAt < startsAt) { + return res.status(400).json({ error: 'Das Ende darf nicht vor dem Start liegen.' }); + } + + const participants = await normalizeParticipants(req.body?.participant_ids ?? []); + if (participants.error) return res.status(400).json({ error: participants.error }); + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query( + `INSERT INTO crm_events (title, description, location, starts_at, ends_at, all_day, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + title, + emptyToNull(req.body?.description), + emptyToNull(req.body?.location), + startsAt, + endsAt, + req.body?.all_day ? 1 : 0, + req.user.id, + ] + ); + await replaceParticipants(conn, result.insertId, participants.ids); + await conn.commit(); + res.status(201).json(await fetchEvent(result.insertId)); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + +/** Bearbeiten und Loeschen darf der Ersteller - und jeder Administrator. */ +function mayModify(user, event) { + return user.role === 'admin' || event.created_by === user.id; +} + +// PATCH /api/events/:id +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Termin-ID.' }); + + const conn = await pool.getConnection(); + try { + const [existingRows] = await conn.query('SELECT * FROM crm_events WHERE id = ? LIMIT 1', [id]); + const existing = existingRows[0]; + if (!existing) return res.status(404).json({ error: 'Termin nicht gefunden.' }); + if (!mayModify(req.user, existing)) { + return res + .status(403) + .json({ error: 'Diesen Termin darf nur der Ersteller oder ein Administrator aendern.' }); + } + + const fields = []; + const values = []; + + if (req.body?.title !== undefined) { + const title = emptyToNull(req.body.title); + if (!title) return res.status(400).json({ error: 'Der Titel darf nicht leer sein.' }); + fields.push('title = ?'); + values.push(title); + } + for (const field of ['description', 'location']) { + if (req.body?.[field] === undefined) continue; + fields.push(`${field} = ?`); + values.push(emptyToNull(req.body[field])); + } + + let startsAt = existing.starts_at; + if (req.body?.starts_at !== undefined) { + startsAt = normalizeDateTime(req.body.starts_at); + if (!startsAt) return res.status(400).json({ error: 'Ungueltiger Startzeitpunkt.' }); + fields.push('starts_at = ?'); + values.push(startsAt); + } + + let endsAt = existing.ends_at; + if (req.body?.ends_at !== undefined) { + endsAt = normalizeDateTime(req.body.ends_at); + if (!endsAt) return res.status(400).json({ error: 'Ungueltiger Endzeitpunkt.' }); + fields.push('ends_at = ?'); + values.push(endsAt); + } + + if (String(endsAt) < String(startsAt)) { + return res.status(400).json({ error: 'Das Ende darf nicht vor dem Start liegen.' }); + } + + if (req.body?.all_day !== undefined) { + fields.push('all_day = ?'); + values.push(req.body.all_day ? 1 : 0); + } + + const participants = await normalizeParticipants(req.body?.participant_ids); + if (participants?.error) return res.status(400).json({ error: participants.error }); + + if (fields.length === 0 && !participants) { + return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); + } + + await conn.beginTransaction(); + if (fields.length > 0) { + await conn.query(`UPDATE crm_events SET ${fields.join(', ')} WHERE id = ?`, [...values, id]); + } + if (participants) await replaceParticipants(conn, id, participants.ids); + await conn.commit(); + + res.json(await fetchEvent(id)); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + +// DELETE /api/events/:id - Teilnehmer entfernt die DB per ON DELETE CASCADE +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Termin-ID.' }); + + try { + const [rows] = await pool.query('SELECT created_by FROM crm_events WHERE id = ? LIMIT 1', [id]); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Termin nicht gefunden.' }); + if (!mayModify(req.user, existing)) { + return res + .status(403) + .json({ error: 'Diesen Termin darf nur der Ersteller oder ein Administrator loeschen.' }); + } + + await pool.query('DELETE FROM crm_events WHERE id = ?', [id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/leads.js b/server/src/routes/leads.js new file mode 100644 index 0000000..556d1a2 --- /dev/null +++ b/server/src/routes/leads.js @@ -0,0 +1,205 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +const STATUSES = new Set([ + 'Neu', + 'Kontaktiert', + 'Qualifiziert', + 'Angebot', + 'Gewonnen', + 'Verloren', +]); + +// Felder, die per API gesetzt werden duerfen. owner_id/created_by werden +// separat behandelt, alles andere ist nicht beschreibbar. +const TEXT_FIELDS = [ + 'salutation', + 'first_name', + 'last_name', + 'company', + 'position', + 'email', + 'phone', + 'mobile', + 'linkedin', + 'website', + 'street', + 'zip', + 'city', + 'country', + 'source', + 'notes', + 'avatar', +]; + +function emptyToNull(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +} + +function parseTags(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((t) => typeof t === 'string') : []; + } catch { + return []; + } +} + +function serializeTags(value) { + if (!Array.isArray(value)) return null; + const tags = [...new Set(value.map((t) => String(t).trim()).filter(Boolean))]; + return JSON.stringify(tags); +} + +function mapLead(row) { + return { ...row, tags: parseTags(row.tags), owner_id: row.owner_id, created_by: row.created_by }; +} + +const SELECT_LEADS = ` + SELECT l.*, + creator.name AS created_by_name, + owner.name AS owner_name + FROM crm_leads l + LEFT JOIN crm_users creator ON creator.id = l.created_by + LEFT JOIN crm_users owner ON owner.id = l.owner_id +`; + +async function fetchLead(id) { + const [rows] = await pool.query(`${SELECT_LEADS} WHERE l.id = ? LIMIT 1`, [id]); + return rows[0] ? mapLead(rows[0]) : null; +} + +/** Prueft, ob eine uebergebene Benutzer-ID existiert. */ +async function userExists(id) { + if (id === null) return true; + const [rows] = await pool.query('SELECT id FROM crm_users WHERE id = ? LIMIT 1', [id]); + return Boolean(rows[0]); +} + +// GET /api/leads +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query(`${SELECT_LEADS} ORDER BY l.updated_at DESC, l.id DESC`); + res.json(rows.map(mapLead)); + } catch (err) { + next(err); + } +}); + +// POST /api/leads +router.post('/', async (req, res, next) => { + const firstName = emptyToNull(req.body?.first_name); + const lastName = emptyToNull(req.body?.last_name); + if (!firstName || !lastName) { + return res.status(400).json({ error: 'Vorname und Nachname sind erforderlich.' }); + } + + const status = STATUSES.has(req.body?.status) ? req.body.status : 'Neu'; + const ownerId = + req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === '' + ? req.user.id + : Number(req.body.owner_id); + + if (Number.isNaN(ownerId) || !(await userExists(ownerId))) { + return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); + } + + const columns = [...TEXT_FIELDS, 'status', 'tags', 'owner_id', 'created_by']; + const values = [ + ...TEXT_FIELDS.map((field) => + field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field]) + ), + status, + serializeTags(req.body?.tags) ?? '[]', + ownerId, + req.user.id, + ]; + + try { + const [result] = await pool.query( + `INSERT INTO crm_leads (${columns.join(', ')}) + VALUES (${columns.map(() => '?').join(', ')})`, + values + ); + res.status(201).json(await fetchLead(result.insertId)); + } catch (err) { + next(err); + } +}); + +// PATCH /api/leads/:id +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' }); + + const fields = []; + const values = []; + + for (const field of TEXT_FIELDS) { + if (req.body?.[field] === undefined) continue; + const value = emptyToNull(req.body[field]); + if ((field === 'first_name' || field === 'last_name') && !value) { + return res.status(400).json({ error: 'Vorname und Nachname duerfen nicht leer sein.' }); + } + fields.push(`${field} = ?`); + values.push(value); + } + + if (req.body?.status !== undefined) { + if (!STATUSES.has(req.body.status)) return res.status(400).json({ error: 'Ungueltiger Status.' }); + fields.push('status = ?'); + values.push(req.body.status); + } + + if (req.body?.tags !== undefined) { + fields.push('tags = ?'); + values.push(serializeTags(req.body.tags) ?? '[]'); + } + + if (req.body?.owner_id !== undefined) { + const ownerId = + req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id); + if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) { + return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); + } + fields.push('owner_id = ?'); + values.push(ownerId); + } + + if (fields.length === 0) return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); + + try { + values.push(id); + const [result] = await pool.query( + `UPDATE crm_leads SET ${fields.join(', ')} WHERE id = ?`, + values + ); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Lead nicht gefunden.' }); + res.json(await fetchLead(id)); + } catch (err) { + next(err); + } +}); + +// DELETE /api/leads/:id - Verbindungen entfernt die DB per ON DELETE CASCADE +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' }); + + try { + const [result] = await pool.query('DELETE FROM crm_leads WHERE id = ?', [id]); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Lead nicht gefunden.' }); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/opportunities.js b/server/src/routes/opportunities.js new file mode 100644 index 0000000..d0b45a7 --- /dev/null +++ b/server/src/routes/opportunities.js @@ -0,0 +1,427 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { requireAuth } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(requireAuth); + +const STAGES = new Set([ + 'Neu', + 'Qualifizierung', + 'Angebot', + 'Verhandlung', + 'Gewonnen', + 'Verloren', +]); + +const TEXT_FIELDS = ['title', 'company', 'description', 'last_interaction_note', 'next_step', 'notes']; +const DATE_FIELDS = ['start_date', 'end_date', 'last_interaction_at']; +const NUMBER_FIELDS = ['daily_rate', 'duration_days']; + +function emptyToNull(value) { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +} + +/** Akzeptiert `YYYY-MM-DD` (auch mit angehaengter Uhrzeit) oder null. */ +function normalizeDate(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value).trim()); + if (!match) return { error: 'Ungueltiges Datum (erwartet JJJJ-MM-TT).' }; + + const [, year, month, day] = match; + const probe = new Date(Number(year), Number(month) - 1, Number(day)); + if ( + probe.getFullYear() !== Number(year) || + probe.getMonth() !== Number(month) - 1 || + probe.getDate() !== Number(day) + ) { + return { error: 'Dieses Datum gibt es nicht.' }; + } + return { value: `${year}-${month}-${day}` }; +} + +function normalizeNumber(value, label, max) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const number = Number(String(value).replace(',', '.')); + if (!Number.isFinite(number) || number < 0) return { error: `${label} muss eine Zahl ab 0 sein.` }; + if (number > max) return { error: `${label} ist unrealistisch hoch.` }; + return { value: number }; +} + +function parseTags(raw) { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === 'string') : []; + } catch { + return []; + } +} + +function serializeTags(value) { + if (!Array.isArray(value)) return null; + return JSON.stringify([...new Set(value.map((tag) => String(tag).trim()).filter(Boolean))]); +} + +const SELECT_OPPORTUNITIES = ` + SELECT o.*, + creator.name AS created_by_name, + owner.name AS owner_name, + owner.color AS owner_color, + owner.avatar AS owner_avatar + FROM crm_opportunities o + LEFT JOIN crm_users creator ON creator.id = o.created_by + LEFT JOIN crm_users owner ON owner.id = o.owner_id +`; + +/** + * DECIMAL kommt aus mysql2 als String zurueck - fuer das Frontend werden daraus + * Zahlen, damit dort nicht ueberall geparst werden muss. + */ +function mapOpportunity(row, contacts) { + return { + ...row, + daily_rate: row.daily_rate === null ? null : Number(row.daily_rate), + duration_days: row.duration_days === null ? null : Number(row.duration_days), + probability: Number(row.probability), + tags: parseTags(row.tags), + contacts: contacts ?? [], + }; +} + +/** Ansprechpartner der uebergebenen Opportunities als Map id -> Liste. */ +async function loadContacts(opportunityIds) { + if (opportunityIds.length === 0) return new Map(); + + const [rows] = await pool.query( + `SELECT c.opportunity_id, c.role, c.lead_id, + l.first_name, l.last_name, l.company, l.position, l.email, l.phone, l.avatar + FROM crm_opportunity_contacts c + JOIN crm_leads l ON l.id = c.lead_id + WHERE c.opportunity_id IN (${opportunityIds.map(() => '?').join(', ')}) + ORDER BY l.last_name ASC, l.first_name ASC`, + opportunityIds + ); + + const map = new Map(); + for (const row of rows) { + const list = map.get(row.opportunity_id) ?? []; + list.push({ + lead_id: row.lead_id, + role: row.role, + first_name: row.first_name, + last_name: row.last_name, + company: row.company, + position: row.position, + email: row.email, + phone: row.phone, + avatar: row.avatar, + }); + map.set(row.opportunity_id, list); + } + return map; +} + +async function fetchOpportunity(id) { + const [rows] = await pool.query(`${SELECT_OPPORTUNITIES} WHERE o.id = ? LIMIT 1`, [id]); + if (!rows[0]) return null; + const contacts = await loadContacts([id]); + return mapOpportunity(rows[0], contacts.get(id)); +} + +async function userExists(id) { + if (id === null) return true; + const [rows] = await pool.query('SELECT id FROM crm_users WHERE id = ? LIMIT 1', [id]); + return Boolean(rows[0]); +} + +/** Prueft die Ansprechpartner-Liste `[{ lead_id, role }]`. */ +async function normalizeContacts(raw) { + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) return { error: 'Ansprechpartner muessen als Liste uebergeben werden.' }; + + const seen = new Map(); + for (const entry of raw) { + const leadId = Number(entry?.lead_id ?? entry); + if (!Number.isInteger(leadId) || leadId <= 0) { + return { error: 'Ungueltige Kontakt-ID bei den Ansprechpartnern.' }; + } + const role = emptyToNull(entry?.role); + if (role !== null && role.length > 120) { + return { error: 'Die Rolle eines Ansprechpartners ist zu lang (max. 120 Zeichen).' }; + } + seen.set(leadId, role); + } + + const ids = [...seen.keys()]; + if (ids.length === 0) return { contacts: [] }; + + const [rows] = await pool.query( + `SELECT id FROM crm_leads WHERE id IN (${ids.map(() => '?').join(', ')})`, + ids + ); + if (rows.length !== ids.length) return { error: 'Mindestens ein Ansprechpartner existiert nicht.' }; + + return { contacts: ids.map((leadId) => ({ lead_id: leadId, role: seen.get(leadId) })) }; +} + +async function replaceContacts(conn, opportunityId, contacts) { + await conn.query('DELETE FROM crm_opportunity_contacts WHERE opportunity_id = ?', [opportunityId]); + if (contacts.length === 0) return; + await conn.query( + `INSERT INTO crm_opportunity_contacts (opportunity_id, lead_id, role) + VALUES ${contacts.map(() => '(?, ?, ?)').join(', ')}`, + contacts.flatMap((contact) => [opportunityId, contact.lead_id, contact.role]) + ); +} + +/** Bearbeiten und Loeschen darf der Ersteller, der Zustaendige und jeder Admin. */ +function mayModify(user, opportunity) { + return ( + user.role === 'admin' || + opportunity.created_by === user.id || + opportunity.owner_id === user.id + ); +} + +// GET /api/opportunities +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query( + `${SELECT_OPPORTUNITIES} ORDER BY o.updated_at DESC, o.id DESC` + ); + const contacts = await loadContacts(rows.map((row) => row.id)); + res.json(rows.map((row) => mapOpportunity(row, contacts.get(row.id)))); + } catch (err) { + next(err); + } +}); + +// POST /api/opportunities +router.post('/', async (req, res, next) => { + const title = emptyToNull(req.body?.title); + if (!title) return res.status(400).json({ error: 'Ein Titel ist erforderlich.' }); + + // Fehlt die Phase, gilt "Neu" - eine falsch geschriebene Phase wird aber + // abgewiesen statt still ueberschrieben, sonst glaubt der Client, er haette + // etwas anderes gesetzt. + const stageGiven = req.body?.stage !== undefined && req.body.stage !== null && req.body.stage !== ''; + if (stageGiven && !STAGES.has(req.body.stage)) { + return res.status(400).json({ error: 'Ungueltige Phase.' }); + } + const stage = stageGiven ? req.body.stage : 'Neu'; + + const probability = normalizeNumber(req.body?.probability ?? 10, 'Die Wahrscheinlichkeit', 100); + if (probability.error) return res.status(400).json({ error: probability.error }); + + const numbers = {}; + for (const [field, label, max] of [ + ['daily_rate', 'Der Tagessatz', 1_000_000], + ['duration_days', 'Die Laufzeit', 100_000], + ]) { + const result = normalizeNumber(req.body?.[field], label, max); + if (result.error) return res.status(400).json({ error: result.error }); + numbers[field] = result.value; + } + + const dates = {}; + for (const field of DATE_FIELDS) { + const result = normalizeDate(req.body?.[field]); + if (result.error) return res.status(400).json({ error: result.error }); + dates[field] = result.value; + } + if (dates.start_date && dates.end_date && dates.end_date < dates.start_date) { + return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' }); + } + + const ownerId = + req.body?.owner_id === undefined || req.body.owner_id === null || req.body.owner_id === '' + ? req.user.id + : Number(req.body.owner_id); + if (Number.isNaN(ownerId) || !(await userExists(ownerId))) { + return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); + } + + const contacts = await normalizeContacts(req.body?.contacts ?? []); + if (contacts.error) return res.status(400).json({ error: contacts.error }); + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.query( + `INSERT INTO crm_opportunities + (title, company, description, stage, probability, daily_rate, duration_days, + start_date, end_date, last_interaction_at, last_interaction_note, next_step, + tags, notes, owner_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + title, + emptyToNull(req.body?.company), + emptyToNull(req.body?.description), + stage, + Math.round(probability.value ?? 10), + numbers.daily_rate, + numbers.duration_days, + dates.start_date, + dates.end_date, + dates.last_interaction_at, + emptyToNull(req.body?.last_interaction_note), + emptyToNull(req.body?.next_step), + serializeTags(req.body?.tags) ?? '[]', + emptyToNull(req.body?.notes), + ownerId, + req.user.id, + ] + ); + await replaceContacts(conn, result.insertId, contacts.contacts); + await conn.commit(); + res.status(201).json(await fetchOpportunity(result.insertId)); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + +// PATCH /api/opportunities/:id +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' }); + + const conn = await pool.getConnection(); + try { + const [existingRows] = await conn.query( + 'SELECT * FROM crm_opportunities WHERE id = ? LIMIT 1', + [id] + ); + const existing = existingRows[0]; + if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); + if (!mayModify(req.user, existing)) { + return res.status(403).json({ + error: 'Diese Opportunity darf nur der Ersteller, der Zustaendige oder ein Administrator aendern.', + }); + } + + const fields = []; + const values = []; + + for (const field of TEXT_FIELDS) { + if (req.body?.[field] === undefined) continue; + const value = emptyToNull(req.body[field]); + if (field === 'title' && !value) { + return res.status(400).json({ error: 'Der Titel darf nicht leer sein.' }); + } + fields.push(`${field} = ?`); + values.push(value); + } + + if (req.body?.stage !== undefined) { + if (!STAGES.has(req.body.stage)) return res.status(400).json({ error: 'Ungueltige Phase.' }); + fields.push('stage = ?'); + values.push(req.body.stage); + } + + if (req.body?.probability !== undefined) { + const result = normalizeNumber(req.body.probability, 'Die Wahrscheinlichkeit', 100); + if (result.error) return res.status(400).json({ error: result.error }); + fields.push('probability = ?'); + values.push(Math.round(result.value ?? 0)); + } + + for (const [field, label, max] of [ + ['daily_rate', 'Der Tagessatz', 1_000_000], + ['duration_days', 'Die Laufzeit', 100_000], + ]) { + if (req.body?.[field] === undefined) continue; + const result = normalizeNumber(req.body[field], label, max); + if (result.error) return res.status(400).json({ error: result.error }); + fields.push(`${field} = ?`); + values.push(result.value); + } + + const nextDates = { + start_date: existing.start_date, + end_date: existing.end_date, + }; + for (const field of DATE_FIELDS) { + if (req.body?.[field] === undefined) continue; + const result = normalizeDate(req.body[field]); + if (result.error) return res.status(400).json({ error: result.error }); + fields.push(`${field} = ?`); + values.push(result.value); + if (field in nextDates) nextDates[field] = result.value; + } + if (nextDates.start_date && nextDates.end_date && nextDates.end_date < nextDates.start_date) { + return res.status(400).json({ error: 'Das Projektende darf nicht vor dem Start liegen.' }); + } + + if (req.body?.tags !== undefined) { + fields.push('tags = ?'); + values.push(serializeTags(req.body.tags) ?? '[]'); + } + + if (req.body?.owner_id !== undefined) { + const ownerId = + req.body.owner_id === null || req.body.owner_id === '' ? null : Number(req.body.owner_id); + if (ownerId !== null && (Number.isNaN(ownerId) || !(await userExists(ownerId)))) { + return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' }); + } + fields.push('owner_id = ?'); + values.push(ownerId); + } + + const contacts = await normalizeContacts(req.body?.contacts); + if (contacts?.error) return res.status(400).json({ error: contacts.error }); + + if (fields.length === 0 && !contacts) { + return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); + } + + await conn.beginTransaction(); + if (fields.length > 0) { + await conn.query(`UPDATE crm_opportunities SET ${fields.join(', ')} WHERE id = ?`, [ + ...values, + id, + ]); + } + if (contacts) await replaceContacts(conn, id, contacts.contacts); + await conn.commit(); + + res.json(await fetchOpportunity(id)); + } catch (err) { + await conn.rollback().catch(() => {}); + next(err); + } finally { + conn.release(); + } +}); + +// DELETE /api/opportunities/:id - Ansprechpartner raeumt die DB per CASCADE +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Opportunity-ID.' }); + + try { + const [rows] = await pool.query( + 'SELECT created_by, owner_id FROM crm_opportunities WHERE id = ? LIMIT 1', + [id] + ); + const existing = rows[0]; + if (!existing) return res.status(404).json({ error: 'Opportunity nicht gefunden.' }); + if (!mayModify(req.user, existing)) { + return res.status(403).json({ + error: 'Diese Opportunity darf nur der Ersteller, der Zustaendige oder ein Administrator loeschen.', + }); + } + + await pool.query('DELETE FROM crm_opportunities WHERE id = ?', [id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/scanner.js b/server/src/routes/scanner.js new file mode 100644 index 0000000..e0207f3 --- /dev/null +++ b/server/src/routes/scanner.js @@ -0,0 +1,552 @@ +import express from 'express'; +import multer from 'multer'; +import fs from 'fs'; +import path from 'path'; +import { Op } from 'sequelize'; +import { Agent, Portal, Project, Match, Setting } from '../scanner/models/index.js'; +import { parsePdfCv } from '../scanner/services/pdfParser.js'; +import { encrypt } from '../scanner/utils/crypto.js'; +import { ensureUploadDir, resolveCvPath } from '../scanner/uploadDir.js'; +import { isPipelineRunning, runMatchingPipeline } from '../scanner/services/pipeline.js'; +import { + scrapeFreelancermap, + scrapeFreelanceDe, + scrapeHays, + scrapeEtengo, + scrapeSThreePortal, + SThreePortals, +} from '../scanner/services/scraper.js'; + +/* + * Portierte Scanner-API. Inhaltlich identisch zur urspruenglichen + * routes/api.js des Projekt Scanners, mit drei Unterschieden: + * - haengt unter /api/scanner und hinter der CRM-Anmeldung (requireAuth), + * die HTTP-Basic-Auth des Originals entfaellt damit + * - eigene Datenbank, eigener Schluessel, eigenes Upload-Verzeichnis: diese + * Instanz laeuft parallel zur alten App, ohne deren Daten anzufassen + * - CV-PDFs werden ueber GET /agents/:id/cv ausgeliefert statt als statisches + * Verzeichnis: ein Link im neuen Tab kann kein Bearer-Token mitschicken, + * das Frontend holt die Datei deshalb per fetch und oeffnet sie als Blob + */ +const router = express.Router(); + +const UPLOAD_DIR = ensureUploadDir(); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => cb(null, UPLOAD_DIR), + filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`), +}); +const upload = multer({ storage, limits: { fileSize: 20 * 1024 * 1024 } }); + +// ========================================== +// AGENTEN +// ========================================== + +router.get('/agents', async (req, res) => { + try { + // cvText ist mehrere Hundert KB pro Agent und wird in der Liste nicht + // gebraucht - nur der Hinweis, ob ueberhaupt ein CV-Text vorliegt. + const agents = await Agent.findAll({ + attributes: [ + 'id', + 'name', + 'email', + 'keywords', + 'excludeKeywords', + 'cvFilePath', + 'isActive', + 'createdAt', + 'updatedAt', + ], + order: [['createdAt', 'DESC']], + }); + res.json(agents); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/agents', upload.single('cv'), async (req, res) => { + try { + const { name, email, keywords, excludeKeywords } = req.body; + let cvText = ''; + let cvFilePath = ''; + + if (req.file) { + cvFilePath = req.file.path; + const fileBuffer = fs.readFileSync(cvFilePath); + cvText = await parsePdfCv(fileBuffer); + } + + const agent = await Agent.create({ + name, + email: email || null, + keywords, + excludeKeywords: excludeKeywords || null, + cvText, + cvFilePath, + isActive: req.body.isActive !== 'false', + }); + + res.status(201).json(agent); + } catch (error) { + console.error('[Scanner] Agent konnte nicht angelegt werden:', error); + res.status(500).json({ error: error.message }); + } +}); + +router.put('/agents/:id', upload.single('cv'), async (req, res) => { + try { + const { id } = req.params; + const { name, email, keywords, excludeKeywords, isActive } = req.body; + const agent = await Agent.findByPk(id); + + if (!agent) { + return res.status(404).json({ error: 'Agent nicht gefunden.' }); + } + + agent.name = name || agent.name; + agent.email = email || agent.email; + agent.keywords = keywords || agent.keywords; + if (excludeKeywords !== undefined) { + agent.excludeKeywords = excludeKeywords || null; + } + if (isActive !== undefined) { + agent.isActive = isActive === 'true' || isActive === true; + } + + if (req.file) { + const old = resolveCvPath(agent.cvFilePath); + if (old) { + try { + fs.unlinkSync(old); + } catch (err) { + console.warn('[Scanner] Altes CV konnte nicht geloescht werden:', err.message); + } + } + + agent.cvFilePath = req.file.path; + const fileBuffer = fs.readFileSync(req.file.path); + agent.cvText = await parsePdfCv(fileBuffer); + } + + await agent.save(); + res.json(agent); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.delete('/agents/:id', async (req, res) => { + try { + const { id } = req.params; + const agent = await Agent.findByPk(id); + + if (!agent) { + return res.status(404).json({ error: 'Agent nicht gefunden.' }); + } + + const stored = resolveCvPath(agent.cvFilePath); + if (stored) { + try { + fs.unlinkSync(stored); + } catch (err) { + console.warn('[Scanner] CV konnte nicht geloescht werden:', err.message); + } + } + + await agent.destroy(); + res.json({ message: 'Agent gelöscht.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/** CV als PDF ausliefern - inline, damit der Browser es direkt anzeigt. */ +router.get('/agents/:id/cv', async (req, res) => { + try { + const agent = await Agent.findByPk(req.params.id, { attributes: ['id', 'cvFilePath'] }); + if (!agent) return res.status(404).json({ error: 'Agent nicht gefunden.' }); + + const file = resolveCvPath(agent.cvFilePath); + if (!file) return res.status(404).json({ error: 'Kein CV hinterlegt.' }); + + res.type('application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${path.basename(file)}"`); + fs.createReadStream(file).pipe(res); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========================================== +// PORTALE +// ========================================== + +router.get('/portals', async (req, res) => { + try { + const portals = await Portal.findAll(); + const sanitisedPortals = portals.map((p) => ({ + id: p.id, + name: p.name, + username: p.username, + hasPassword: !!p.passwordEncrypted, + hasSession: !!p.sessionState, + connectionStatus: p.connectionStatus, + lastLoginAttempt: p.lastLoginAttempt, + updatedAt: p.updatedAt, + })); + res.json(sanitisedPortals); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/portals/credentials', async (req, res) => { + try { + const { name, username, password } = req.body; + const portal = await Portal.findOne({ where: { name } }); + + if (!portal) { + return res.status(404).json({ error: `Portal ${name} nicht gefunden.` }); + } + + portal.username = username; + if (password) { + portal.passwordEncrypted = encrypt(password); + } + await portal.save(); + + res.json({ message: 'Zugangsdaten gespeichert.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Cookie-Objekt auf die von Playwright erwartete Form bringen. Exporte aus +// Cookie-Editor & Co. enthalten Felder und sameSite-Werte, die Playwright +// ablehnt (z. B. "no_restriction", "expirationDate", "hostOnly"). +const normalizeCookie = (c) => { + if (!c || typeof c !== 'object') return null; + + let sameSite = c.sameSite; + if (typeof sameSite === 'string') { + const s = sameSite.toLowerCase(); + if (s === 'strict') sameSite = 'Strict'; + else if (s === 'lax') sameSite = 'Lax'; + else if (s === 'none' || s === 'no_restriction') sameSite = 'None'; + else sameSite = 'Lax'; + } else { + sameSite = 'Lax'; + } + + let expires = c.expires; + if (expires === undefined && c.expirationDate !== undefined) { + expires = c.expirationDate; + } + if (typeof expires !== 'number' || !isFinite(expires)) expires = -1; + + if (!c.name || c.value === undefined) return null; + + return { + name: String(c.name), + value: String(c.value), + domain: c.domain || '', + path: c.path || '/', + expires, + httpOnly: !!c.httpOnly, + secure: !!c.secure, + sameSite, + }; +}; + +router.post('/portals/sync-session', async (req, res) => { + try { + const { name, sessionState } = req.body; + const portal = await Portal.findOne({ where: { name } }); + + if (!portal) { + return res.status(404).json({ error: `Portal ${name} nicht gefunden.` }); + } + + let parsedState; + try { + parsedState = typeof sessionState === 'string' ? JSON.parse(sessionState) : sessionState; + } catch { + return res.status(400).json({ error: 'Ungueltiges Format - es muss JSON sein.' }); + } + + // Drei Formen sind erlaubt: Playwright storageState, reines Cookie-Array + // (Cookie-Editor) und die Nutzlast des Bookmarklets. + let rawCookies = []; + let origins = []; + if (Array.isArray(parsedState)) { + rawCookies = parsedState; + } else if (parsedState && typeof parsedState === 'object') { + rawCookies = Array.isArray(parsedState.cookies) ? parsedState.cookies : []; + origins = Array.isArray(parsedState.origins) ? parsedState.origins : []; + } + + const cookies = rawCookies.map(normalizeCookie).filter(Boolean); + if (cookies.length === 0) { + return res.status(400).json({ error: 'Keine gueltigen Cookies gefunden.' }); + } + + portal.sessionState = JSON.stringify({ cookies, origins }); + portal.connectionStatus = 'connected'; + portal.lastLoginAttempt = new Date(); + await portal.save(); + + res.json({ + message: `Session für ${name} importiert. ${cookies.length} Cookies gespeichert.`, + status: 'connected', + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/portals/test-connection/:id', async (req, res) => { + try { + const { id } = req.params; + const portal = await Portal.findByPk(id); + + if (!portal) { + return res.status(404).json({ error: 'Portal nicht gefunden.' }); + } + + const sThreeMatch = SThreePortals.find((p) => p.name === portal.name); + const publicPortals = ['hays.de', 'etengo.de']; + if (publicPortals.includes(portal.name) || sThreeMatch) { + // Oeffentliche Portale brauchen keine Session. Ein minimaler Scrape + // zeigt, ob das Portal antwortet und die Selektoren noch passen. + let result; + try { + if (portal.name === 'hays.de') result = await scrapeHays(['test']); + else if (portal.name === 'etengo.de') result = await scrapeEtengo(['test']); + else result = await scrapeSThreePortal(sThreeMatch); + portal.connectionStatus = 'connected'; + portal.lastLoginAttempt = new Date(); + await portal.save(); + return res.json({ + status: 'connected', + message: `Öffentliches Portal erreichbar. ${result.projects.length} Treffer im Listing.`, + }); + } catch (err) { + portal.connectionStatus = 'error'; + await portal.save(); + return res.json({ status: 'error', message: 'Portal nicht erreichbar: ' + err.message }); + } + } + + if (!portal.sessionState) { + portal.connectionStatus = 'disconnected'; + await portal.save(); + return res.json({ + status: 'disconnected', + message: 'Keine Session hinterlegt. Bitte zuerst Cookies importieren.', + }); + } + + let isValid = false; + if (portal.name === 'freelancermap.de') { + const result = await scrapeFreelancermap(['test'], portal.sessionState); + isValid = result.sessionValid; + } else if (portal.name === 'freelance.de') { + const result = await scrapeFreelanceDe(['test'], portal.sessionState); + isValid = result.sessionValid; + } + + portal.connectionStatus = isValid ? 'connected' : 'expired'; + await portal.save(); + + res.json({ + status: portal.connectionStatus, + message: isValid ? 'Session ist gültig.' : 'Session ist abgelaufen. Bitte neu anmelden.', + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========================================== +// MATCHES +// ========================================== + +router.get('/matches', async (req, res) => { + try { + const { agentId, portal, status, minPercentage, limit } = req.query; + const where = {}; + const projectWhere = {}; + + if (agentId) where.agentId = agentId; + if (status) where.status = status; + if (minPercentage) { + where.matchPercentage = { [Op.gte]: parseInt(minPercentage, 10) }; + } + if (portal) projectWhere.portal = portal; + + const parsedLimit = parseInt(limit, 10); + const finalLimit = + Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 2000) : 500; + + const matches = await Match.findAll({ + where, + include: [ + { model: Agent, attributes: ['id', 'name', 'email', 'isActive'] }, + { model: Project, where: projectWhere }, + ], + order: [ + ['matchPercentage', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: finalLimit, + }); + + res.json(matches); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/matches/:id', async (req, res) => { + try { + const match = await Match.findByPk(req.params.id, { + include: [{ model: Agent, attributes: ['id', 'name', 'email', 'isActive'] }, { model: Project }], + }); + if (!match) return res.status(404).json({ error: 'Match nicht gefunden.' }); + res.json(match); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/matches/:id', async (req, res) => { + try { + const { id } = req.params; + const { status } = req.body; + const match = await Match.findByPk(id); + + if (!match) { + return res.status(404).json({ error: 'Match nicht gefunden.' }); + } + + match.status = status; + await match.save(); + + res.json(match); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.delete('/matches/:id', async (req, res) => { + try { + const { id } = req.params; + const match = await Match.findByPk(id); + + if (!match) { + return res.status(404).json({ error: 'Match nicht gefunden.' }); + } + + await match.destroy(); + res.json({ message: 'Match gelöscht.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========================================== +// EINSTELLUNGEN +// ========================================== + +// Geheimnisse werden in GET-Antworten maskiert und beim Speichern +// uebersprungen, solange die Maske noch drinsteht - so bleibt ein gesetzter +// Wert erhalten, wenn das Feld leer gelassen wird. +const SECRET_KEYS = ['smtp_pass', 'openai_api_key']; +const MASK = '********'; + +router.get('/settings', async (req, res) => { + try { + const settingsList = await Setting.findAll(); + const settingsMap = {}; + settingsList.forEach((s) => { + if (SECRET_KEYS.includes(s.keyName) && s.value) { + settingsMap[s.keyName] = MASK; + } else { + settingsMap[s.keyName] = s.value; + } + }); + res.json(settingsMap); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/settings', async (req, res) => { + try { + const settings = req.body; + for (const key of Object.keys(settings)) { + if (SECRET_KEYS.includes(key) && (settings[key] === MASK || settings[key] === '')) { + continue; + } + await Setting.setVal(key, settings[key]); + } + res.json({ message: 'Einstellungen gespeichert.' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ========================================== +// MANUELLER SCAN +// ========================================== + +router.post('/scan', async (req, res) => { + try { + const scanningActive = await Setting.getVal('scanning_active', 'true'); + if (scanningActive === 'false') { + return res + .status(409) + .json({ error: 'Scanner ist deaktiviert. Aktiviere ihn in den Einstellungen.' }); + } + + if (isPipelineRunning()) { + return res.status(409).json({ error: 'Ein Suchlauf läuft bereits. Bitte kurz warten.' }); + } + + // Pipeline laeuft im Hintergrund weiter, damit die Anfrage nicht in einen + // Timeout laeuft. + runMatchingPipeline() + .then(() => console.log('[Scanner] Manueller Scan abgeschlossen.')) + .catch((err) => console.error('[Scanner] Fehler im manuellen Scan:', err)); + + res.json({ + message: + 'Scraping und Matching gestartet. Das kann einige Minuten dauern - die Ansicht aktualisiert sich automatisch.', + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +/* + * Betriebszustand fuer das Dashboard: laeuft gerade ein Suchlauf und ist der + * Hintergrund-Scheduler in dieser Instanz aktiv. Beim Parallelbetrieb mit der + * alten App ist Letzteres die entscheidende Information. + */ +router.get('/status', async (req, res) => { + try { + const scanningActive = (await Setting.getVal('scanning_active', 'true')) !== 'false'; + res.json({ + scanRunning: isPipelineRunning(), + schedulerEnabled: process.env.SCANNER_SCHEDULER !== 'false', + scanningActive, + database: process.env.SCANNER_DB_NAME || 'perinco_scanner', + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/server/src/routes/users.js b/server/src/routes/users.js new file mode 100644 index 0000000..384c6e6 --- /dev/null +++ b/server/src/routes/users.js @@ -0,0 +1,237 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { pool } from '../db.js'; +import { requireAuth, requireAdmin } from '../middleware/auth.js'; +import { publicUser } from './auth.js'; +import { normalizeAvatar, normalizeColor } from '../userFields.js'; + +const router = express.Router(); +router.use(requireAuth); + +const ROLES = new Set(['admin', 'user']); + +/** Felder, die Admins optional mitschicken duerfen; Pruefung im Modul. */ +const OPTIONAL_FIELDS = [ + ['color', normalizeColor], + ['avatar', normalizeAvatar], +]; + +function normalizeEmail(value) { + const email = String(value ?? '').trim().toLowerCase(); + return email === '' ? null : email; +} + +// GET /api/users/directory +// Benutzerliste ohne Adminrechte - jeder angemeldete Benutzer braucht sie fuer +// das Netzwerkdiagramm, Zustaendigkeiten, @-Mentions, Teilnehmerauswahl und +// Farben im Kalender. Bewusst vor dem Admin-Guard registriert; Passwort-Hashes +// tauchen hier nie auf. Deaktivierte Konten bleiben enthalten, damit alte +// Zuordnungen und Mentions weiter aufloesbar sind - die Auswahlfelder im +// Frontend filtern sie heraus. +router.get('/directory', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + FROM crm_users ORDER BY name ASC` + ); + res.json(rows.map(publicUser)); + } catch (err) { + next(err); + } +}); + +// Ab hier ist alles Admin-only - die Rolle "user" sieht die Einstellungen im +// Frontend gar nicht und wird hier zusaetzlich abgewiesen. +router.use(requireAdmin); + +/** Verhindert, dass der letzte aktive Admin verschwindet. */ +async function countOtherActiveAdmins(excludeId) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS count FROM crm_users + WHERE role = 'admin' AND is_active = 1 AND id <> ?`, + [excludeId] + ); + return rows[0].count; +} + +function duplicateMessage(err) { + if (err?.code !== 'ER_DUP_ENTRY') return null; + return err.message?.includes('uq_crm_users_email') + ? 'Diese E-Mail-Adresse ist bereits vergeben.' + : 'Dieser Benutzername ist bereits vergeben.'; +} + +// GET /api/users +router.get('/', async (req, res, next) => { + try { + const [rows] = await pool.query( + `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + FROM crm_users ORDER BY role DESC, name ASC` + ); + res.json(rows.map(publicUser)); + } catch (err) { + next(err); + } +}); + +// POST /api/users +router.post('/', async (req, res, next) => { + const username = String(req.body?.username || '').trim(); + const name = String(req.body?.name || '').trim(); + const email = normalizeEmail(req.body?.email); + const password = String(req.body?.password || ''); + const role = ROLES.has(req.body?.role) ? req.body.role : 'user'; + const isActive = req.body?.is_active === false ? 0 : 1; + + if (!username || !name || !password) { + return res.status(400).json({ error: 'Benutzername, Name und Passwort sind erforderlich.' }); + } + if (password.length < 8) { + return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen haben.' }); + } + + const optional = {}; + for (const [field, normalize] of OPTIONAL_FIELDS) { + const result = normalize(req.body?.[field]); + if (result.error) return res.status(400).json({ error: result.error }); + optional[field] = result.value; + } + + try { + const hash = await bcrypt.hash(password, 12); + const [result] = await pool.query( + `INSERT INTO crm_users (username, email, name, role, color, avatar, password_hash, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [username, email, name, role, optional.color, optional.avatar, hash, isActive] + ); + const [rows] = await pool.query( + `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + FROM crm_users WHERE id = ?`, + [result.insertId] + ); + res.status(201).json(publicUser(rows[0])); + } catch (err) { + const message = duplicateMessage(err); + if (message) return res.status(409).json({ error: message }); + next(err); + } +}); + +// PATCH /api/users/:id - bearbeitet Benutzer inkl. Admin +router.patch('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' }); + + try { + const [existingRows] = await pool.query('SELECT * FROM crm_users WHERE id = ? LIMIT 1', [id]); + const existing = existingRows[0]; + if (!existing) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + + const fields = []; + const values = []; + + if (req.body?.username !== undefined) { + const username = String(req.body.username).trim(); + if (!username) return res.status(400).json({ error: 'Benutzername darf nicht leer sein.' }); + fields.push('username = ?'); + values.push(username); + } + if (req.body?.name !== undefined) { + const name = String(req.body.name).trim(); + if (!name) return res.status(400).json({ error: 'Name darf nicht leer sein.' }); + fields.push('name = ?'); + values.push(name); + } + if (req.body?.email !== undefined) { + fields.push('email = ?'); + values.push(normalizeEmail(req.body.email)); + } + for (const [field, normalize] of OPTIONAL_FIELDS) { + if (req.body?.[field] === undefined) continue; + const result = normalize(req.body[field]); + if (result.error) return res.status(400).json({ error: result.error }); + fields.push(`${field} = ?`); + values.push(result.value); + } + + const nextRole = req.body?.role !== undefined ? String(req.body.role) : existing.role; + if (req.body?.role !== undefined) { + if (!ROLES.has(nextRole)) return res.status(400).json({ error: 'Ungueltige Rolle.' }); + fields.push('role = ?'); + values.push(nextRole); + } + + const nextActive = + req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : existing.is_active ? 1 : 0; + if (req.body?.is_active !== undefined) { + fields.push('is_active = ?'); + values.push(nextActive); + } + + // Sicherheitsnetz: es muss immer mindestens ein aktiver Admin uebrig bleiben. + const losesAdminRights = + existing.role === 'admin' && existing.is_active && (nextRole !== 'admin' || nextActive === 0); + if (losesAdminRights && (await countOtherActiveAdmins(id)) === 0) { + return res.status(409).json({ + error: 'Das ist der letzte aktive Administrator. Lege zuerst einen weiteren Admin an.', + }); + } + + if (req.body?.password) { + const password = String(req.body.password); + if (password.length < 8) { + return res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen haben.' }); + } + fields.push('password_hash = ?'); + values.push(await bcrypt.hash(password, 12)); + } + + if (fields.length === 0) { + return res.status(400).json({ error: 'Keine Aenderungen uebergeben.' }); + } + + values.push(id); + await pool.query(`UPDATE crm_users SET ${fields.join(', ')} WHERE id = ?`, values); + + const [rows] = await pool.query( + `SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at + FROM crm_users WHERE id = ?`, + [id] + ); + res.json(publicUser(rows[0])); + } catch (err) { + const message = duplicateMessage(err); + if (message) return res.status(409).json({ error: message }); + next(err); + } +}); + +// DELETE /api/users/:id +router.delete('/:id', async (req, res, next) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' }); + if (id === req.user.id) { + return res.status(400).json({ error: 'Das eigene Konto kann nicht geloescht werden.' }); + } + + try { + const [rows] = await pool.query('SELECT role, is_active FROM crm_users WHERE id = ? LIMIT 1', [ + id, + ]); + const target = rows[0]; + if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' }); + + if (target.role === 'admin' && target.is_active && (await countOtherActiveAdmins(id)) === 0) { + return res + .status(409) + .json({ error: 'Der letzte aktive Administrator kann nicht geloescht werden.' }); + } + + await pool.query('DELETE FROM crm_users WHERE id = ?', [id]); + res.json({ ok: true }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/scanner/db/config.js b/server/src/scanner/db/config.js new file mode 100644 index 0000000..96ab480 --- /dev/null +++ b/server/src/scanner/db/config.js @@ -0,0 +1,33 @@ +import { Sequelize } from 'sequelize'; + +/* + * Der Scanner-Teil bringt seine eigene Sequelize-Verbindung mit - das CRM + * selbst arbeitet mit mysql2 direkt (siehe ../../db.js). + * + * Die Datenbank ist bewusst eine EIGENE (SCANNER_DB_NAME, Default + * "perinco_scanner") und nicht die "project_scanner" der alten App: beide + * Scanner laufen parallel und duerfen sich nicht in die Daten schreiben. Die + * Tabellennamen bleiben identisch (agents, portals, projects, matches, + * settings), weil sie in einer getrennten Datenbank liegen. Bestehende Agenten, + * Portale und Einstellungen holt `npm run scanner:import` einmalig herueber. + * + * Zugangsdaten fallen auf die CRM-Werte zurueck, wenn nichts eigenes gesetzt + * ist. dotenv wird nicht hier geladen - das erledigt server.js. + */ +const sequelize = new Sequelize( + process.env.SCANNER_DB_NAME || 'perinco_scanner', + process.env.SCANNER_DB_USER || process.env.DB_USER || 'root', + process.env.SCANNER_DB_PASSWORD || process.env.DB_PASSWORD || '', + { + host: process.env.SCANNER_DB_HOST || process.env.DB_HOST || 'localhost', + port: Number(process.env.SCANNER_DB_PORT || process.env.DB_PORT || 3306), + dialect: 'mysql', + logging: false, + define: { + underscored: true, + timestamps: true, + }, + } +); + +export default sequelize; diff --git a/server/src/scanner/db/ensureDatabase.js b/server/src/scanner/db/ensureDatabase.js new file mode 100644 index 0000000..a6f00e0 --- /dev/null +++ b/server/src/scanner/db/ensureDatabase.js @@ -0,0 +1,35 @@ +import mysql from 'mysql2/promise'; + +/* + * Legt die Scanner-Datenbank an, falls sie noch fehlt. Sequelize kann das nicht + * selbst - `sync()` erzeugt Tabellen, aber keine Datenbank. Da der Scanner hier + * bewusst eine eigene Datenbank benutzt (nicht die der alten App), soll die + * erste Einrichtung ohne Handarbeit an der MariaDB laufen. + */ +export async function ensureScannerDatabase() { + const name = process.env.SCANNER_DB_NAME || 'perinco_scanner'; + + // Bezeichner lassen sich nicht als Platzhalter binden - deshalb hart pruefen, + // welche Zeichen im Datenbanknamen erlaubt sind. + if (!/^[A-Za-z0-9_]+$/.test(name)) { + throw new Error(`Ungueltiger SCANNER_DB_NAME: "${name}" (nur A-Z, a-z, 0-9, _).`); + } + + const connection = await mysql.createConnection({ + host: process.env.SCANNER_DB_HOST || process.env.DB_HOST || 'localhost', + port: Number(process.env.SCANNER_DB_PORT || process.env.DB_PORT || 3306), + user: process.env.SCANNER_DB_USER || process.env.DB_USER || 'root', + password: process.env.SCANNER_DB_PASSWORD || process.env.DB_PASSWORD || '', + }); + + try { + await connection.query( + `CREATE DATABASE IF NOT EXISTS \`${name}\` + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` + ); + } finally { + await connection.end(); + } + + return name; +} diff --git a/server/src/scanner/importLegacy.js b/server/src/scanner/importLegacy.js new file mode 100644 index 0000000..58af906 --- /dev/null +++ b/server/src/scanner/importLegacy.js @@ -0,0 +1,287 @@ +/* + * Einmalige Uebernahme aus der alten Projekt-Scanner-Datenbank in die eigene + * Datenbank dieser Instanz. Beide Scanner laufen danach parallel und getrennt - + * dieses Skript kopiert nur, es veraendert die Quelle nicht. + * + * Aufruf (aus server/): + * npm run scanner:import + * npm run scanner:import -- --with-matches auch Projekte und Treffer + * npm run scanner:import -- --force vorhandene Zieldaten ersetzen + * npm run scanner:import -- --dry-run nur anzeigen, nichts schreiben + * + * Quelle konfigurieren (server/.env): + * LEGACY_DB_NAME=project_scanner + * LEGACY_DB_HOST / _PORT / _USER / _PASSWORD (leer = CRM-Werte) + * LEGACY_UPLOAD_DIR=/opt/project-scanner/backend/uploads + * LEGACY_ENCRYPTION_KEY=... Portal-Passwoerter mit uebernehmen + * + * Was uebernommen wird: + * - Einstellungen (inkl. OpenAI-Key, SMTP-Zugang, Schwellenwerte) + * - Portale: Benutzername, Session-Cookies, Status; Passwoerter nur mit + * LEGACY_ENCRYPTION_KEY, weil sie mit dem alten Schluessel verschluesselt + * sind und hier neu verschluesselt werden muessen + * - Suchagenten inkl. CV-Text und CV-PDF (Datei wird kopiert) + * - mit --with-matches zusaetzlich Projekte und Treffer + */ +import 'dotenv/config'; +import fs from 'node:fs'; +import path from 'node:path'; +import mysql from 'mysql2/promise'; +import { + Agent, + Match, + Portal, + Project, + Setting, + initScannerDatabase, +} from './models/index.js'; +import { encrypt, decryptWith } from './utils/crypto.js'; +import { ensureUploadDir } from './uploadDir.js'; + +const args = process.argv.slice(2); +const withMatches = args.includes('--with-matches'); +const force = args.includes('--force'); +const dryRun = args.includes('--dry-run'); + +const LEGACY_UPLOAD_DIR = process.env.LEGACY_UPLOAD_DIR || ''; +const LEGACY_KEY = process.env.LEGACY_ENCRYPTION_KEY || ''; + +const log = (message) => console.log(`[Import] ${message}`); + +async function openLegacy() { + const database = process.env.LEGACY_DB_NAME || 'project_scanner'; + const connection = await mysql.createConnection({ + host: process.env.LEGACY_DB_HOST || process.env.DB_HOST || 'localhost', + port: Number(process.env.LEGACY_DB_PORT || process.env.DB_PORT || 3306), + user: process.env.LEGACY_DB_USER || process.env.DB_USER || 'root', + password: process.env.LEGACY_DB_PASSWORD || process.env.DB_PASSWORD || '', + database, + dateStrings: false, + }); + log(`Quelle: ${database}`); + return connection; +} + +/** Kopiert das CV-PDF in das eigene Upload-Verzeichnis. */ +function copyCv(storedPath, uploadDir) { + if (!storedPath) return ''; + + const fileName = path.basename(storedPath); + const candidates = [ + path.isAbsolute(storedPath) ? storedPath : null, + LEGACY_UPLOAD_DIR ? path.join(LEGACY_UPLOAD_DIR, fileName) : null, + path.resolve(storedPath), + ].filter(Boolean); + + const source = candidates.find((candidate) => fs.existsSync(candidate)); + if (!source) { + log(` CV-Datei nicht gefunden: ${storedPath} (LEGACY_UPLOAD_DIR setzen?)`); + return ''; + } + + const target = path.join(uploadDir, fileName); + if (!dryRun) fs.copyFileSync(source, target); + return target; +} + +async function importSettings(legacy) { + const [rows] = await legacy.query('SELECT key_name, value FROM settings'); + for (const row of rows) { + if (!dryRun) await Setting.setVal(row.key_name, row.value ?? ''); + } + log(`Einstellungen: ${rows.length} Schluessel uebernommen.`); +} + +async function importPortals(legacy) { + const [rows] = await legacy.query( + 'SELECT name, username, password_encrypted, session_state, connection_status, last_login_attempt FROM portals' + ); + + let withSession = 0; + let withPassword = 0; + + for (const row of rows) { + // Portale sind bei der Initialisierung schon angelegt - hier wird nur der + // Inhalt nachgezogen, ueber den Namen zugeordnet. + const portal = await Portal.findOne({ where: { name: row.name } }); + if (!portal) { + log(` Portal ${row.name} gibt es hier nicht - uebersprungen.`); + continue; + } + + portal.username = row.username || portal.username; + if (row.session_state) { + portal.sessionState = row.session_state; + portal.connectionStatus = row.connection_status || 'connected'; + portal.lastLoginAttempt = row.last_login_attempt || new Date(); + withSession++; + } + + if (row.password_encrypted && LEGACY_KEY) { + const plain = decryptWith(row.password_encrypted, LEGACY_KEY); + if (plain) { + // Neu verschluesseln - diese Instanz hat ihren eigenen Schluessel. + portal.passwordEncrypted = encrypt(plain); + withPassword++; + } else { + log(` Passwort von ${row.name} liess sich mit LEGACY_ENCRYPTION_KEY nicht lesen.`); + } + } + + if (!dryRun) await portal.save(); + } + + log(`Portale: ${rows.length} geprueft, ${withSession} mit Session, ${withPassword} mit Passwort.`); + if (!LEGACY_KEY) { + log(' Hinweis: ohne LEGACY_ENCRYPTION_KEY bleiben die Portal-Passwoerter leer.'); + } +} + +async function importAgents(legacy, uploadDir) { + const [rows] = await legacy.query( + `SELECT id, name, email, cv_text, cv_file_path, keywords, exclude_keywords, is_active, + created_at, updated_at + FROM agents ORDER BY id` + ); + + for (const row of rows) { + const cvFilePath = copyCv(row.cv_file_path, uploadDir); + if (dryRun) { + log(` wuerde Agent ${row.id} "${row.name}" anlegen`); + continue; + } + + // Ids bleiben erhalten, damit uebernommene Treffer weiter passen. + await Agent.create( + { + id: row.id, + name: row.name, + email: row.email, + cvText: row.cv_text, + cvFilePath, + keywords: row.keywords, + excludeKeywords: row.exclude_keywords, + isActive: Boolean(row.is_active), + createdAt: row.created_at, + updatedAt: row.updated_at, + }, + { silent: true } + ); + } + + log(`Suchagenten: ${rows.length} uebernommen.`); +} + +async function importProjectsAndMatches(legacy) { + const [projects] = await legacy.query( + `SELECT id, portal, portal_project_id, title, description, company, location, rate, url, + published_at, created_at + FROM projects ORDER BY id` + ); + + if (!dryRun) { + for (const row of projects) { + await Project.create( + { + id: row.id, + portal: row.portal, + portalProjectId: row.portal_project_id, + title: row.title, + description: row.description, + company: row.company, + location: row.location, + rate: row.rate, + url: row.url, + publishedAt: row.published_at, + createdAt: row.created_at, + }, + { silent: true } + ); + } + } + log(`Projekte: ${projects.length} uebernommen.`); + + const [matches] = await legacy.query( + `SELECT id, agent_id, project_id, match_percentage, pros, cons, status, notification_sent, + created_at, updated_at + FROM matches ORDER BY id` + ); + + const parseList = (value) => { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + }; + + if (!dryRun) { + for (const row of matches) { + await Match.create( + { + id: row.id, + agentId: row.agent_id, + projectId: row.project_id, + matchPercentage: row.match_percentage, + pros: parseList(row.pros), + cons: parseList(row.cons), + status: row.status, + notificationSent: Boolean(row.notification_sent), + createdAt: row.created_at, + updatedAt: row.updated_at, + }, + { silent: true } + ); + } + } + log(`Treffer: ${matches.length} uebernommen.`); +} + +async function main() { + if (dryRun) log('Probelauf - es wird nichts geschrieben.'); + + await initScannerDatabase(); + const uploadDir = ensureUploadDir(); + log(`Ziel-Upload-Verzeichnis: ${uploadDir}`); + + const existingAgents = await Agent.count(); + if (existingAgents > 0 && !force) { + log( + `Abbruch: es gibt hier schon ${existingAgents} Suchagenten. Mit --force werden Agenten, ` + + 'Projekte und Treffer vorher geloescht.' + ); + process.exit(1); + } + + const legacy = await openLegacy(); + try { + if (force && !dryRun) { + // Reihenfolge wegen der Fremdschluessel: Treffer, dann Projekte, dann Agenten. + await Match.destroy({ where: {}, truncate: false }); + await Project.destroy({ where: {}, truncate: false }); + await Agent.destroy({ where: {}, truncate: false }); + log('Vorhandene Agenten, Projekte und Treffer geloescht.'); + } + + await importSettings(legacy); + await importPortals(legacy); + await importAgents(legacy, uploadDir); + if (withMatches) { + await importProjectsAndMatches(legacy); + } else { + log('Projekte und Treffer uebersprungen (--with-matches aktiviert sie).'); + } + } finally { + await legacy.end(); + } + + log('Fertig.'); + process.exit(0); +} + +main().catch((err) => { + console.error('[Import] Fehlgeschlagen:', err.message); + process.exit(1); +}); diff --git a/server/src/scanner/models/Agent.js b/server/src/scanner/models/Agent.js new file mode 100644 index 0000000..848d4e6 --- /dev/null +++ b/server/src/scanner/models/Agent.js @@ -0,0 +1,51 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../db/config.js'; + +const Agent = sequelize.define('Agent', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + email: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isEmail: true, + }, + }, + cvText: { + type: DataTypes.TEXT('long'), + allowNull: true, + field: 'cv_text', + }, + cvFilePath: { + type: DataTypes.STRING, + allowNull: true, + field: 'cv_file_path', + }, + keywords: { + type: DataTypes.STRING, + allowNull: false, + comment: 'Comma-separated keywords for search', + }, + excludeKeywords: { + type: DataTypes.TEXT, + allowNull: true, + field: 'exclude_keywords', + comment: 'Comma-separated terms; projects containing any are ignored', + }, + isActive: { + type: DataTypes.BOOLEAN, + defaultValue: true, + field: 'is_active', + }, +}, { + tableName: 'agents', +}); + +export default Agent; diff --git a/server/src/scanner/models/Match.js b/server/src/scanner/models/Match.js new file mode 100644 index 0000000..40179b1 --- /dev/null +++ b/server/src/scanner/models/Match.js @@ -0,0 +1,85 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../db/config.js'; +import Agent from './Agent.js'; +import Project from './Project.js'; + +const Match = sequelize.define('Match', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + agentId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'agent_id', + references: { + model: Agent, + key: 'id', + }, + }, + projectId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'project_id', + references: { + model: Project, + key: 'id', + }, + }, + matchPercentage: { + type: DataTypes.INTEGER, + defaultValue: 0, + field: 'match_percentage', + }, + pros: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'JSON array of strings', + get() { + const rawValue = this.getDataValue('pros'); + return rawValue ? JSON.parse(rawValue) : []; + }, + set(value) { + this.setDataValue('pros', JSON.stringify(value)); + }, + }, + cons: { + type: DataTypes.TEXT, + allowNull: true, + comment: 'JSON array of strings', + get() { + const rawValue = this.getDataValue('cons'); + return rawValue ? JSON.parse(rawValue) : []; + }, + set(value) { + this.setDataValue('cons', JSON.stringify(value)); + }, + }, + status: { + type: DataTypes.STRING(50), + defaultValue: 'new', // 'new', 'reviewed', 'applied', 'rejected' + }, + notificationSent: { + type: DataTypes.BOOLEAN, + defaultValue: false, + field: 'notification_sent', + }, +}, { + tableName: 'matches', + indexes: [ + { + unique: true, + fields: ['agent_id', 'project_id'], + }, + ], +}); + +// Associations +Agent.hasMany(Match, { foreignKey: 'agent_id', onDelete: 'CASCADE' }); +Match.belongsTo(Agent, { foreignKey: 'agent_id' }); + +Project.hasMany(Match, { foreignKey: 'project_id', onDelete: 'CASCADE' }); +Match.belongsTo(Project, { foreignKey: 'project_id' }); + +export default Match; diff --git a/server/src/scanner/models/Portal.js b/server/src/scanner/models/Portal.js new file mode 100644 index 0000000..07f67d7 --- /dev/null +++ b/server/src/scanner/models/Portal.js @@ -0,0 +1,44 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../db/config.js'; + +const Portal = sequelize.define('Portal', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING(50), + allowNull: false, + unique: true, + }, + username: { + type: DataTypes.STRING, + allowNull: true, + }, + passwordEncrypted: { + type: DataTypes.STRING(500), + allowNull: true, + field: 'password_encrypted', + }, + sessionState: { + type: DataTypes.TEXT('long'), + allowNull: true, + field: 'session_state', + comment: 'JSON string of cookies and localStorage', + }, + connectionStatus: { + type: DataTypes.STRING(50), + defaultValue: 'disconnected', + field: 'connection_status', + }, + lastLoginAttempt: { + type: DataTypes.DATE, + allowNull: true, + field: 'last_login_attempt', + }, +}, { + tableName: 'portals', +}); + +export default Portal; diff --git a/server/src/scanner/models/Project.js b/server/src/scanner/models/Project.js new file mode 100644 index 0000000..3c7a1ba --- /dev/null +++ b/server/src/scanner/models/Project.js @@ -0,0 +1,61 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../db/config.js'; + +const Project = sequelize.define('Project', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + portal: { + type: DataTypes.STRING(50), + allowNull: false, + }, + portalProjectId: { + type: DataTypes.STRING(100), + allowNull: false, + field: 'portal_project_id', + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT('long'), + allowNull: true, + }, + company: { + type: DataTypes.STRING, + allowNull: true, + }, + location: { + type: DataTypes.STRING, + allowNull: true, + }, + rate: { + type: DataTypes.STRING(100), + allowNull: true, + }, + url: { + type: DataTypes.STRING(500), + allowNull: false, + }, + publishedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'published_at', + }, +}, { + tableName: 'projects', + indexes: [ + { + unique: true, + fields: ['portal', 'portal_project_id'], + }, + ], + timestamps: true, + updatedAt: false, // projects are immutable once scraped, we only track when they were created + createdAt: 'created_at', +}); + +export default Project; diff --git a/server/src/scanner/models/Setting.js b/server/src/scanner/models/Setting.js new file mode 100644 index 0000000..f4b4e11 --- /dev/null +++ b/server/src/scanner/models/Setting.js @@ -0,0 +1,50 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../db/config.js'; + +const Setting = sequelize.define('Setting', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + keyName: { + type: DataTypes.STRING(100), + allowNull: false, + unique: true, + field: 'key_name', + }, + value: { + type: DataTypes.TEXT, + allowNull: true, + }, +}, { + tableName: 'settings', +}); + +// Helper static methods to get and set settings easily +Setting.getVal = async function(key, defaultValue = null) { + try { + const setting = await this.findOne({ where: { keyName: key } }); + return setting ? setting.value : defaultValue; + } catch (err) { + console.error(`Error getting setting ${key}:`, err); + return defaultValue; + } +}; + +Setting.setVal = async function(key, value) { + try { + const [setting] = await this.findOrCreate({ + where: { keyName: key }, + defaults: { value: String(value) } + }); + setting.value = String(value); + await setting.save(); + return true; + } catch (err) { + console.error(`Error setting ${key}:`, err); + return false; + } +}; + +export default Setting; diff --git a/server/src/scanner/models/index.js b/server/src/scanner/models/index.js new file mode 100644 index 0000000..fe22f20 --- /dev/null +++ b/server/src/scanner/models/index.js @@ -0,0 +1,86 @@ +import sequelize from '../db/config.js'; +import { ensureScannerDatabase } from '../db/ensureDatabase.js'; +import Agent from './Agent.js'; +import Portal from './Portal.js'; +import Project from './Project.js'; +import Match from './Match.js'; +import Setting from './Setting.js'; + +const initScannerDatabase = async () => { + try { + // Eigene Datenbank, deshalb erst anlegen und dann verbinden. + const dbName = await ensureScannerDatabase(); + await sequelize.authenticate(); + console.log(`[Scanner] Datenbankverbindung steht (${dbName}).`); + + // Sync database tables. Plain sync() — `alter: true` repeatedly re-creates + // UNIQUE indexes on each boot and eventually exhausts MySQL's 64-key limit. + // Schema changes are managed via SQL migrations / schema.sql instead. + await sequelize.sync(); + console.log('[Scanner] Tabellen synchronisiert.'); + + // Ensure every supported portal exists. Per-row findOrCreate so newly added + // portals appear after upgrade without manual SQL. + const defaultPortals = [ + { name: 'freelance.de', connectionStatus: 'disconnected' }, + { name: 'freelancermap.de', connectionStatus: 'disconnected' }, + { name: 'hays.de', connectionStatus: 'disconnected' }, + { name: 'etengo.de', connectionStatus: 'disconnected' }, + { name: 'huxley.com', connectionStatus: 'disconnected' }, + { name: 'computerfutures.com', connectionStatus: 'disconnected' }, + { name: 'progressiverecruitment.com', connectionStatus: 'disconnected' }, + { name: 'realstaffing.com', connectionStatus: 'disconnected' }, + { name: 'globalenterprisepartners.com', connectionStatus: 'disconnected' }, + { name: 'madisonblack.com', connectionStatus: 'disconnected' }, + { name: 'jpgray.nl', connectionStatus: 'disconnected' } + ]; + for (const p of defaultPortals) { + const [, created] = await Portal.findOrCreate({ + where: { name: p.name }, + defaults: p + }); + if (created) console.log(`[Scanner] Portal angelegt: ${p.name}`); + } + + // Default settings + const defaultSettings = [ + { keyName: 'scanning_active', value: 'true' }, + { keyName: 'search_interval', value: '60' }, + { keyName: 'openai_api_key', value: '' }, + { keyName: 'email_notifications_active', value: 'false' }, + { keyName: 'sound_notifications_active', value: 'true' }, + { keyName: 'smtp_host', value: '' }, + { keyName: 'smtp_port', value: '587' }, + { keyName: 'smtp_user', value: '' }, + { keyName: 'smtp_pass', value: '' }, + { keyName: 'smtp_from', value: 'scanner@perinco.de' }, + { keyName: 'notification_email_recipient', value: '' }, + { keyName: 'match_threshold', value: '70' }, + { keyName: 'display_threshold', value: '50' } + ]; + + for (const s of defaultSettings) { + const exists = await Setting.findOne({ where: { keyName: s.keyName } }); + if (!exists) { + await Setting.create(s); + } + } + console.log('[Scanner] Standardeinstellungen geprueft.'); + + } catch (error) { + // Ein fehlender Scanner-Teil darf das CRM nicht am Start hindern - die + // Scanner-Endpunkte antworten dann mit einem Fehler, alles andere laeuft. + console.error('[Scanner] Datenbank nicht erreichbar oder Sync fehlgeschlagen:', error.message); + throw error; + } +}; + +export { + sequelize, + Agent, + Portal, + Project, + Match, + Setting, + initScannerDatabase +}; diff --git a/server/src/scanner/services/cleanup.js b/server/src/scanner/services/cleanup.js new file mode 100644 index 0000000..46b9e33 --- /dev/null +++ b/server/src/scanner/services/cleanup.js @@ -0,0 +1,23 @@ +import sequelize from '../db/config.js'; +import Project from '../models/Project.js'; + +// Retention window for scraped projects. Matches cascade-delete via FK. +const RETENTION_DAYS = 7; + +// Project model defines `createdAt: 'created_at'` for the column rename, but +// Sequelize's attribute-to-column mapping in `where` does not pick it up here +// (likely because of the underscored global default + explicit override +// interaction). Using sequelize.col('created_at') sidesteps it. +export const cleanupOldProjects = async () => { + const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000); + try { + const deleted = await Project.destroy({ + where: sequelize.where(sequelize.col('created_at'), '<', cutoff) + }); + console.log(`[Cleanup] Removed ${deleted} projects older than ${RETENTION_DAYS} days (cutoff ${cutoff.toISOString()}).`); + return deleted; + } catch (err) { + console.error('[Cleanup] Failed to delete old projects:', err); + return 0; + } +}; diff --git a/server/src/scanner/services/matchingEngine.js b/server/src/scanner/services/matchingEngine.js new file mode 100644 index 0000000..94bf7f8 --- /dev/null +++ b/server/src/scanner/services/matchingEngine.js @@ -0,0 +1,79 @@ +import { OpenAI } from 'openai'; +import Setting from '../models/Setting.js'; + +/** + * Evaluates the match between an agent's CV and a scraped project description using OpenAI. + * @param {string} cvText - Plain text extracted from CV PDF. + * @param {string} projectDescription - Plain text of the project description. + * @returns {Promise<{matchPercentage: number, pros: string[], cons: string[]}>} + */ +export const evaluateMatch = async (cvText, projectDescription) => { + if (!cvText || !projectDescription) { + return { + matchPercentage: 0, + pros: ['Fehlende CV-Daten oder Projektbeschreibung für den Abgleich.'], + cons: [] + }; + } + + // Fetch API key from DB, fallback to environment variable + const apiKey = await Setting.getVal('openai_api_key') || process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OpenAI API Key is not configured. Please set it in Settings.'); + } + + const openai = new OpenAI({ apiKey }); + + const systemPrompt = `Du bist ein hochentwickeltes IT-Recruiting-Matching-System. +Deine Aufgabe ist es, den Lebenslauf (CV) eines IT-Beraters mit einer Projektbeschreibung abzugleichen. +Berechne eine realistische Übereinstimmungsquote (Trefferquote) in Prozent (Zahl von 0 bis 100). +Analysiere Gemeinsamkeiten und Lücken. + +Erstelle eine Liste von Stichpunkten für: +1. Pros: Warum der Berater für das Projekt geeignet ist (Erfahrung, Kernkompetenzen, passende Programmiersprachen/Frameworks). +2. Cons: Was gegen eine Eignung spricht oder welche Risiken/Lücken es gibt (fehlende Technologien, Sprachbarrieren, Reisebereitschaft, zu geringe Seniorität). + +Antworte ausschließlich im JSON-Format mit folgendem Schema: +{ + "matchPercentage": 75, + "pros": [ + "Sehr gute Übereinstimmung bei React und Node.js-Kenntnissen.", + "Erfahrung im geforderten Finanzsektor vorhanden." + ], + "cons": [ + "Keine Erwähnung der geforderten Docker/Kubernetes-Erfahrung im CV.", + "Projektsprache ist Deutsch (fließend), der Berater listet Deutsch nur auf B2-Niveau." + ] +} +Schreibe keinen zusätzlichen Text vor oder nach dem JSON-Objekt.`; + + const userContent = `### LEBENSLAUF (CV): +${cvText} + +### PROJEKTBESCHREIBUNG: +${projectDescription}`; + + try { + const response = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userContent } + ], + response_format: { type: 'json_object' }, + temperature: 0.1 // Low temperature for more deterministic analysis + }); + + const rawContent = response.choices[0].message.content.trim(); + const result = JSON.parse(rawContent); + + return { + matchPercentage: Number(result.matchPercentage) || 0, + pros: Array.isArray(result.pros) ? result.pros : [], + cons: Array.isArray(result.cons) ? result.cons : [] + }; + } catch (error) { + console.error('Error during OpenAI project matching:', error); + throw new Error('LLM Evaluation failed: ' + error.message); + } +}; diff --git a/server/src/scanner/services/pdfParser.js b/server/src/scanner/services/pdfParser.js new file mode 100644 index 0000000..b68dc62 --- /dev/null +++ b/server/src/scanner/services/pdfParser.js @@ -0,0 +1,22 @@ +import pdf from 'pdf-parse'; + +/** + * Extracts plain text from a CV PDF file buffer. + * @param {Buffer} fileBuffer - The PDF file content as a buffer. + * @returns {Promise} - The extracted plain text. + */ +export const parsePdfCv = async (fileBuffer) => { + try { + const data = await pdf(fileBuffer); + + // Clean up the text: remove double newlines and trim whitespace + let text = data.text; + text = text.replace(/\n\s*\n/g, '\n'); + text = text.trim(); + + return text; + } catch (error) { + console.error('Error parsing PDF CV:', error); + throw new Error('Failed to parse PDF: ' + error.message); + } +}; diff --git a/server/src/scanner/services/pipeline.js b/server/src/scanner/services/pipeline.js new file mode 100644 index 0000000..14fb290 --- /dev/null +++ b/server/src/scanner/services/pipeline.js @@ -0,0 +1,286 @@ +import { Agent, Portal, Project, Match, Setting } from '../models/index.js'; +import { scrapeFreelancermap, scrapeFreelanceDe, scrapeHays, scrapeEtengo, scrapeSThreePortal, SThreePortals } from './scraper.js'; +import { evaluateMatch } from './matchingEngine.js'; +import nodemailer from 'nodemailer'; + +// Helper to encrypt/decrypt credentials if needed, or send email notifications +const sendEmailNotification = async (agent, project, matchPercentage, pros, cons) => { + const active = await Setting.getVal('email_notifications_active') === 'true'; + if (!active) return; + + const smtpHost = await Setting.getVal('smtp_host'); + const smtpPort = parseInt(await Setting.getVal('smtp_port', '587'), 10); + const smtpUser = await Setting.getVal('smtp_user'); + const smtpPass = await Setting.getVal('smtp_pass'); + const smtpFrom = await Setting.getVal('smtp_from', 'scanner@perinco.de'); + const recipient = await Setting.getVal('notification_email_recipient') || agent.email; + + if (!smtpHost || !smtpUser || !smtpPass || !recipient) { + console.warn('SMTP settings are incomplete. Skipping email notification.'); + return; + } + + const transporter = nodemailer.createTransport({ + host: smtpHost, + port: smtpPort, + secure: smtpPort === 465, + auth: { + user: smtpUser, + pass: smtpPass + } + }); + + const prosList = pros.map(p => `
  • ✅ ${p}
  • `).join(''); + const consList = cons.map(c => `
  • ❌ ${c}
  • `).join(''); + + const mailOptions = { + from: smtpFrom, + to: recipient, + subject: `[PROJEKT-MATCHING ${matchPercentage}%] Neue Chance für ${agent.name}`, + html: ` +
    +

    Neues Projekt-Match gefunden

    +

    Hallo,

    +

    es gibt ein neues passendes IT-Projekt auf ${project.portal} für ${agent.name}.

    + +
    + +
    +

    Eignung: ${matchPercentage}% Match

    + +

    Vorteile (Warum es passt):

    +
      ${prosList || '
    • Keine Angaben
    • '}
    + +

    Herausforderungen (Gaps):

    +
      ${consList || '
    • Keine Angaben
    • '}
    +
    + +

    Diese E-Mail wurde automatisch vom Perinco Projekt Scanner generiert.

    +
    + ` + }; + + try { + await transporter.sendMail(mailOptions); + console.log(`Notification email sent for Agent ${agent.name} and Project ${project.title}`); + } catch (error) { + console.error('Failed to send email notification:', error); + } +}; + +/* + * Ein Lauf zur Zeit. Scheduler und der Knopf "Jetzt scannen" koennen sich sonst + * ueberholen - zwei parallele Laeufe starten doppelt Chromium und bezahlen jede + * Bewertung zweimal bei OpenAI. + */ +let pipelineRunning = false; + +export const isPipelineRunning = () => pipelineRunning; + +/** + * Scrapen, Speichern und Bewerten in einem Durchlauf. + * @returns {Promise} false, wenn schon ein Lauf aktiv war. + */ +export const runMatchingPipeline = async () => { + if (pipelineRunning) { + console.log('[Pipeline] Lauf laeuft schon - neuer Aufruf uebersprungen.'); + return false; + } + pipelineRunning = true; + try { + return await executePipeline(); + } finally { + pipelineRunning = false; + } +}; + +const executePipeline = async () => { + console.log('--- STARTING MATCHING PIPELINE ---'); + + // 1. Fetch active agents + const activeAgents = await Agent.findAll({ where: { isActive: true } }); + if (activeAgents.length === 0) { + console.log('No active agents found. Pipeline execution stopped.'); + return true; + } + + // 2. Extract unique keywords from active agents + const keywordSet = new Set(); + activeAgents.forEach(agent => { + if (agent.keywords) { + agent.keywords.split(',').forEach(k => { + const trimmed = k.trim(); + if (trimmed) keywordSet.add(trimmed); + }); + } + }); + + const keywords = Array.from(keywordSet); + if (keywords.length === 0) { + console.log('No active search keywords found. Pipeline execution stopped.'); + return true; + } + + console.log(`Keywords to scrape: ${keywords.join(', ')}`); + + // 3. Fetch portal configurations + const freelancermapPortal = await Portal.findOne({ where: { name: 'freelancermap.de' } }); + const freelanceDePortal = await Portal.findOne({ where: { name: 'freelance.de' } }); + + // 4. Run scrapers + let scrapedProjects = []; + + try { + const mapResult = await scrapeFreelancermap(keywords, freelancermapPortal?.sessionState); + scrapedProjects = scrapedProjects.concat(mapResult.projects); + } catch (err) { + console.error('Error scraping Freelancermap:', err); + } + + try { + const freelanceResult = await scrapeFreelanceDe(keywords, freelanceDePortal?.sessionState); + scrapedProjects = scrapedProjects.concat(freelanceResult.projects); + } catch (err) { + console.error('Error scraping Freelance.de:', err); + } + + try { + const haysResult = await scrapeHays(keywords); + scrapedProjects = scrapedProjects.concat(haysResult.projects); + await Portal.update( + { connectionStatus: 'connected', lastLoginAttempt: new Date() }, + { where: { name: 'hays.de' } } + ); + } catch (err) { + console.error('Error scraping Hays.de:', err); + await Portal.update({ connectionStatus: 'error' }, { where: { name: 'hays.de' } }); + } + + try { + const etengoResult = await scrapeEtengo(keywords); + scrapedProjects = scrapedProjects.concat(etengoResult.projects); + await Portal.update( + { connectionStatus: 'connected', lastLoginAttempt: new Date() }, + { where: { name: 'etengo.de' } } + ); + } catch (err) { + console.error('Error scraping Etengo.de:', err); + await Portal.update({ connectionStatus: 'error' }, { where: { name: 'etengo.de' } }); + } + + // SThree-family staffing portals (shared template — generic scraper). + for (const cfg of SThreePortals) { + try { + const result = await scrapeSThreePortal(cfg); + scrapedProjects = scrapedProjects.concat(result.projects); + await Portal.update( + { connectionStatus: 'connected', lastLoginAttempt: new Date() }, + { where: { name: cfg.name } } + ); + } catch (err) { + console.error(`Error scraping ${cfg.name}:`, err); + await Portal.update({ connectionStatus: 'error' }, { where: { name: cfg.name } }); + } + } + + console.log(`Total projects scraped: ${scrapedProjects.length}`); + + // 5. Store projects and run matching analysis + let newProjectsStoredCount = 0; + let matchesEvaluatedCount = 0; + + const thresholdStr = await Setting.getVal('match_threshold', '70'); + const matchThreshold = parseInt(thresholdStr, 10) || 70; + + for (const scrapedProj of scrapedProjects) { + try { + // Find or create project in DB + const [project, created] = await Project.findOrCreate({ + where: { + portal: scrapedProj.portal, + portalProjectId: scrapedProj.portalProjectId + }, + defaults: scrapedProj + }); + + if (created) { + newProjectsStoredCount++; + } + + // Perform matching analysis with active agents + for (const agent of activeAgents) { + // Skip if match already exists + const existingMatch = await Match.findOne({ + where: { + agentId: agent.id, + projectId: project.id + } + }); + + if (existingMatch) continue; + + // Exclusion list: skip this project for the agent if its title or + // description contains any excluded term (case-insensitive). Runs + // before the OpenAI call so excluded projects cost nothing. + if (agent.excludeKeywords) { + const haystack = `${project.title || ''} ${project.description || ''}`.toLowerCase(); + const excluded = agent.excludeKeywords + .split(',') + .map(t => t.trim().toLowerCase()) + .filter(Boolean) + .find(term => haystack.includes(term)); + if (excluded) { + console.log(`Skipping project [${project.title}] for Agent [${agent.name}] — matched exclusion term "${excluded}".`); + continue; + } + } + + // Skip matching if CV text is empty + if (!agent.cvText) { + console.log(`Skipping match for Agent ${agent.name} (ID: ${agent.id}) - No CV text uploaded.`); + continue; + } + + console.log(`Evaluating match: Agent [${agent.name}] <-> Project [${project.title}]`); + + try { + const matchResult = await evaluateMatch(agent.cvText, project.description); + matchesEvaluatedCount++; + + const match = await Match.create({ + agentId: agent.id, + projectId: project.id, + matchPercentage: matchResult.matchPercentage, + pros: matchResult.pros, + cons: matchResult.cons, + status: 'new', + notificationSent: false + }); + + // Trigger email notification if threshold reached + if (match.matchPercentage >= matchThreshold) { + await sendEmailNotification(agent, project, match.matchPercentage, match.pros, match.cons); + match.notificationSent = true; + await match.save(); + } + } catch (matchErr) { + console.error(`Failed to evaluate match for Agent ${agent.id} and Project ${project.id}:`, matchErr.message); + } + } + + } catch (dbErr) { + console.error('Error inserting project or creating match:', dbErr.message); + } + } + + console.log(`--- PIPELINE COMPLETED ---`); + console.log(`Stored ${newProjectsStoredCount} new projects.`); + console.log(`Evaluated ${matchesEvaluatedCount} new matches.`); + return true; +}; diff --git a/server/src/scanner/services/scheduler.js b/server/src/scanner/services/scheduler.js new file mode 100644 index 0000000..ef6947f --- /dev/null +++ b/server/src/scanner/services/scheduler.js @@ -0,0 +1,125 @@ +import cron from 'node-cron'; +import Setting from '../models/Setting.js'; +import { runMatchingPipeline } from './pipeline.js'; +import { cleanupOldProjects } from './cleanup.js'; + +let currentJob = null; +let cleanupJob = null; +let currentIntervalMinutes = null; + +/** + * Startet den Hintergrund-Crawler und plant sich bei geaenderten Intervallen + * selbst neu. + */ +export const startScheduler = async () => { + try { + const intervalStr = await Setting.getVal('search_interval', '60'); + const intervalMinutes = parseInt(intervalStr, 10) || 60; + + currentIntervalMinutes = intervalMinutes; + console.log('[Scheduler] Cron-Scheduler wird eingerichtet.'); + console.log(`[Scheduler] Suchintervall: ${intervalMinutes} Minuten.`); + + // If a job is already running, stop it + if (currentJob) { + currentJob.stop(); + console.log('[Scheduler] Vorherigen Cron-Job gestoppt.'); + } + + /* + * Zeitversatz zum parallel laufenden alten Scanner. Ohne ihn starten beide + * Instanzen direkt nach einem Neustart gleichzeitig und greifen mit + * denselben Portal-Sessions zeitgleich auf die Boersen zu - das provoziert + * Rate-Limits und CAPTCHAs. Mit Versatz laufen die Laeufe verschraenkt. + */ + const offsetMinutes = Number(process.env.SCANNER_SCHEDULER_OFFSET ?? 30); + const useOffset = Number.isFinite(offsetMinutes) && offsetMinutes > 0; + + /* + * Eigene Sperrfrist statt eines vorgealterten lastRunTime: ein Zeitstempel + * kann einen Lauf nur vorziehen, nie verzoegern. Sobald der Versatz groesser + * als das Suchintervall ist - bei 15 Minuten Intervall der Normalfall - + * waere der erste Lauf sonst sofort faellig gewesen. + */ + const notBefore = useOffset ? Date.now() + offsetMinutes * 60 * 1000 : 0; + let lastRunTime = null; + + if (useOffset) { + console.log(`[Scheduler] Erster Lauf in ${offsetMinutes} Minuten (Versatz zum alten Scanner).`); + } + + // We check every minute if we are due for a run. + // This allows exact dynamic changes without having to manually restart the node process. + currentJob = cron.schedule('* * * * *', async () => { + // Master on/off switch (toggled from Settings UI). When off, the + // background scanner stays idle but the backend/UI keep running. + const scanningActive = await Setting.getVal('scanning_active', 'true'); + if (scanningActive === 'false') { + return; + } + + const activeIntervalStr = await Setting.getVal('search_interval', '60'); + const activeIntervalMinutes = parseInt(activeIntervalStr, 10) || 60; + + // Geaendertes Intervall im Betrieb uebernehmen + if (activeIntervalMinutes !== currentIntervalMinutes) { + console.log(`[Scheduler] Intervall von ${currentIntervalMinutes} auf ${activeIntervalMinutes} Minuten geaendert - Neuplanung.`); + startScheduler(); + return; + } + + const now = new Date(); + + // Sperrfrist nach dem Start (Versatz zum parallel laufenden Scanner) + if (notBefore && now.getTime() < notBefore) { + return; + } + + // Weekday gate: skip Saturday (6) and Sunday (0). Scraper runs Mon-Fri only. + const day = now.getDay(); + if (day === 0 || day === 6) { + return; + } + + // Time-of-day gate: only scan between 07:00 and 21:59 server-local time + // (Europe/Berlin). getHours() returns local hour on the host. + const hour = now.getHours(); + if (hour < 7 || hour >= 22) { + return; + } + + // Run if never run before, or if enough time has passed + const isDue = !lastRunTime || (now - lastRunTime) >= activeIntervalMinutes * 60 * 1000; + + if (isDue) { + lastRunTime = now; + console.log(`[Scheduler] Geplanter Lauf startet: ${now.toLocaleTimeString('de-DE')}`); + try { + await runMatchingPipeline(); + } catch (err) { + console.error('[Scheduler] Fehler im Suchlauf:', err); + } + } + }); + + currentJob.start(); + console.log('[Scheduler] Cron-Job laeuft, Pruefung jede Minute.'); + + // Taegliches Aufraeumen um 03:30 - halbe Stunde nach dem alten Scanner, + // damit nicht beide gleichzeitig grosse Loeschlaeufe auf derselben MariaDB + // fahren. Laeuft nur einmal, auch wenn der Scheduler neu geplant wird. + if (!cleanupJob) { + cleanupJob = cron.schedule('30 3 * * *', () => { + console.log('[Scheduler] Taegliches Aufraeumen laeuft.'); + cleanupOldProjects().catch(err => console.error('[Scheduler] Fehler beim Aufraeumen:', err)); + }); + cleanupJob.start(); + console.log('[Scheduler] Aufraeum-Job registriert (03:30).'); + + // Also run once on boot so retention applies immediately after restart. + cleanupOldProjects().catch(() => {}); + } + } catch (error) { + console.error('[Scheduler] Failed to initialize scheduler:', error); + } +}; diff --git a/server/src/scanner/services/scraper.js b/server/src/scanner/services/scraper.js new file mode 100644 index 0000000..c7f9975 --- /dev/null +++ b/server/src/scanner/services/scraper.js @@ -0,0 +1,720 @@ +import { chromium } from 'playwright'; +import Portal from '../models/Portal.js'; + +// Stealth-like launch args — no need for playwright-extra/puppeteer-extra-plugin-stealth +// We pass anti-detection flags and a realistic user agent directly. +const STEALTH_ARGS = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-blink-features=AutomationControlled', + '--disable-infobars', +]; + +// Defensive parse: storage state stored in DB may originate from older sync +// versions or from Cookie-Editor exports with non-Playwright cookie shapes. +// Normalize before passing to browser.newContext(). +const parseStorageState = (sessionState) => { + if (!sessionState) return null; + let raw; + try { + raw = typeof sessionState === 'string' ? JSON.parse(sessionState) : sessionState; + } catch { + return null; + } + + let rawCookies = []; + let origins = []; + if (Array.isArray(raw)) { + rawCookies = raw; + } else if (raw && typeof raw === 'object') { + rawCookies = Array.isArray(raw.cookies) ? raw.cookies : []; + origins = Array.isArray(raw.origins) ? raw.origins : []; + } + + const cookies = rawCookies.map(c => { + if (!c || !c.name || c.value === undefined) return null; + let sameSite = c.sameSite; + if (typeof sameSite === 'string') { + const s = sameSite.toLowerCase(); + if (s === 'strict') sameSite = 'Strict'; + else if (s === 'lax') sameSite = 'Lax'; + else if (s === 'none' || s === 'no_restriction') sameSite = 'None'; + else sameSite = 'Lax'; + } else { + sameSite = 'Lax'; + } + let expires = c.expires; + if (expires === undefined && c.expirationDate !== undefined) expires = c.expirationDate; + if (typeof expires !== 'number' || !isFinite(expires)) expires = -1; + return { + name: String(c.name), + value: String(c.value), + domain: c.domain || '', + path: c.path || '/', + expires, + httpOnly: !!c.httpOnly, + secure: !!c.secure, + sameSite, + }; + }).filter(Boolean); + + if (cookies.length === 0) return null; + return { cookies, origins }; +}; + +/** + * Scrapes projects from Freelancermap.de based on keywords. + * @param {string[]} keywords - List of search terms. + * @param {string|null} sessionState - JSON string of cookies & storage. + * @returns {Promise<{projects: Object[], sessionValid: boolean}>} + */ +export const scrapeFreelancermap = async (keywords, sessionState = null) => { + console.log(`Starting Freelancermap scrape for keywords: ${keywords.join(', ')}`); + const projectsList = []; + let sessionValid = false; + + const browser = await chromium.launch({ + headless: true, + args: STEALTH_ARGS + }); + + try { + // Initialize context with cookies if available + let contextOptions = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 } + }; + + if (sessionState) { + const parsed = parseStorageState(sessionState); + if (parsed) { + contextOptions.storageState = parsed; + console.log(`Loaded Freelancermap session: ${parsed.cookies.length} cookies.`); + } else { + console.warn('Freelancermap session state empty or invalid after normalize.'); + } + } + + const context = await browser.newContext(contextOptions); + const page = await context.newPage(); + + // Anti-detection: override navigator.webdriver + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + // Check if session is valid by visiting dashboard/profile page + if (sessionState) { + try { + await page.goto('https://www.freelancermap.de/dashboard.html', { waitUntil: 'domcontentloaded', timeout: 15000 }); + const loginButton = await page.$('a[href*="/login"]'); + const userProfile = await page.$('.user-avatar, .user-name, a[href*="/logout"]'); + + if (userProfile && !loginButton) { + sessionValid = true; + console.log('Freelancermap session is valid (Logged In).'); + } else { + console.log('Freelancermap session has expired.'); + } + } catch (err) { + console.warn('Error checking Freelancermap session validity:', err.message); + } + } + + // Loop through each keyword to search + for (const keyword of keywords) { + const searchUrl = `https://www.freelancermap.de/projekte?q=${encodeURIComponent(keyword)}&sort=created_desc`; + console.log(`Navigating to Freelancermap search: ${searchUrl}`); + + await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1000 + Math.random() * 2000); + + // Freelancermap project cards use stable class `.project-card`. Detail + // attributes use data-testid which is reliable across rerenders. + const projectElements = await page.$$('.project-card'); + console.log(`Found ${projectElements.length} project cards for keyword: ${keyword}`); + + for (const el of projectElements) { + try { + const linkEl = await el.$('a[data-testid="title"], a[data-id="project-card-title"]'); + if (!linkEl) continue; + + const title = (await linkEl.innerText()).trim(); + const rawUrl = await linkEl.getAttribute('href'); + if (!title || !rawUrl) continue; + + const url = rawUrl.startsWith('http') ? rawUrl : `https://www.freelancermap.de${rawUrl}`; + + // Project ID: trailing numeric segment if present, else full slug. + const slugMatch = url.match(/\/projekt\/([^/?#]+)/); + const slug = slugMatch ? slugMatch[1] : null; + const idMatch = slug ? slug.match(/-(\d+)$/) : null; + const portalProjectId = idMatch ? idMatch[1] : (slug || url); + + if (projectsList.some(p => p.portalProjectId === portalProjectId)) continue; + + // Company: first div in .project-info (no class on text node) + const company = await el.evaluate(node => { + const info = node.querySelector('.project-info'); + if (!info) return ''; + const first = info.querySelector('div'); + return first ? first.innerText.trim() : ''; + }); + + // Location: data-testid="city" container + const location = await el.evaluate(node => { + const c = node.querySelector('[data-testid="city"]'); + return c ? c.innerText.replace(/\s+/g, ' ').trim() : ''; + }); + + // Type / duration / start → concatenated into rate as listing card has no € + const meta = await el.evaluate(node => { + const parts = []; + ['type', 'duration', 'beginningText', 'remoteInPercent'].forEach(t => { + const e2 = node.querySelector(`[data-testid="${t}"]`); + if (e2) parts.push(e2.innerText.replace(/\s+/g, ' ').trim()); + }); + return parts.join(' | '); + }); + + // Listing card has no description text — fetch detail page lazily would + // be expensive; leave description empty here. Matching engine handles + // empty descriptions by returning low score, but title + meta still + // give signal once we add a detail fetch step later. + const description = `${title}\n${meta}`.trim(); + + projectsList.push({ + portal: 'freelancermap.de', + portalProjectId, + title, + description, + company: company || 'N/A', + location: location || 'Remote / N/A', + rate: meta || 'N/A', + url, + publishedAt: new Date() + }); + } catch (err) { + console.error('Error parsing Freelancermap card:', err.message); + } + } + } + + // Save updated storage state if session was valid (cookies might rotate) + if (sessionValid) { + const updatedState = await context.storageState(); + sessionValid = true; + await Portal.update( + { sessionState: JSON.stringify(updatedState), connectionStatus: 'connected' }, + { where: { name: 'freelancermap.de' } } + ); + } else if (sessionState) { + // Session was provided but check failed + await Portal.update( + { connectionStatus: 'expired' }, + { where: { name: 'freelancermap.de' } } + ); + } + + } catch (error) { + console.error('Error during Freelancermap scrape:', error); + } finally { + await browser.close(); + } + + return { projects: projectsList, sessionValid }; +}; + +/** + * Scrapes job listings from Hays.de. Public portal, no login required. + * Search URL: https://www.hays.de/jobsuche/stellenangebote-jobs?page=1&mrew=1&joblevel=3&q=&e=false + * joblevel=3 (Senior/Lead-Bereich, vom Nutzer vorgegeben), mrew=1, e=false (alle Vertragsarten). + * @param {string[]} keywords + * @returns {Promise<{projects: Object[], sessionValid: boolean}>} + */ +export const scrapeHays = async (keywords) => { + console.log(`Starting Hays.de scrape for keywords: ${keywords.join(', ')}`); + const projectsList = []; + + const browser = await chromium.launch({ headless: true, args: STEALTH_ARGS }); + + try { + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 } + }); + const page = await context.newPage(); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + for (const keyword of keywords) { + const searchUrl = `https://www.hays.de/jobsuche/stellenangebote-jobs?page=1&mrew=1&joblevel=3&q=${encodeURIComponent(keyword)}&e=false`; + console.log(`Navigating to Hays search: ${searchUrl}`); + + try { + await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + // Wait for actual job-detail anchors (JS hydrates them). + try { + await page.waitForSelector('a[href*="/jobsuche/stellenangebote-jobs-detail-"]', { timeout: 12000 }); + } catch { + console.warn(`Hays: no job-detail anchors visible for "${keyword}"`); + continue; + } + await page.waitForTimeout(800 + Math.random() * 1000); + + // Only real job-detail URLs. Pattern: /jobsuche/stellenangebote-jobs-detail--/ + const linkEls = await page.$$('a[href*="/jobsuche/stellenangebote-jobs-detail-"]'); + console.log(`Found ${linkEls.length} Hays job-detail anchors for keyword: ${keyword}`); + + for (const linkEl of linkEls) { + try { + const rawUrl = await linkEl.getAttribute('href'); + if (!rawUrl) continue; + const url = rawUrl.startsWith('http') ? rawUrl : `https://www.hays.de${rawUrl}`; + + // Hays job-id: numeric segment at end before trailing /N + const idMatch = url.match(/-(\d{5,})(?:\/\d+)?(?:[?#].*)?$/); + if (!idMatch) continue; + const portalProjectId = idMatch[1]; + + if (projectsList.some(p => p.portalProjectId === portalProjectId)) continue; + + // Title: link inner text first, fall back to nearest heading. + let title = ((await linkEl.innerText()) || '').replace(/\s+/g, ' ').trim(); + if (!title) { + title = await linkEl.evaluate(node => { + let n = node; + for (let i = 0; i < 6 && n; i++) { + n = n.parentElement; + if (!n) break; + const h = n.querySelector('h2, h3, [class*="title"], [class*="Title"]'); + if (h && h.innerText) return h.innerText.replace(/\s+/g, ' ').trim(); + } + return ''; + }).catch(() => ''); + } + // Derive title from URL slug if still missing. + if (!title) { + const slugMatch = url.match(/stellenangebote-jobs-detail-(.+?)-\d{5,}/); + if (slugMatch) { + title = slugMatch[1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + } + if (!title) continue; + + // Card text: closest ancestor that contains structured info (max 6 levels up). + const meta = await linkEl.evaluate(node => { + let card = node; + for (let i = 0; i < 6; i++) { + if (!card.parentElement) break; + card = card.parentElement; + const txt = (card.innerText || '').replace(/\s+/g, ' ').trim(); + if (txt.length > 80 && txt.length < 1500) return txt.slice(0, 1200); + } + return (node.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 600); + }).catch(() => ''); + + // Location: try common DE cities or fall back to scanning "Standort:" pattern. + const locMatch = meta.match(/Standort[:\s]+([^|·\n]{2,40})/i) + || meta.match(/\b(Berlin|Hamburg|München|Köln|Frankfurt|Stuttgart|Düsseldorf|Leipzig|Dortmund|Essen|Bremen|Dresden|Hannover|Nürnberg|Mannheim|Karlsruhe|Wiesbaden|Bonn|Münster|Augsburg|Aachen|Erfurt|Mainz|Kiel|Halle|Magdeburg|Freiburg|Lübeck|Oberhausen|Rostock|Kassel|Saarbrücken|Mönchengladbach|Krefeld|Chemnitz|Braunschweig|Wuppertal|Bielefeld|Duisburg|remote|Remote)\b/); + const location = locMatch ? locMatch[1] || locMatch[0] : 'Remote / N/A'; + + projectsList.push({ + portal: 'hays.de', + portalProjectId, + title: title.slice(0, 250), + description: `${title}\n${meta}`.trim().slice(0, 1500), + company: 'Hays (Vermittlung)', + location: location.slice(0, 200), + rate: (meta.match(/(?:€|EUR)[^|·]{0,40}/) || ['N/A'])[0].slice(0, 90), + url, + publishedAt: new Date() + }); + } catch (err) { + console.error('Error parsing Hays card:', err.message); + } + } + } catch (err) { + console.error(`Error scraping Hays for "${keyword}":`, err.message); + } + } + } catch (error) { + console.error('Error during Hays scrape:', error); + } finally { + await browser.close(); + } + + return { projects: projectsList, sessionValid: true }; +}; + +/** + * Generic scraper for the SThree-family staffing portals (Huxley, Computer + * Futures, Progressive, Real Staffing, Global Enterprise Partners, Madison + * Black, JP Gray, etc.). They all share the same template — job detail URLs + * follow `//job///`. Public, no login. + * + * @param {object} cfg + * @param {string} cfg.name - DB portal name (e.g. 'huxley.com') + * @param {string} cfg.listingUrl - Search-result listing URL + * @param {string} cfg.localePath - e.g. '/de-de/' or '/en-nl/' + */ +const scrapeSThreeStyle = async ({ name, listingUrl, localePath }) => { + console.log(`Starting ${name} scrape (listing).`); + const projectsList = []; + + const browser = await chromium.launch({ headless: true, args: STEALTH_ARGS }); + + try { + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 } + }); + const page = await context.newPage(); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + try { + await page.goto(listingUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + const jobLinkSelector = `a[href*="${localePath}job/"]`; + try { + await page.waitForSelector(jobLinkSelector, { timeout: 12000 }); + } catch { + console.warn(`${name}: no job anchors visible after wait.`); + } + await page.waitForTimeout(1500); + + const links = await page.$$eval(jobLinkSelector, (els, locale) => { + const seen = new Set(); + const out = []; + // Escape regex special chars in locale (keeps slashes as literal). + const esc = locale.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(esc + 'job/([^/]+)/(\\d{4,})/?'); + for (const a of els) { + const href = a.getAttribute('href') || ''; + if (href.includes('{{')) continue; + const m = href.match(re); + if (!m) continue; + const id = m[2]; + if (seen.has(id)) continue; + seen.add(id); + out.push({ id, slug: m[1], href }); + } + return out; + }, localePath).catch(() => []); + console.log(`Found ${links.length} ${name} job URLs.`); + + // Detail-page fetches were triggering Cloudflare/WAF on the SThree + // family. Title is derived from the URL slug; description = title only. + // No detail requests → no bot signature, no rate spikes. + for (const link of links) { + try { + const url = link.href.startsWith('http') ? link.href : new URL(link.href, listingUrl).toString(); + + if (projectsList.some(p => p.portalProjectId === link.id)) continue; + + let title = ''; + try { + title = decodeURIComponent(link.slug); + } catch { + title = link.slug; + } + title = title.replace(/-/g, ' ').replace(/\s+/g, ' ').trim(); + // Title-case for the slug pieces (German diacritics kept). + title = title.replace(/\b(\w)([\w'’äöüß]*)/g, (_, a, b) => a.toUpperCase() + b); + if (!title) continue; + + projectsList.push({ + portal: name, + portalProjectId: link.id, + title: title.slice(0, 250), + description: title.slice(0, 250), + company: `${name} (Vermittlung)`, + location: 'Siehe Anzeige', + rate: 'N/A', + url, + publishedAt: new Date() + }); + } catch (err) { + console.error(`Error building ${name} project entry:`, err.message); + } + } + } catch (err) { + console.error(`Error scraping ${name} listing:`, err.message); + } + } catch (error) { + console.error(`Error during ${name} scrape:`, error); + } finally { + await browser.close(); + } + + console.log(`${name} scrape collected ${projectsList.length} projects.`); + return { projects: projectsList, sessionValid: true }; +}; + +// Per-portal wrappers used by pipeline.js / api.js +export const SThreePortals = [ + { name: 'huxley.com', listingUrl: 'https://www.huxley.com/de-de/stellensuche/?country=Deutschland&searchRadius=50km', localePath: '/de-de/' }, + { name: 'computerfutures.com', listingUrl: 'https://www.computerfutures.com/de-de/stellensuche/?country=Deutschland&searchRadius=50km', localePath: '/de-de/' }, + { name: 'progressiverecruitment.com', listingUrl: 'https://www.progressiverecruitment.com/de-de/jobs-projekte-suchen/?country=Deutschland&searchRadius=50km', localePath: '/de-de/' }, + { name: 'realstaffing.com', listingUrl: 'https://www.realstaffing.com/de-de/job-suche/?country=Deutschland&searchRadius=50km', localePath: '/de-de/' }, + { name: 'globalenterprisepartners.com', listingUrl: 'https://www.globalenterprisepartners.com/de-de/job-search/?country=Deutschland&searchRadius=50km', localePath: '/de-de/' }, + { name: 'madisonblack.com', listingUrl: 'https://www.madisonblack.com/de-de/job-search/?searchRadius=50km', localePath: '/de-de/' }, + { name: 'jpgray.nl', listingUrl: 'https://www.jpgray.nl/en-nl/job-search/?country=Netherlands&searchRadius=50km', localePath: '/en-nl/' } +]; + +export const scrapeSThreePortal = (cfg) => scrapeSThreeStyle(cfg); + +/** + * Scrapes IT projects from Etengo.de. Public portal, no login required. + * Search URL: https://www.etengo.de/it-projektsuche/?q= + * @param {string[]} keywords + * @returns {Promise<{projects: Object[], sessionValid: boolean}>} + */ +export const scrapeEtengo = async (/* keywords */) => { + // Etengo's search filter is JS-side and the listing URL ignores ?q. Load the + // full project list once and return all entries; matching engine handles + // keyword relevance per agent CV. + console.log(`Starting Etengo.de scrape (full listing).`); + const projectsList = []; + + const browser = await chromium.launch({ headless: true, args: STEALTH_ARGS }); + + try { + const context = await browser.newContext({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 } + }); + const page = await context.newPage(); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + try { + await page.goto('https://www.etengo.de/it-projektsuche/', { waitUntil: 'domcontentloaded', timeout: 30000 }); + try { + await page.waitForSelector('a[href*="/it-projektsuche/"][href*="/"]', { timeout: 10000 }); + } catch { + console.warn('Etengo: project anchors not visible.'); + } + await page.waitForTimeout(1000); + + // Collect detail-page URLs: /it-projektsuche// + const anchors = await page.$$('a[href*="/it-projektsuche/"]'); + const seen = new Set(); + const detailUrls = []; + for (const a of anchors) { + const href = await a.getAttribute('href'); + if (!href) continue; + const m = href.match(/\/it-projektsuche\/(\d{3,})\/?$/); + if (!m) continue; + const url = href.startsWith('http') ? href : `https://www.etengo.de${href}`; + if (seen.has(m[1])) continue; + seen.add(m[1]); + detailUrls.push({ id: m[1], url }); + } + console.log(`Found ${detailUrls.length} Etengo project URLs.`); + + // Fetch each detail page in same context for title + description. + for (const { id, url } of detailUrls) { + try { + const detail = await context.newPage(); + await detail.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }); + await detail.waitForTimeout(500); + + const title = await detail.evaluate(() => { + const h = document.querySelector('h1'); + if (h && h.innerText) return h.innerText.replace(/\s+/g, ' ').trim(); + const t = document.querySelector('title'); + return t ? t.innerText.replace(/ - Etengo.*$/i, '').trim() : ''; + }).catch(() => ''); + + const description = await detail.evaluate(() => { + // Look for main article / project description container. + const cand = document.querySelector('main, article, .entry-content, .project-detail, [class*="detail"]'); + const node = cand || document.body; + return (node.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 1500); + }).catch(() => ''); + + const location = (description.match(/Einsatzort[:\s]+([^|·\n]{2,40})/i) + || description.match(/\b(Berlin|Hamburg|München|Köln|Frankfurt|Stuttgart|Düsseldorf|Leipzig|Hannover|Nürnberg|Bonn|Münster|Karlsruhe|Mannheim|Bremen|Dresden|Erfurt|Mainz|remote|Remote)\b/) || ['Remote / N/A'])[0]; + + const rateMatch = description.match(/(?:€|EUR)[^|·]{0,40}/); + + await detail.close(); + + if (!title) continue; + if (projectsList.some(p => p.portalProjectId === id)) continue; + + projectsList.push({ + portal: 'etengo.de', + portalProjectId: id, + title: title.slice(0, 250), + description: `${title}\n${description}`.trim().slice(0, 1500), + company: 'Etengo (Vermittlung)', + location: String(location).slice(0, 200), + rate: rateMatch ? rateMatch[0].slice(0, 90) : 'N/A', + url, + publishedAt: new Date() + }); + } catch (err) { + console.error(`Error fetching Etengo detail ${url}:`, err.message); + } + } + } catch (err) { + console.error('Error scraping Etengo listing:', err.message); + } + } catch (error) { + console.error('Error during Etengo scrape:', error); + } finally { + await browser.close(); + } + + console.log(`Etengo scrape collected ${projectsList.length} projects.`); + return { projects: projectsList, sessionValid: true }; +}; + +/** + * Scrapes projects from Freelance.de based on keywords. + * @param {string[]} keywords - List of search terms. + * @param {string|null} sessionState - JSON string of cookies & storage. + * @returns {Promise<{projects: Object[], sessionValid: boolean}>} + */ +export const scrapeFreelanceDe = async (keywords, sessionState = null) => { + console.log(`Starting Freelance.de scrape for keywords: ${keywords.join(', ')}`); + const projectsList = []; + let sessionValid = false; + + const browser = await chromium.launch({ + headless: true, + args: STEALTH_ARGS + }); + + try { + let contextOptions = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + viewport: { width: 1280, height: 800 } + }; + + if (sessionState) { + const parsed = parseStorageState(sessionState); + if (parsed) { + contextOptions.storageState = parsed; + console.log(`Loaded Freelance.de session: ${parsed.cookies.length} cookies.`); + } else { + console.warn('Freelance.de session state empty or invalid after normalize.'); + } + } + + const context = await browser.newContext(contextOptions); + const page = await context.newPage(); + + // Anti-detection: override navigator.webdriver + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); + + // Check if session is valid by visiting dashboard/profile page + if (sessionState) { + try { + await page.goto('https://www.freelance.de/users/profile.php', { waitUntil: 'domcontentloaded', timeout: 15000 }); + const loginButton = await page.$('a[href*="login"], a[href*="sign-in"]'); + const userProfile = await page.$('a[href*="logout"], .user-info, .profile-settings'); + + if (userProfile && !loginButton) { + sessionValid = true; + console.log('Freelance.de session is valid (Logged In).'); + } else { + console.log('Freelance.de session has expired.'); + } + } catch (err) { + console.warn('Error checking Freelance.de session validity:', err.message); + } + } + + for (const keyword of keywords) { + // freelance.de search url: search_sort_by=2 means date descending (newest first) + const searchUrl = `https://www.freelance.de/search/project.php?__search_sort_by=2&query=${encodeURIComponent(keyword)}`; + console.log(`Navigating to Freelance.de search: ${searchUrl}`); + + await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(1000 + Math.random() * 2000); + + // Select project list items + const projectElements = await page.$$('.project-list-item, .project-row, .list-item-main, div[class*="project-list-item"]'); + console.log(`Found ${projectElements.length} potential project elements for keyword: ${keyword}`); + + for (const el of projectElements) { + try { + // Find the link to the project detail page + // Format e.g., /Projekte/Projekt-Details-12345.html + const linkEl = await el.$('a[href*="/Projekte/"], a[href*="/Projekt/"]'); + if (!linkEl) continue; + + const title = await linkEl.innerText(); + const rawUrl = await linkEl.getAttribute('href'); + if (!title || !rawUrl) continue; + + const url = rawUrl.startsWith('http') ? rawUrl : `https://www.freelance.de${rawUrl}`; + + // Parse project ID from URL + // Format e.g., /Projekte/123456-React-Entwickler or /Projekt/123456 + const matchId = url.match(/\/(\d+)-/) || url.match(/\/(\d+)\.html/) || url.match(/\/(\d+)(?:\/|\?|$)/); + const portalProjectId = matchId ? matchId[1] : url; + + if (projectsList.some(p => p.portalProjectId === portalProjectId)) continue; + + // Scrape details from listing card + const descEl = await el.$('.project-description, .description, p, .project-details'); + const description = descEl ? await descEl.innerText() : ''; + + const locEl = await el.$('.location, .project-location, span[title*="Ort"], .meta-location'); + const location = locEl ? await locEl.innerText() : 'Remote / N/A'; + + const rateEl = await el.$('.rate, .project-rate, .meta-rate, span[title*="Budget"]'); + const rate = rateEl ? await rateEl.innerText() : 'N/A'; + + const compEl = await el.$('.company, .project-company, .meta-company'); + const company = compEl ? await compEl.innerText() : 'N/A'; + + projectsList.push({ + portal: 'freelance.de', + portalProjectId, + title: title.trim(), + description: description.trim(), + company: company.trim(), + location: location.trim(), + rate: rate.trim(), + url, + publishedAt: new Date() + }); + } catch (err) { + console.error('Error parsing individual Freelance.de element:', err.message); + } + } + } + + // Save updated storage state if session was valid + if (sessionValid) { + const updatedState = await context.storageState(); + sessionValid = true; + await Portal.update( + { sessionState: JSON.stringify(updatedState), connectionStatus: 'connected' }, + { where: { name: 'freelance.de' } } + ); + } else if (sessionState) { + await Portal.update( + { connectionStatus: 'expired' }, + { where: { name: 'freelance.de' } } + ); + } + + } catch (error) { + console.error('Error during Freelance.de scrape:', error); + } finally { + await browser.close(); + } + + return { projects: projectsList, sessionValid }; +}; diff --git a/server/src/scanner/uploadDir.js b/server/src/scanner/uploadDir.js new file mode 100644 index 0000000..e0c60f2 --- /dev/null +++ b/server/src/scanner/uploadDir.js @@ -0,0 +1,29 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/* + * Verzeichnis der CV-PDFs dieser Scanner-Instanz. Absolut aufgeloest, damit es + * nicht am Arbeitsverzeichnis des Prozesses haengt. + * + * Bewusst ein EIGENES Verzeichnis und nicht das der alten App: beide Instanzen + * verwalten ihre Agenten getrennt, und ein Loeschen hier darf dort keine Datei + * mitreissen. Bestehende CVs holt `npm run scanner:import` herueber. + */ +export const UPLOAD_DIR = path.resolve( + process.env.SCANNER_UPLOAD_DIR || path.join(process.cwd(), 'uploads') +); + +export function ensureUploadDir() { + if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + } + return UPLOAD_DIR; +} + +/** Loest einen in der DB gespeicherten CV-Pfad gegen UPLOAD_DIR auf. */ +export function resolveCvPath(stored) { + if (!stored) return null; + if (path.isAbsolute(stored) && fs.existsSync(stored)) return stored; + const candidate = path.join(UPLOAD_DIR, path.basename(stored)); + return fs.existsSync(candidate) ? candidate : null; +} diff --git a/server/src/scanner/utils/crypto.js b/server/src/scanner/utils/crypto.js new file mode 100644 index 0000000..3fc2816 --- /dev/null +++ b/server/src/scanner/utils/crypto.js @@ -0,0 +1,48 @@ +import CryptoJS from 'crypto-js'; + +/* + * Eigener Schluessel, unabhaengig vom alten Scanner: die Portal-Passwoerter + * liegen in einer eigenen Datenbank und werden hier neu verschluesselt. Der + * Schluessel der alten App wird absichtlich NICHT als Fallback gelesen - beide + * Instanzen sollen sich nicht ueber gemeinsame Geheimnisse verkoppeln. + * + * Ohne SCANNER_ENCRYPTION_KEY laeuft alles weiter, aber mit einem bekannten + * Ersatzschluessel - das ist nur fuer die lokale Entwicklung gedacht, deshalb + * die Warnung. + */ +const FALLBACK_KEY = 'perinco-scanner-dev-key-nicht-fuer-produktion'; + +export const SCANNER_KEY_CONFIGURED = Boolean(process.env.SCANNER_ENCRYPTION_KEY); + +const SECRET_KEY = process.env.SCANNER_ENCRYPTION_KEY || FALLBACK_KEY; + +/** Verschluesselt ein Klartext-Passwort. */ +export const encrypt = (text) => { + if (!text) return ''; + return CryptoJS.AES.encrypt(text, SECRET_KEY).toString(); +}; + +/** Entschluesselt einen Cipher-String; leerer String, wenn es nicht passt. */ +export const decrypt = (cipherText) => { + if (!cipherText) return ''; + try { + const bytes = CryptoJS.AES.decrypt(cipherText, SECRET_KEY); + return bytes.toString(CryptoJS.enc.Utf8); + } catch { + return ''; + } +}; + +/** + * Entschluesselt mit einem fremden Schluessel - gebraucht beim Uebernehmen der + * Portal-Zugangsdaten aus der alten Scanner-Datenbank. + */ +export const decryptWith = (cipherText, key) => { + if (!cipherText || !key) return ''; + try { + const bytes = CryptoJS.AES.decrypt(cipherText, key); + return bytes.toString(CryptoJS.enc.Utf8); + } catch { + return ''; + } +}; diff --git a/server/src/schema.sql b/server/src/schema.sql new file mode 100644 index 0000000..ab0f6d1 --- /dev/null +++ b/server/src/schema.sql @@ -0,0 +1,153 @@ +-- perinco CRM - Schema fuer MariaDB-Datenbank perinco_CRM +-- Idempotent: kann jederzeit erneut ausgefuehrt werden. + +CREATE TABLE IF NOT EXISTS crm_users ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + username VARCHAR(64) NOT NULL, + email VARCHAR(190) NULL, + name VARCHAR(190) NOT NULL, + role ENUM('admin','user') NOT NULL DEFAULT 'user', + password_hash VARCHAR(255) NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_crm_users_username (username), + UNIQUE KEY uq_crm_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Persoenliche Farbe fuer die Kalenderansicht. NULL = automatisch aus der +-- Palette (siehe userColor() im Frontend). ADD COLUMN IF NOT EXISTS haelt das +-- Statement idempotent (MariaDB-Erweiterung). +ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS color VARCHAR(9) NULL AFTER role; + +-- Benutzerfoto als Data-URL oder externe URL - wie bei crm_leads.avatar. +ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color; + +CREATE TABLE IF NOT EXISTS crm_leads ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + salutation VARCHAR(32) NULL, + first_name VARCHAR(120) NOT NULL, + last_name VARCHAR(120) NOT NULL, + company VARCHAR(190) NULL, + position VARCHAR(190) NULL, + email VARCHAR(190) NULL, + phone VARCHAR(64) NULL, + mobile VARCHAR(64) NULL, + linkedin VARCHAR(255) NULL, + website VARCHAR(255) NULL, + street VARCHAR(190) NULL, + zip VARCHAR(24) NULL, + city VARCHAR(120) NULL, + country VARCHAR(120) NULL, + status ENUM('Neu','Kontaktiert','Qualifiziert','Angebot','Gewonnen','Verloren') + NOT NULL DEFAULT 'Neu', + source VARCHAR(120) NULL, + tags LONGTEXT NULL, -- JSON-Array als Text (MariaDB-kompatibel) + notes TEXT NULL, + avatar LONGTEXT NULL, -- Data-URL oder externe URL + owner_id INT UNSIGNED NULL, -- zustaendiger Benutzer + created_by INT UNSIGNED NULL, -- wer den Kontakt angelegt hat + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_crm_leads_name (last_name, first_name), + KEY idx_crm_leads_company (company), + KEY idx_crm_leads_status (status), + KEY idx_crm_leads_owner (owner_id), + KEY idx_crm_leads_creator (created_by), + CONSTRAINT fk_crm_leads_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL, + CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber +-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend). +CREATE TABLE IF NOT EXISTS crm_lead_connections ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + from_lead_id INT UNSIGNED NOT NULL, + to_lead_id INT UNSIGNED NOT NULL, + type ENUM('Kollege','Vorgesetzter','Kunde','Partner','Empfehlung','Netzwerk','Sonstige') + NOT NULL DEFAULT 'Netzwerk', + label VARCHAR(190) NULL, + strength TINYINT UNSIGNED NOT NULL DEFAULT 2, -- 1 = lose, 2 = normal, 3 = eng + created_by INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_crm_conn_pair (from_lead_id, to_lead_id), + KEY idx_crm_conn_to (to_lead_id), + CONSTRAINT fk_crm_conn_from FOREIGN KEY (from_lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE, + CONSTRAINT fk_crm_conn_to FOREIGN KEY (to_lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE, + CONSTRAINT fk_crm_conn_user FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Vertriebschancen. Ansprechpartner haengen als n:m an den Kontakten, damit ein +-- Kontakt in mehreren Opportunities auftauchen kann. +CREATE TABLE IF NOT EXISTS crm_opportunities ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(190) NOT NULL, + company VARCHAR(190) NULL, + description TEXT NULL, + stage ENUM('Neu','Qualifizierung','Angebot','Verhandlung','Gewonnen','Verloren') + NOT NULL DEFAULT 'Neu', + probability TINYINT UNSIGNED NOT NULL DEFAULT 10, -- 0-100 % + daily_rate DECIMAL(10,2) NULL, -- Tagessatz in Euro + duration_days DECIMAL(6,1) NULL, -- Personentage, halbe moeglich + start_date DATE NULL, + end_date DATE NULL, + last_interaction_at DATE NULL, + last_interaction_note VARCHAR(500) NULL, + next_step VARCHAR(500) NULL, + tags LONGTEXT NULL, -- JSON-Array als Text, wie bei crm_leads + notes TEXT NULL, + owner_id INT UNSIGNED NULL, + created_by INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_crm_opp_stage (stage), + KEY idx_crm_opp_owner (owner_id), + KEY idx_crm_opp_interaction (last_interaction_at), + CONSTRAINT fk_crm_opp_owner FOREIGN KEY (owner_id) REFERENCES crm_users (id) ON DELETE SET NULL, + CONSTRAINT fk_crm_opp_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS crm_opportunity_contacts ( + opportunity_id INT UNSIGNED NOT NULL, + lead_id INT UNSIGNED NOT NULL, + role VARCHAR(120) NULL, -- z. B. "Entscheider", "Fachbereich" + PRIMARY KEY (opportunity_id, lead_id), + KEY idx_crm_opp_contacts_lead (lead_id), + CONSTRAINT fk_crm_oc_opp FOREIGN KEY (opportunity_id) REFERENCES crm_opportunities (id) ON DELETE CASCADE, + CONSTRAINT fk_crm_oc_lead FOREIGN KEY (lead_id) REFERENCES crm_leads (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Gemeinsamer Teamkalender: alle Benutzer sehen alle Termine. +-- description darf Mention-Tokens der Form @[Anzeigename](lead:12) bzw. +-- @[Anzeigename](user:3) enthalten - das Frontend rendert sie klickbar. +CREATE TABLE IF NOT EXISTS crm_events ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(190) NOT NULL, + description TEXT NULL, + location VARCHAR(190) NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, + all_day TINYINT(1) NOT NULL DEFAULT 0, + created_by INT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_crm_events_start (starts_at), + KEY idx_crm_events_creator (created_by), + CONSTRAINT fk_crm_events_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Ein Termin kann mehrere Teilnehmer haben; der Primaerschluessel verhindert +-- Doppeleintraege. +CREATE TABLE IF NOT EXISTS crm_event_participants ( + event_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + PRIMARY KEY (event_id, user_id), + KEY idx_crm_event_participants_user (user_id), + CONSTRAINT fk_crm_ep_event FOREIGN KEY (event_id) REFERENCES crm_events (id) ON DELETE CASCADE, + CONSTRAINT fk_crm_ep_user FOREIGN KEY (user_id) REFERENCES crm_users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..9a82183 --- /dev/null +++ b/server/src/server.js @@ -0,0 +1,119 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { initDatabase, pool } from './db.js'; +import authRoutes from './routes/auth.js'; +import userRoutes from './routes/users.js'; +import leadRoutes from './routes/leads.js'; +import connectionRoutes from './routes/connections.js'; +import eventRoutes from './routes/events.js'; +import opportunityRoutes from './routes/opportunities.js'; +import scannerRoutes from './routes/scanner.js'; +import { requireAuth } from './middleware/auth.js'; +import { initScannerDatabase } from './scanner/models/index.js'; +import { startScheduler } from './scanner/services/scheduler.js'; +import { SCANNER_KEY_CONFIGURED } from './scanner/utils/crypto.js'; + +const app = express(); +const PORT = Number(process.env.PORT || 5006); +const HOST = process.env.HOST || '127.0.0.1'; + +// Avatare kommen als Data-URL - deshalb ein groesseres Limit als der Default. +app.use(express.json({ limit: '6mb' })); + +// Im Produktivbetrieb liefert Apache Frontend und API unter derselben Origin +// aus, CORS ist dann irrelevant. Fuer den lokalen Vite-Dev-Server auf einem +// anderen Port braucht es die Freigabe. +app.use( + cors({ + origin: (process.env.CORS_ORIGINS || 'http://localhost:3100,http://127.0.0.1:3100').split(','), + }) +); + +app.get('/api/health', async (req, res) => { + try { + await pool.query('SELECT 1'); + res.json({ ok: true, db: 'up' }); + } catch { + res.status(503).json({ ok: false, db: 'down' }); + } +}); + +app.use('/api/auth', authRoutes); +app.use('/api/users', userRoutes); +app.use('/api/leads', leadRoutes); +app.use('/api/connections', connectionRoutes); +app.use('/api/events', eventRoutes); +app.use('/api/opportunities', opportunityRoutes); + +// Projekt Scanner. Komplett hinter der CRM-Anmeldung - der urspruengliche +// HTTP-Basic-Auth-Zugang des Scanners entfaellt damit. +app.use('/api/scanner', requireAuth, scannerRoutes); + +// Das Diktat laeuft komplett im Browser (Whisper ueber transformers.js) - das +// Backend ist daran nicht beteiligt und sieht keine Audiodaten. + +app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' })); + +// Zentrale Fehlerbehandlung: Details ins Log, generische Meldung nach aussen. +app.use((err, req, res, next) => { + if (res.headersSent) return next(err); + + // Kaputtes JSON o.ae. ist ein Client-Fehler - kein 500, und es muss auch + // nicht als unbehandelter Serverfehler im Log landen. + const status = err?.status || err?.statusCode; + if (err?.type === 'entity.parse.failed' || status === 400) { + return res.status(400).json({ error: 'Ungueltige Anfrage (JSON konnte nicht gelesen werden).' }); + } + if (err?.type === 'entity.too.large' || status === 413) { + return res.status(413).json({ error: 'Die Anfrage ist zu gross (max. 6 MB).' }); + } + + console.error('[perinco CRM] Unbehandelter Fehler:', err); + res.status(500).json({ error: 'Interner Serverfehler.' }); +}); + +try { + await initDatabase(); + + /* + * Der Scanner ist eine vollstaendig eigenstaendige Instanz: eigene Datenbank, + * eigener Verschluesselungsschluessel, eigenes Upload-Verzeichnis, eigener + * Scheduler. Er laeuft damit parallel zur alten App, ohne ihre Daten zu + * beruehren. Fehlt die Datenbank, startet das CRM trotzdem - dann antworten + * nur die Scanner-Endpunkte mit einem Fehler. + */ + let scannerReady = true; + try { + await initScannerDatabase(); + } catch { + scannerReady = false; + console.warn('[Scanner] Bereich ist nicht verfuegbar - CRM startet ohne Scanner.'); + } + + if (scannerReady && !SCANNER_KEY_CONFIGURED) { + console.warn( + '[Scanner] SCANNER_ENCRYPTION_KEY ist nicht gesetzt - Portal-Passwoerter werden mit ' + + 'einem bekannten Ersatzschluessel verschluesselt. Fuer den Produktivbetrieb setzen.' + ); + } + + /* + * Hintergrund-Scheduler (Scraping + OpenAI-Bewertung). Standard: AN, denn der + * Parallelbetrieb soll funktional sein. Beide Scanner bewerten dieselben + * Projekte unabhaengig voneinander - die OpenAI-Kosten fallen also doppelt an, + * solange die alte App laeuft. Zum Abschalten SCANNER_SCHEDULER=false setzen. + */ + if (scannerReady && process.env.SCANNER_SCHEDULER !== 'false') { + await startScheduler(); + } else if (scannerReady) { + console.log('[Scanner] Scheduler deaktiviert (SCANNER_SCHEDULER=false).'); + } + + app.listen(PORT, HOST, () => { + console.log(`[perinco CRM] Backend laeuft auf http://${HOST}:${PORT}`); + }); +} catch (err) { + console.error('[perinco CRM] Start fehlgeschlagen:', err); + process.exit(1); +} diff --git a/server/src/userFields.js b/server/src/userFields.js new file mode 100644 index 0000000..784fdce --- /dev/null +++ b/server/src/userFields.js @@ -0,0 +1,36 @@ +// Validierung der Benutzerfelder, die sowohl die Nutzerverwaltung (Admin) als +// auch "Mein Konto" (jeder fuer sich selbst) setzen darf. Bewusst in einem +// eigenen Modul, damit auth.js und users.js dieselbe Pruefung nutzen, ohne sich +// gegenseitig zu importieren. + +const COLOR_RE = /^#[0-9a-f]{6}$/; + +/** Data-URL eines Bildes oder externe http(s)-URL. */ +const AVATAR_RE = /^(data:image\/(png|jpe?g|gif|webp|avif|svg\+xml);base64,[A-Za-z0-9+/=\s]+|https?:\/\/\S+)$/i; + +/** + * Ein Foto liegt als Data-URL in der Datenbank. Das JSON-Limit der Express-App + * ist 6 MB; hier bleibt bewusst Luft fuer den Rest des Requests. + */ +const AVATAR_MAX_LENGTH = 3_000_000; + +/** Prueft eine Hex-Farbe; null bzw. '' bedeutet "automatisch aus der Palette". */ +export function normalizeColor(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const color = String(value).trim().toLowerCase(); + if (!COLOR_RE.test(color)) return { error: 'Ungueltige Farbe (erwartet z. B. #6ba04a).' }; + return { value: color }; +} + +/** Prueft ein Benutzerfoto; null bzw. '' entfernt es. */ +export function normalizeAvatar(value) { + if (value === null || value === undefined || String(value).trim() === '') return { value: null }; + const avatar = String(value).trim(); + if (avatar.length > AVATAR_MAX_LENGTH) { + return { error: 'Das Foto ist zu gross. Bitte ein kleineres Bild verwenden.' }; + } + if (!AVATAR_RE.test(avatar)) { + return { error: 'Ungueltiges Foto (erwartet eine Bild-Data-URL oder eine http(s)-Adresse).' }; + } + return { value: avatar }; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8ecf68d --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,200 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Sidebar } from './components/Sidebar'; +import { Header } from './components/Header'; +import { LoginScreen } from './components/LoginScreen'; +import { Leads } from './components/leads/Leads'; +import { Opportunities } from './components/opportunities/Opportunities'; +import { Calendar } from './components/calendar/Calendar'; +import { parseSqlDateTime } from './components/calendar/calendarUtils'; +import { Settings } from './components/settings/Settings'; +import { ScannerDashboard } from './components/scanner/ScannerDashboard'; +import { ScannerRejected } from './components/scanner/ScannerRejected'; +import { ScannerAgents } from './components/scanner/ScannerAgents'; +import { ScannerPortals } from './components/scanner/ScannerPortals'; +import { useToast } from './components/Toast'; +import { NavigationProvider } from './lib/navigation'; +import { useAuth } from './lib/auth'; +import { connectionsApi, eventsApi, leadsApi, opportunitiesApi, usersApi } from './lib/api'; +import { + OPEN_STAGES, + formatEuro, + opportunityVolume, + type AppUser, + type CalendarEvent, + type Lead, + type LeadConnection, + type Opportunity, + type SettingsTab, + type ViewState, +} from './types'; + +const Splash: React.FC = () => ( +
    + perinco + +

    Lade perinco CRM...

    +
    +); + +/** Erster Reiter im Menue - hier startet die App. */ +const DEFAULT_VIEW: ViewState = 'Opportunities'; + +const App: React.FC = () => { + const { user, isLoading } = useAuth(); + const { addToast } = useToast(); + + const [currentView, setCurrentView] = useState(DEFAULT_VIEW); + const [isSidebarOpen, setIsSidebarOpen] = useState(true); + /** + * Gewuenschter Reiter der Einstellungen. Der Diktat-Hinweis springt so direkt + * nach Whisper, das keinen eigenen Menuepunkt mehr hat. + */ + const [settingsTab, setSettingsTab] = useState(undefined); + + const [leads, setLeads] = useState([]); + const [connections, setConnections] = useState([]); + const [users, setUsers] = useState([]); + const [events, setEvents] = useState([]); + const [opportunities, setOpportunities] = useState([]); + const [isDataLoading, setIsDataLoading] = useState(false); + + /** + * Ansichtswechsel fuer Menue und tief liegende Komponenten. `tab` waehlt + * gleich den passenden Reiter der Einstellungen; ohne Angabe entscheidet die + * Einstellungsseite selbst (Rolle admin: Nutzerverwaltung, sonst Mein Konto). + */ + const goTo = useCallback((view: ViewState, options?: { tab?: SettingsTab }) => { + setCurrentView(view); + setSettingsTab(options?.tab); + }, []); + + const loadData = useCallback( + async (silent = false) => { + if (!user) return; + if (!silent) setIsDataLoading(true); + try { + // Die Benutzerliste braucht jeder: Netzwerkdiagramm, Zustaendigkeit, + // @-Mentions, Teilnehmer und Farben im Kalender. /users/directory ist + // deshalb - anders als /users - fuer alle Rollen freigegeben. + const [leadRows, connectionRows, eventRows, userRows, opportunityRows] = await Promise.all([ + leadsApi.list(), + connectionsApi.list(), + eventsApi.list(), + usersApi.directory(), + opportunitiesApi.list(), + ]); + setLeads(leadRows); + setConnections(connectionRows); + setEvents(eventRows); + setUsers(userRows); + setOpportunities(opportunityRows); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Daten konnten nicht geladen werden.', 'error'); + } finally { + setIsDataLoading(false); + } + }, + [user, addToast] + ); + + useEffect(() => { + if (user) loadData(); + }, [user, loadData]); + + if (isLoading) return ; + if (!user) return ; + + const upcomingCount = events.filter( + (event) => parseSqlDateTime(event.ends_at).getTime() >= Date.now() + ).length; + + const openOpportunities = opportunities.filter((item) => OPEN_STAGES.includes(item.stage)); + const openVolume = openOpportunities.reduce( + (sum, item) => sum + (opportunityVolume(item) ?? 0), + 0 + ); + + // Untertitel der Kopfzeile. Die Scanner-Reiter bringen ihre Kennzahlen selbst + // mit, deshalb steht hier nur, worum es auf der Seite geht. + const STATIC_SUBTITLES: Partial> = { + Scanner: 'Projekt Scanner - Suchagenten, Portale und Projektübereinstimmungen', + Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer', + Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen', + 'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen', + Einstellungen: 'Konto, Diktat, Benutzer und Scanner', + }; + + const subtitle = + currentView === 'Opportunities' + ? `${opportunities.length} ${ + opportunities.length === 1 ? 'Opportunity' : 'Opportunities' + } · ${openOpportunities.length} offen · ${formatEuro(openVolume)} Pipeline` + : currentView === 'Kontakte' + ? `${leads.length} ${leads.length === 1 ? 'Kontakt' : 'Kontakte'} · ${connections.length} ${ + connections.length === 1 ? 'Verbindung' : 'Verbindungen' + }` + : currentView === 'Kalender' + ? `Gemeinsamer Teamkalender · ${events.length} ${ + events.length === 1 ? 'Termin' : 'Termine' + } · ${upcomingCount} anstehend` + : STATIC_SUBTITLES[currentView]; + + return ( + +
    + setIsSidebarOpen((open) => !open)} + /> + +
    +
    setIsSidebarOpen((open) => !open)} + /> + +
    + {currentView === 'Opportunities' && ( + loadData(true)} + /> + )} + {currentView === 'Kalender' && ( + loadData(true)} + /> + )} + {currentView === 'Kontakte' && ( + loadData(true)} + /> + )} + {currentView === 'Scanner' && } + {currentView === 'Unpassend' && } + {currentView === 'Suchagenten' && } + {currentView === 'Börsen-Konten' && } + {currentView === 'Einstellungen' && } +
    +
    +
    +
    + ); +}; + +export default App; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..f354da5 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import type { ViewState } from '../types'; +import { useTheme } from '../lib/theme'; + +interface HeaderProps { + title: ViewState; + subtitle?: string; + onToggleSidebar: () => void; +} + +const SUN_ICON = + 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z'; +const MOON_ICON = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z'; + +export const Header: React.FC = ({ title, subtitle, onToggleSidebar }) => { + const { theme, preference, toggle, useSystem } = useTheme(); + const isDark = theme === 'dark'; + + return ( +
    + + +
    +

    {title}

    + {subtitle &&

    {subtitle}

    } +
    + +
    + {/* Doppelklick loest die manuelle Wahl und folgt wieder dem System. */} + +
    +
    + ); +}; diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx new file mode 100644 index 0000000..6da42a7 --- /dev/null +++ b/src/components/LoginScreen.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { useAuth } from '../lib/auth'; + +/** Dekoratives Netzwerk-Muster - greift das Knoten-Motiv des perinco Logos auf. */ +const NetworkPattern: React.FC = () => ( + +); + +export const LoginScreen: React.FC = () => { + const { login } = useAuth(); + const [identifier, setIdentifier] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + try { + await login(identifier, password); + } catch (err) { + setError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
    + {/* Markenpanel */} +
    + + perinco +
    +

    perinco CRM

    +

    + Kontakte pflegen, Beziehungen sichtbar machen. Wer kennt wen – auf einen Blick. +

    +
    +

    + Performance Intelligence Consulting +

    +
    + + {/* Formular */} +
    +
    + perinco + +

    Anmelden

    +

    + Melde dich mit deinem Benutzernamen oder deiner E-Mail-Adresse an. +

    + +
    +
    + + setIdentifier(event.target.value)} + className="w-full rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25" + /> +
    + +
    + + setPassword(event.target.value)} + className="w-full rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25" + /> +
    + + {error && ( +
    + + + + {error} +
    + )} + + +
    + +

    + Probleme bei der Anmeldung? Wende dich an einen Administrator. +

    +
    +
    +
    + ); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..9cd79d6 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,315 @@ +import React, { useEffect, useState } from 'react'; +import { SCANNER_VIEWS, type ViewState } from '../types'; +import { UserAvatar } from './ui/UserAvatar'; +import { useAuth } from '../lib/auth'; + +interface SidebarProps { + currentView: ViewState; + onViewChange: (view: ViewState) => void; + isOpen: boolean; + onToggle: () => void; +} + +interface MenuItem { + name: ViewState; + icon: string; + /** Abweichende Beschriftung; ohne Angabe wird der Ansichtsname gezeigt. */ + label?: string; +} + +// Kategorien-Reiter in Anzeigereihenfolge. Weitere Kategorien lassen sich hier +// ergaenzen - Einstellungen bleibt separat am unteren Rand. +const CATEGORY_ITEMS: MenuItem[] = [ + { + name: 'Opportunities', + icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z', + }, + { + name: 'Kalender', + icon: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', + }, + { + name: 'Kontakte', + icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', + }, +]; + +/** + * Projekt Scanner. Der Menuepunkt klappt die Reiter des Scanners darunter auf - + * "Scanner" selbst ist dabei das Dashboard. Die Einstellungen des Scanners + * stehen nicht hier, sondern als Reiter in den CRM-Einstellungen. + */ +const SCANNER_ITEM: MenuItem = { + name: 'Scanner', + icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', +}; + +const SCANNER_CHILDREN: MenuItem[] = [ + { + name: 'Scanner', + label: 'Dashboard', + icon: 'M9 17V7m4 10V11m4 6v-3m4 7H5a2 2 0 01-2-2V5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2z', + }, + { + name: 'Unpassend', + icon: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636', + }, + { + name: 'Suchagenten', + icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z', + }, + { + name: 'Börsen-Konten', + icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9', + }, +]; + +/* + * Whisper hat keinen eigenen Menuepunkt mehr - es ist ein Reiter in den + * Einstellungen. Deshalb sind die Einstellungen jetzt fuer jede Rolle sichtbar: + * jeder pflegt sein Konto und richtet sein eigenes Diktat ein. Welche Reiter + * erscheinen, entscheidet die Rolle innerhalb der Einstellungen. + */ +const SETTINGS_ITEM: MenuItem = { + name: 'Einstellungen', + icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z', +}; + +function useIsMobile() { + const [isMobile, setIsMobile] = useState(() => + typeof window === 'undefined' ? false : window.innerWidth < 1024 + ); + + useEffect(() => { + const onResize = () => setIsMobile(window.innerWidth < 1024); + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, []); + + return isMobile; +} + +export const Sidebar: React.FC = ({ currentView, onViewChange, isOpen, onToggle }) => { + const { user, logout } = useAuth(); + const isMobile = useIsMobile(); + const showLabels = isOpen || isMobile; + + const isScannerView = SCANNER_VIEWS.includes(currentView); + const [isScannerOpen, setIsScannerOpen] = useState(isScannerView); + + // Wer per Klick im Scanner landet, soll die Unterpunkte auch sehen. + useEffect(() => { + if (isScannerView) setIsScannerOpen(true); + }, [isScannerView]); + + const renderItem = (item: MenuItem, options: { indented?: boolean } = {}) => { + const isActive = currentView === item.name; + return ( + + ); + }; + + /** + * Scanner-Gruppe: der Klick oeffnet die Unterpunkte und wechselt gleich auf + * das Dashboard. Bei eingeklappter Leiste gibt es nur das Symbol, die + * Unterpunkte erscheinen erst mit den Beschriftungen. + */ + const renderScannerGroup = () => { + const isActive = currentView === 'Scanner'; + return ( +
    + + + {showLabels && isScannerOpen && ( +
    + {SCANNER_CHILDREN.map((child) => renderItem(child, { indented: true }))} +
    + )} +
    + ); + }; + + return ( + <> + {isOpen && isMobile && ( +
    + )} + + + + ); +}; diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx new file mode 100644 index 0000000..0ff56fa --- /dev/null +++ b/src/components/Toast.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +type ToastKind = 'success' | 'error' | 'info'; + +interface Toast { + id: number; + message: string; + kind: ToastKind; +} + +interface ToastContextValue { + addToast: (message: string, kind?: ToastKind) => void; +} + +const ToastContext = createContext(null); + +let nextId = 1; + +const STYLES: Record = { + success: 'border-brand-200 bg-brand-50 text-brand-800', + error: 'border-red-200 bg-red-50 text-red-700', + info: 'border-ink-200 bg-surface text-ink-800', +}; + +const ICONS: Record = { + success: 'M5 13l4 4L19 7', + error: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z', + info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', +}; + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message: string, kind: ToastKind = 'info') => { + const id = nextId++; + setToasts((prev) => [...prev, { id, message, kind }]); + window.setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4500); + }, []); + + const value = useMemo(() => ({ addToast }), [addToast]); + + return ( + + {children} +
    + {toasts.map((toast) => ( +
    + + + +

    {toast.message}

    + +
    + ))} +
    +
    + ); +}; + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast muss innerhalb von verwendet werden.'); + return ctx; +} diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx new file mode 100644 index 0000000..bd15015 --- /dev/null +++ b/src/components/calendar/Calendar.tsx @@ -0,0 +1,770 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { EventModal } from './EventModal'; +import { EventDetailModal } from './EventDetailModal'; +import { UserDetailModal } from './UserDetailModal'; +import { LeadModal } from '../leads/LeadModal'; +import { ConfirmDialog } from '../ui/Modal'; +import { UserAvatar } from '../ui/UserAvatar'; +import { MentionText, mentionPlainText } from './mentions'; +import { + DAY_MS, + WEEKDAY_LABELS, + addDays, + addMonths, + compareEvents, + formatDayLong, + formatEventRange, + formatMonthYear, + formatTime, + isSameDay, + monthMatrix, + parseSqlDateTime, + positionEvents, + startOfDay, + startOfWeek, + weekDays, +} from './calendarUtils'; +import { useToast } from '../Toast'; +import { useAuth } from '../../lib/auth'; +import { eventsApi } from '../../lib/api'; +import { + eventColor, + userColor, + type AppUser, + type CalendarEvent, + type CalendarViewMode, + type Lead, + type LeadConnection, +} from '../../types'; + +interface CalendarProps { + events: CalendarEvent[]; + leads: Lead[]; + users: AppUser[]; + connections: LeadConnection[]; + isLoading: boolean; + reload: () => void; +} + +const VIEW_BUTTONS: { mode: CalendarViewMode; label: string }[] = [ + { mode: 'month', label: 'Monat' }, + { mode: 'week', label: 'Woche' }, + { mode: 'day', label: 'Tag' }, +]; + +const HOUR_HEIGHT = 48; +/** Ab wie vielen Terminen pro Monatstag auf "+N weitere" gekuerzt wird. */ +const MONTH_CHIP_LIMIT = 3; + +// ---------- Bausteine ---------- +// Bewusst auf Modulebene: als verschachtelte Funktionskomponenten wuerde React +// sie bei jedem Render als neuen Typ behandeln und das Zeitraster neu montieren +// - die Scrollposition waere danach jedes Mal verloren. + +const EventChip: React.FC<{ + event: CalendarEvent; + onOpen: (id: number) => void; + showTime?: boolean; +}> = ({ event, onOpen, showTime = false }) => ( + +); + +interface GridProps { + today: Date; + dayEvents: (day: Date) => CalendarEvent[]; + onCreate: (start: Date) => void; + onOpenEvent: (id: number) => void; + onShowDay: (day: Date) => void; +} + +const MonthView: React.FC = ({ + cursor, + today, + dayEvents, + onCreate, + onOpenEvent, + onShowDay, +}) => { + const days = monthMatrix(cursor); + + return ( +
    +
    + {WEEKDAY_LABELS.map((label) => ( +
    + {label} +
    + ))} +
    + +
    + {days.map((day, index) => { + const isCurrentMonth = day.getMonth() === cursor.getMonth(); + const isToday = isSameDay(day, today); + const list = dayEvents(day); + const visible = list.slice(0, MONTH_CHIP_LIMIT); + const hidden = list.length - visible.length; + const createAt = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9); + + return ( +
    onCreate(createAt)} + onKeyDown={(keyEvent) => { + if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { + keyEvent.preventDefault(); + onCreate(createAt); + } + }} + title="Klicken, um an diesem Tag einen Termin anzulegen" + className={`min-h-[104px] cursor-pointer border-b border-ink-100 p-1.5 transition hover:bg-brand-50/40 ${ + index % 7 === 6 ? '' : 'border-r' + } ${isCurrentMonth ? '' : 'bg-ink-50/50'}`} + > +
    + + {day.getDate()} + + {list.length > 0 && ( + {list.length} + )} +
    + +
    + {visible.map((event) => ( + + ))} + {hidden > 0 && ( + + )} +
    +
    + ); + })} +
    +
    + ); +}; + +const TimeGrid: React.FC }> = ({ + days, + today, + dayEvents, + onCreate, + onOpenEvent, + onShowDay, + scrollRef, +}) => { + const allDayRows = days.map((day) => dayEvents(day).filter((event) => event.all_day)); + const hasAllDay = allDayRows.some((list) => list.length > 0); + const gridTemplate = `56px repeat(${days.length}, minmax(0, 1fr))`; + const now = new Date(); + const nowPercent = ((now.getTime() - startOfDay(now).getTime()) / DAY_MS) * 100; + + return ( +
    + {/* Kopfzeile mit den Tagen */} +
    +
    + {days.map((day) => ( + + ))} +
    + + {/* Ganztaegige Termine */} + {hasAllDay && ( +
    +
    + Ganztag +
    + {days.map((day, index) => ( +
    + {allDayRows[index].map((event) => ( + + ))} +
    + ))} +
    + )} + + {/* Zeitraster */} +
    +
    +
    + {Array.from({ length: 24 }, (_, hour) => ( +
    + + {hour === 0 ? '' : `${String(hour).padStart(2, '0')}:00`} + +
    + ))} +
    + + {days.map((day) => { + const positioned = positionEvents( + dayEvents(day).filter((event) => !event.all_day), + day + ); + + return ( +
    + {/* Stundenzellen sind gleichzeitig Klickflaeche fuer neue Termine */} + {Array.from({ length: 24 }, (_, hour) => ( +
    + onCreate(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour)) + } + title={`Termin um ${String(hour).padStart(2, '0')}:00 anlegen`} + className="cursor-pointer border-b border-ink-100 transition hover:bg-brand-50/40" + style={{ height: HOUR_HEIGHT }} + /> + ))} + + {isSameDay(day, today) && ( +
    + +
    + )} + + {positioned.map(({ event, topPercent, heightPercent, leftPercent, widthPercent }) => ( + + ))} +
    + ); + })} +
    +
    +
    + ); +}; + +// ---------- Hauptansicht ---------- + +export const Calendar: React.FC = ({ + events, + leads, + users, + connections, + isLoading, + reload, +}) => { + const { addToast } = useToast(); + const { user, isAdmin } = useAuth(); + + const [viewMode, setViewMode] = useState('month'); + const [cursor, setCursor] = useState(() => startOfDay(new Date())); + + const [createStart, setCreateStart] = useState(null); + const [editEvent, setEditEvent] = useState(null); + const [detailEventId, setDetailEventId] = useState(null); + const [eventToDelete, setEventToDelete] = useState(null); + const [detailUserId, setDetailUserId] = useState(null); + const [detailLeadId, setDetailLeadId] = useState(null); + + const timeGridRef = useRef(null); + + // Das Zeitraster startet am Vormittag - die Nachtstunden interessieren selten. + useEffect(() => { + if (viewMode === 'month') return; + const element = timeGridRef.current; + if (element) element.scrollTop = 7 * HOUR_HEIGHT; + }, [viewMode]); + + const today = startOfDay(new Date()); + + // Deaktivierte Konten sollen nicht mehr auswaehlbar sein - zum Aufloesen + // bestehender Mentions bleibt die vollstaendige Liste in Verwendung. + const activeUsers = useMemo(() => users.filter((item) => item.is_active), [users]); + + const eventsByDay = useMemo(() => { + const map = new Map(); + for (const event of events) { + const start = startOfDay(parseSqlDateTime(event.starts_at)); + const end = startOfDay(parseSqlDateTime(event.ends_at)); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue; + // Mehrtaegige Termine an jedem betroffenen Tag eintragen. Der Deckel bei + // 400 Tagen verhindert, dass ein Tippfehler im Jahr die Ansicht sprengt. + for (let day = start, guard = 0; day <= end && guard < 400; day = addDays(day, 1), guard++) { + const key = day.toDateString(); + const list = map.get(key) ?? []; + list.push(event); + map.set(key, list); + } + } + for (const list of map.values()) list.sort(compareEvents); + return map; + }, [events]); + + const dayEvents = useMemo( + () => (day: Date) => eventsByDay.get(day.toDateString()) ?? [], + [eventsByDay] + ); + + const detailEvent = detailEventId + ? events.find((event) => event.id === detailEventId) ?? null + : null; + const detailUser = detailUserId ? users.find((item) => item.id === detailUserId) ?? null : null; + const detailLead = detailLeadId ? leads.find((lead) => lead.id === detailLeadId) ?? null : null; + + const canModify = (event: CalendarEvent) => isAdmin || event.created_by === user?.id; + + // Anstehende Termine fuer die Liste rechts: alles, was noch nicht vorbei ist, + // der naechste Termin zuerst. + const upcoming = useMemo(() => { + const now = Date.now(); + return events + .filter((event) => parseSqlDateTime(event.ends_at).getTime() >= now) + .sort( + (a, b) => + parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime() || + a.id - b.id + ); + }, [events]); + + const upcomingGroups = useMemo(() => { + const groups: { day: Date; items: CalendarEvent[] }[] = []; + for (const event of upcoming) { + const day = startOfDay(parseSqlDateTime(event.starts_at)); + const last = groups[groups.length - 1]; + if (last && isSameDay(last.day, day)) last.items.push(event); + else groups.push({ day, items: [event] }); + } + return groups; + }, [upcoming]); + + // Nur Benutzer mit eigenen Terminen kommen in die Legende. + const legendUsers = useMemo(() => { + const ids = new Set( + events.map((event) => event.created_by).filter((id): id is number => id !== null) + ); + return users.filter((item) => ids.has(item.id)); + }, [events, users]); + + const step = (direction: 1 | -1) => + setCursor((current) => { + if (viewMode === 'month') return addMonths(current, direction); + if (viewMode === 'week') return addDays(current, direction * 7); + return addDays(current, direction); + }); + + const rangeLabel = + viewMode === 'month' + ? formatMonthYear(cursor) + : viewMode === 'week' + ? (() => { + const first = startOfWeek(cursor); + const last = addDays(first, 6); + return `${first.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + })} – ${last.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + })}`; + })() + : formatDayLong(cursor); + + const openCreate = (start: Date) => { + setEditEvent(null); + setCreateStart(start); + }; + + const showDay = (day: Date) => { + setCursor(startOfDay(day)); + setViewMode('day'); + }; + + const confirmDelete = async () => { + if (!eventToDelete) return; + try { + await eventsApi.remove(eventToDelete.id); + addToast('Termin geloescht.', 'success'); + setDetailEventId(null); + reload(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.', 'error'); + } finally { + setEventToDelete(null); + } + }; + + const gridProps: GridProps = { + today, + dayEvents, + onCreate: openCreate, + onOpenEvent: setDetailEventId, + onShowDay: showDay, + }; + + return ( +
    + {/* Steuerleiste */} +
    +
    + + + +

    {rangeLabel}

    +
    + +
    +
    + {VIEW_BUTTONS.map((button) => ( + + ))} +
    + + +
    +
    + +
    + {/* Kalender */} +
    + {isLoading && events.length === 0 ? ( +
    + +
    + ) : viewMode === 'month' ? ( + + ) : ( + + )} + + {legendUsers.length > 0 && ( +
    + + Farben + + {legendUsers.map((item) => ( + + ))} +
    + )} +
    + + {/* Anstehende Termine */} + +
    + + {/* Dialoge */} + {(createStart !== null || editEvent !== null) && ( + { + setCreateStart(null); + setEditEvent(null); + }} + onSaved={reload} + /> + )} + + {detailEvent && editEvent === null && ( + { + setEditEvent(detailEvent); + setDetailEventId(null); + }} + onDelete={() => setEventToDelete(detailEvent)} + onClose={() => setDetailEventId(null)} + onOpenLead={setDetailLeadId} + onOpenUser={setDetailUserId} + /> + )} + + {detailUser && ( + setDetailUserId(null)} + onOpenLead={(leadId) => { + setDetailUserId(null); + setDetailLeadId(leadId); + }} + /> + )} + + {detailLead && ( + setDetailLeadId(null)} + onSaved={reload} + /> + )} + + {eventToDelete && ( + setEventToDelete(null)} + /> + )} +
    + ); +}; diff --git a/src/components/calendar/EventDetailModal.tsx b/src/components/calendar/EventDetailModal.tsx new file mode 100644 index 0000000..b11e9de --- /dev/null +++ b/src/components/calendar/EventDetailModal.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { Modal } from '../ui/Modal'; +import { UserAvatar } from '../ui/UserAvatar'; +import { MentionText } from './mentions'; +import { formatDayLong, formatEventRange, parseSqlDateTime } from './calendarUtils'; +import { eventColor, userColor, type AppUser, type CalendarEvent, type Lead } from '../../types'; + +interface EventDetailModalProps { + event: CalendarEvent; + leads: Lead[]; + users: AppUser[]; + canModify: boolean; + onEdit: () => void; + onDelete: () => void; + onClose: () => void; + onOpenLead: (leadId: number) => void; + onOpenUser: (userId: number) => void; +} + +const Row: React.FC<{ icon: string; children: React.ReactNode }> = ({ icon, children }) => ( +
    + + + +
    {children}
    +
    +); + +/** Leseansicht eines Termins - hier sind die @-Mentions anklickbar. */ +export const EventDetailModal: React.FC = ({ + event, + leads, + users, + canModify, + onEdit, + onDelete, + onClose, + onOpenLead, + onOpenUser, +}) => { + const color = eventColor(event); + const start = parseSqlDateTime(event.starts_at); + + return ( + + + {canModify && ( + <> + + + + )} +
    + } + > +
    +
    + + {event.created_by !== null && ( + + )} +
    +

    + Angelegt von +

    +

    + {event.created_by_name ?? 'unbekannt'} +

    +
    +
    + +
    + + {formatDayLong(start)} + · {formatEventRange(event)} + + + {event.location && ( + + {event.location} + + )} + + + {event.participants.length === 0 ? ( + Keine Teilnehmer eingetragen. + ) : ( +
    + {event.participants.map((participant) => { + const tint = userColor(participant); + return ( + + ); + })} +
    + )} +
    +
    + +
    +

    + Beschreibung +

    + {event.description ? ( +
    + +
    + ) : ( +

    + Keine Beschreibung hinterlegt. +

    + )} +
    +
    + + ); +}; diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx new file mode 100644 index 0000000..7c1ba8e --- /dev/null +++ b/src/components/calendar/EventModal.tsx @@ -0,0 +1,286 @@ +import React, { useMemo, useState } from 'react'; +import { Modal } from '../ui/Modal'; +import { inputClass, labelClass } from '../leads/leadUi'; +import { MentionTextarea, buildCandidates } from './mentions'; +import { + addDays, + parseSqlDateTime, + startOfDay, + toDateInput, + toDateTimeInput, + toSqlDateTime, +} from './calendarUtils'; +import { useToast } from '../Toast'; +import { eventsApi } from '../../lib/api'; +import { userColor, type AppUser, type CalendarEvent, type Lead } from '../../types'; + +interface EventModalProps { + /** null = neuer Termin */ + event: CalendarEvent | null; + /** Vorbelegung fuer neue Termine, z. B. der angeklickte Tag/Slot. */ + defaultStart?: Date; + leads: Lead[]; + users: AppUser[]; + currentUserId: number; + onClose: () => void; + onSaved: () => void; +} + +const HOUR = 60 * 60 * 1000; + +function initialRange(event: CalendarEvent | null, defaultStart?: Date) { + if (event) { + return { start: parseSqlDateTime(event.starts_at), end: parseSqlDateTime(event.ends_at) }; + } + const start = defaultStart ?? new Date(new Date().setMinutes(0, 0, 0) + HOUR); + return { start, end: new Date(start.getTime() + HOUR) }; +} + +export const EventModal: React.FC = ({ + event, + defaultStart, + leads, + users, + currentUserId, + onClose, + onSaved, +}) => { + const { addToast } = useToast(); + const range = useMemo(() => initialRange(event, defaultStart), [event, defaultStart]); + + const [title, setTitle] = useState(event?.title ?? ''); + const [location, setLocation] = useState(event?.location ?? ''); + const [description, setDescription] = useState(event?.description ?? ''); + const [allDay, setAllDay] = useState(event?.all_day ?? false); + const [start, setStart] = useState(range.start); + const [end, setEnd] = useState(range.end); + const [participantIds, setParticipantIds] = useState( + event ? event.participants.map((participant) => participant.id) : [currentUserId] + ); + const [isSaving, setIsSaving] = useState(false); + + const candidates = useMemo(() => buildCandidates(leads, users), [leads, users]); + + // Start verschieben nimmt das Ende mit, solange es nicht davor liegen soll. + const handleStartChange = (value: string) => { + const next = parseSqlDateTime(value); + if (Number.isNaN(next.getTime())) return; + const shift = next.getTime() - start.getTime(); + setStart(next); + setEnd(new Date(end.getTime() + shift)); + }; + + const handleEndChange = (value: string) => { + const next = parseSqlDateTime(value); + if (Number.isNaN(next.getTime())) return; + setEnd(next); + }; + + const toggleParticipant = (id: number) => + setParticipantIds((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + + const handleSubmit = async (formEvent: React.FormEvent) => { + formEvent.preventDefault(); + if (!title.trim()) { + addToast('Bitte einen Titel angeben.', 'error'); + return; + } + + // Ganztaegig: der Termin belegt die kompletten gewaehlten Tage. + const startsAt = allDay ? startOfDay(start) : start; + const endsAt = allDay + ? new Date(addDays(startOfDay(end), 1).getTime() - 60_000) + : end; + + if (endsAt.getTime() < startsAt.getTime()) { + addToast('Das Ende darf nicht vor dem Start liegen.', 'error'); + return; + } + + setIsSaving(true); + try { + const payload = { + title: title.trim(), + description: description.trim() || null, + location: location.trim() || null, + starts_at: toSqlDateTime(startsAt), + ends_at: toSqlDateTime(endsAt), + all_day: allDay, + participant_ids: participantIds, + }; + + if (event) { + await eventsApi.update(event.id, payload); + addToast('Termin aktualisiert.', 'success'); + } else { + await eventsApi.create(payload); + addToast('Termin angelegt.', 'success'); + } + onSaved(); + onClose(); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } finally { + setIsSaving(false); + } + }; + + return ( + + + +
    + } + > +
    +
    + + setTitle(inputEvent.target.value)} + placeholder="z. B. Jour Fixe Vertrieb" + className={inputClass} + /> +
    + + + +
    +
    + + handleStartChange(inputEvent.target.value)} + className={inputClass} + /> +
    +
    + + handleEndChange(inputEvent.target.value)} + className={inputClass} + /> +
    +
    + +
    + + setLocation(inputEvent.target.value)} + placeholder="Raum, Adresse oder Videolink" + className={inputClass} + /> +
    + +
    + + +

    + @ tippen, um einen Kontakt oder Benutzer + zu markieren – markierte Personen sind spaeter anklickbar. +

    +
    + +
    +

    + Teilnehmer +

    +

    + Mehrfachauswahl moeglich. Die Farbe des Termins im Kalender ist die des Erstellers. +

    +
    + {users.map((user) => { + const isSelected = participantIds.includes(user.id); + const color = userColor(user); + return ( + + ); + })} +
    +
    +
    + + ); +}; diff --git a/src/components/calendar/UserDetailModal.tsx b/src/components/calendar/UserDetailModal.tsx new file mode 100644 index 0000000..b82d87e --- /dev/null +++ b/src/components/calendar/UserDetailModal.tsx @@ -0,0 +1,148 @@ +import React, { useMemo } from 'react'; +import { Modal } from '../ui/Modal'; +import { UserAvatar } from '../ui/UserAvatar'; +import { formatEventRange, parseSqlDateTime } from './calendarUtils'; +import { + leadFullName, + userColor, + type AppUser, + type CalendarEvent, + type Lead, +} from '../../types'; + +interface UserDetailModalProps { + user: AppUser; + leads: Lead[]; + events: CalendarEvent[]; + onClose: () => void; + onOpenLead: (leadId: number) => void; +} + +/** Detailfenster fuer einen im Kalender markierten Benutzer. */ +export const UserDetailModal: React.FC = ({ + user, + leads, + events, + onClose, + onOpenLead, +}) => { + const color = userColor(user); + + const ownedLeads = useMemo( + () => + leads + .filter((lead) => lead.owner_id === user.id) + .sort((a, b) => leadFullName(a).localeCompare(leadFullName(b), 'de')), + [leads, user.id] + ); + + // Die naechsten Termine, an denen der Benutzer beteiligt ist (Ersteller + // oder Teilnehmer). + const upcoming = useMemo(() => { + const now = Date.now(); + return events + .filter( + (event) => + (event.created_by === user.id || + event.participants.some((participant) => participant.id === user.id)) && + parseSqlDateTime(event.ends_at).getTime() >= now + ) + .sort( + (a, b) => + parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime() + ) + .slice(0, 5); + }, [events, user.id]); + + return ( + + Schliessen + + } + > +
    +
    + +
    +

    {user.name}

    +

    {user.email || 'Keine E-Mail hinterlegt'}

    +

    + + Kalenderfarbe +

    +
    +
    + +
    +

    + Naechste Termine +

    + {upcoming.length === 0 ? ( +

    + Keine anstehenden Termine. +

    + ) : ( +
      + {upcoming.map((event) => ( +
    • +

      {event.title}

      +

      + {parseSqlDateTime(event.starts_at).toLocaleDateString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + })}{' '} + · {formatEventRange(event)} +

      +
    • + ))} +
    + )} +
    + +
    +

    + Zustaendig fuer {ownedLeads.length} {ownedLeads.length === 1 ? 'Kontakt' : 'Kontakte'} +

    + {ownedLeads.length === 0 ? ( +

    + Aktuell keine Kontakte zugeordnet. +

    + ) : ( +
      + {ownedLeads.map((lead) => ( +
    • + +
    • + ))} +
    + )} +
    +
    +
    + ); +}; diff --git a/src/components/calendar/calendarUtils.ts b/src/components/calendar/calendarUtils.ts new file mode 100644 index 0000000..a22e216 --- /dev/null +++ b/src/components/calendar/calendarUtils.ts @@ -0,0 +1,199 @@ +import type { CalendarEvent } from '../../types'; + +/** + * Das Backend liefert DATETIME als String (`dateStrings: true`), also lokale + * Wandzeit ohne Zeitzone. `new Date('2026-08-04 10:00:00')` ist nicht in allen + * Browsern gueltig - deshalb wird hier von Hand geparst. Das haelt Termine + * unabhaengig von der Zeitzone des Clients an der gespeicherten Uhrzeit. + */ +export function parseSqlDateTime(value: string): Date { + const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value ?? ''); + if (!match) return new Date(NaN); + const [, year, month, day, hour = '0', minute = '0', second = '0'] = match; + return new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); +} + +const pad = (value: number) => String(value).padStart(2, '0'); + +/** `YYYY-MM-DD HH:MM:SS` - das Format, das das Backend erwartet. */ +export function toSqlDateTime(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( + date.getHours() + )}:${pad(date.getMinutes())}:00`; +} + +/** `YYYY-MM-DDTHH:MM` fuer */ +export function toDateTimeInput(date: Date): string { + return `${toDateInput(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** `YYYY-MM-DD` fuer */ +export function toDateInput(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +export function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +export function addDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +export function addMonths(date: Date, months: number): Date { + // Auf den Ersten setzen, damit der 31. nicht in den Folgemonat rutscht. + return new Date(date.getFullYear(), date.getMonth() + months, 1); +} + +/** Wochenstart Montag - deutsche Konvention. */ +export function startOfWeek(date: Date): Date { + const start = startOfDay(date); + const shift = (start.getDay() + 6) % 7; + return addDays(start, -shift); +} + +export function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const; + +/** + * 6 Wochen ab dem Montag vor dem Monatsersten - feste Hoehe, damit das Raster + * beim Blaettern nicht springt. + */ +export function monthMatrix(month: Date): Date[] { + const first = startOfWeek(new Date(month.getFullYear(), month.getMonth(), 1)); + return Array.from({ length: 42 }, (_, index) => addDays(first, index)); +} + +export function weekDays(date: Date): Date[] { + const first = startOfWeek(date); + return Array.from({ length: 7 }, (_, index) => addDays(first, index)); +} + +export function formatTime(date: Date): string { + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +export function formatDayLong(date: Date): string { + return date.toLocaleDateString('de-DE', { + weekday: 'long', + day: '2-digit', + month: 'long', + year: 'numeric', + }); +} + +export function formatMonthYear(date: Date): string { + return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); +} + +export const DAY_MS = 24 * 60 * 60 * 1000; +/** Mindesthoehe eines Termins im Zeitraster, sonst ist er nicht lesbar. */ +const MIN_SLOT_MS = 15 * 60 * 1000; + +export interface PositionedEvent { + event: CalendarEvent; + topPercent: number; + heightPercent: number; + leftPercent: number; + widthPercent: number; +} + +/** + * Positioniert die Termine eines Tages im Zeitraster. Ueberlappende Termine + * werden in Spalten aufgeteilt, damit keiner verdeckt wird; Prozentwerte + * beziehen sich auf die 24-Stunden-Spalte. + */ +export function positionEvents(events: CalendarEvent[], day: Date): PositionedEvent[] { + const dayStart = startOfDay(day).getTime(); + const dayEnd = dayStart + DAY_MS; + + const items = events + .map((event) => { + const rawStart = parseSqlDateTime(event.starts_at).getTime(); + const rawEnd = parseSqlDateTime(event.ends_at).getTime(); + const start = Math.max(rawStart, dayStart); + const end = Math.min(Math.max(rawEnd, start + MIN_SLOT_MS), dayEnd); + return { event, start, end, column: 0 }; + }) + .sort((a, b) => a.start - b.start || a.end - b.end); + + const positioned: PositionedEvent[] = []; + + // Ein Cluster ist eine Kette sich beruehrender Termine; nur innerhalb eines + // Clusters muss die Breite geteilt werden. + const flush = (cluster: typeof items) => { + if (cluster.length === 0) return; + const columnEnds: number[] = []; + for (const item of cluster) { + let column = columnEnds.findIndex((end) => end <= item.start); + if (column === -1) column = columnEnds.length; + columnEnds[column] = item.end; + item.column = column; + } + const columns = columnEnds.length; + for (const item of cluster) { + positioned.push({ + event: item.event, + topPercent: ((item.start - dayStart) / DAY_MS) * 100, + heightPercent: ((item.end - item.start) / DAY_MS) * 100, + leftPercent: (item.column / columns) * 100, + widthPercent: 100 / columns, + }); + } + }; + + let cluster: typeof items = []; + let clusterEnd = -Infinity; + for (const item of items) { + if (cluster.length > 0 && item.start >= clusterEnd) { + flush(cluster); + cluster = []; + clusterEnd = -Infinity; + } + cluster.push(item); + clusterEnd = Math.max(clusterEnd, item.end); + } + flush(cluster); + + return positioned; +} + +/** Sortierung im Raster: ganztaegig zuerst, danach nach Startzeit. */ +export function compareEvents(a: CalendarEvent, b: CalendarEvent): number { + if (a.all_day !== b.all_day) return a.all_day ? -1 : 1; + const diff = parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime(); + return diff !== 0 ? diff : a.id - b.id; +} + +/** Zeitspanne als Text fuer Karten und Listen. */ +export function formatEventRange(event: CalendarEvent): string { + const start = parseSqlDateTime(event.starts_at); + const end = parseSqlDateTime(event.ends_at); + + if (event.all_day) { + return isSameDay(start, end) + ? 'Ganztaegig' + : `Ganztaegig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`; + } + if (isSameDay(start, end)) return `${formatTime(start)} – ${formatTime(end)}`; + return `${formatTime(start)} – ${end.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + })} ${formatTime(end)}`; +} diff --git a/src/components/calendar/mentions.tsx b/src/components/calendar/mentions.tsx new file mode 100644 index 0000000..3ff6e5f --- /dev/null +++ b/src/components/calendar/mentions.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + leadFullName, + userColor, + type AppUser, + type Lead, +} from '../../types'; + +/** + * Mentions werden als Token im Text gespeichert: `@[Anzeigename](lead:12)` + * bzw. `@[Anzeigename](user:3)`. Der Name steht mit im Token, damit der Text + * auch ohne geladene Listen lesbar bleibt; beim Rendern gewinnt aber der + * aktuelle Name aus den Daten, falls die Person noch existiert. + */ +const MENTION_TOKEN = /@\[([^\]\n]+)\]\((lead|user):(\d+)\)/g; + +export type MentionKind = 'lead' | 'user'; + +export interface MentionCandidate { + kind: MentionKind; + id: number; + label: string; + hint: string; + color: string; + /** Foto des Kontakts bzw. Benutzers, falls hinterlegt. */ + avatar: string | null; +} + +type Segment = + | { type: 'text'; value: string } + | { type: 'mention'; kind: MentionKind; id: number; label: string }; + +export function parseMentions(text: string): Segment[] { + const segments: Segment[] = []; + let lastIndex = 0; + + // Der Regex ist global, also vor jedem Durchlauf zuruecksetzen. + MENTION_TOKEN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MENTION_TOKEN.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, match.index) }); + } + segments.push({ + type: 'mention', + label: match[1], + kind: match[2] as MentionKind, + id: Number(match[3]), + }); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) segments.push({ type: 'text', value: text.slice(lastIndex) }); + return segments; +} + +/** Text ohne Token-Syntax - fuer Vorschauen, Tooltips und Suche. */ +export function mentionPlainText(text: string): string { + return parseMentions(text) + .map((segment) => (segment.type === 'text' ? segment.value : `@${segment.label}`)) + .join(''); +} + +export function buildCandidates(leads: Lead[], users: AppUser[]): MentionCandidate[] { + const fromUsers: MentionCandidate[] = users.map((user) => ({ + kind: 'user', + id: user.id, + label: user.name, + hint: `Benutzer · @${user.username}`, + color: userColor(user), + avatar: user.avatar ?? null, + })); + + const fromLeads: MentionCandidate[] = leads.map((lead) => ({ + kind: 'lead', + id: lead.id, + label: leadFullName(lead), + hint: [lead.company, lead.position].filter(Boolean).join(' · ') || 'Kontakt', + color: '#6e6e6e', + avatar: lead.avatar, + })); + + // Benutzer zuerst: im Teamkalender werden meist Kollegen markiert. + return [...fromUsers, ...fromLeads]; +} + +export function mentionToken(candidate: MentionCandidate): string { + // Eckige Klammern im Namen wuerden das Token zerlegen. + const label = candidate.label.replace(/[\[\]()]/g, '').trim() || 'Unbenannt'; + return `@[${label}](${candidate.kind}:${candidate.id})`; +} + +// ---------- Anzeige ---------- + +interface MentionTextProps { + text: string; + leads: Lead[]; + users: AppUser[]; + onOpenLead?: (leadId: number) => void; + onOpenUser?: (userId: number) => void; + className?: string; +} + +/** Rendert Text mit klickbaren Mentions. Zeilenumbrueche bleiben erhalten. */ +export const MentionText: React.FC = ({ + text, + leads, + users, + onOpenLead, + onOpenUser, + className = '', +}) => { + const leadById = useMemo(() => new Map(leads.map((lead) => [lead.id, lead])), [leads]); + const userById = useMemo(() => new Map(users.map((user) => [user.id, user])), [users]); + + return ( + + {parseMentions(text).map((segment, index) => { + if (segment.type === 'text') return {segment.value}; + + const lead = segment.kind === 'lead' ? leadById.get(segment.id) : undefined; + const user = segment.kind === 'user' ? userById.get(segment.id) : undefined; + const exists = Boolean(lead || user); + const label = lead ? leadFullName(lead) : user ? user.name : segment.label; + + if (!exists) { + // Person gibt es nicht mehr (oder ist deaktiviert): nur noch Text. + return ( + + @{segment.label} + + ); + } + + const tint = user ? userColor(user) : '#6ba04a'; + return ( + + ); + })} + + ); +}; + +// ---------- Eingabe ---------- + +interface MentionTextareaProps { + value: string; + onChange: (value: string) => void; + candidates: MentionCandidate[]; + placeholder?: string; + rows?: number; + className?: string; + id?: string; +} + +const MAX_SUGGESTIONS = 8; + +/** Was steht direkt vor dem Cursor gerade nach einem @? */ +function activeQuery(text: string, caret: number): { start: number; query: string } | null { + const before = text.slice(0, caret); + // @ am Wortanfang, danach keine Leerzeichen und kein weiteres @ + const match = /(^|[\s(,;])@([^\s@\[\]()]{0,40})$/.exec(before); + if (!match) return null; + return { start: caret - match[2].length - 1, query: match[2] }; +} + +export const MentionTextarea: React.FC = ({ + value, + onChange, + candidates, + placeholder, + rows = 4, + className = '', + id, +}) => { + const textareaRef = useRef(null); + const [query, setQuery] = useState<{ start: number; query: string } | null>(null); + const [highlighted, setHighlighted] = useState(0); + + const suggestions = useMemo(() => { + if (!query) return []; + const needle = query.query.toLowerCase(); + const matches = needle + ? candidates.filter( + (candidate) => + candidate.label.toLowerCase().includes(needle) || + candidate.hint.toLowerCase().includes(needle) + ) + : candidates; + return matches.slice(0, MAX_SUGGESTIONS); + }, [candidates, query]); + + useEffect(() => { + setHighlighted(0); + }, [query?.query]); + + const isOpen = query !== null && suggestions.length > 0; + + const syncQuery = (text: string, caret: number) => setQuery(activeQuery(text, caret)); + + const insert = (candidate: MentionCandidate) => { + if (!query) return; + const token = `${mentionToken(candidate)} `; + const next = value.slice(0, query.start) + token + value.slice(query.start + 1 + query.query.length); + onChange(next); + setQuery(null); + + // Cursor hinter das eingefuegte Token setzen. + const caret = query.start + token.length; + requestAnimationFrame(() => { + const element = textareaRef.current; + if (!element) return; + element.focus(); + element.setSelectionRange(caret, caret); + }); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!isOpen) return; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setHighlighted((index) => (index + 1) % suggestions.length); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setHighlighted((index) => (index - 1 + suggestions.length) % suggestions.length); + } else if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + insert(suggestions[highlighted]); + } else if (event.key === 'Escape') { + // Nicht das Modal schliessen, nur die Vorschlagsliste. + event.preventDefault(); + event.stopPropagation(); + setQuery(null); + } + }; + + return ( +
    +