# 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.