# 02 Datenmodell Zwei Datenbanken, absichtlich getrennt: | Datenbank | Inhalt | Zugriff | | --- | --- | --- | | `perinco_CRM` | Benutzer, Kontakte, Opportunities, Termine, Postfach, Einstellungen | `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-Übernahme aus der eigenständigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer eigenen Datenbank, damit beide Anwendungen parallel laufen können. ## 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 ||--o{ crm_acquisitions : created_by crm_acquisitions |o--o| crm_opportunities : "wird zu" crm_acquisition_options ||--o{ crm_acquisitions : "fuellt Von, Kanal, Berater" crm_users { int id PK varchar username UK varchar email UK varchar name enum role "admin | user" varchar color "Terminfarbe, 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 kind "Recruiter | Partner | Endkunde" varchar source "z.B. 'Mail' bei Herkunft Postfach" tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt" tinyint mail_consent "Einverständnis für Werbemails" datetime mail_consent_at "wann erteilt - der Nachweis" 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 .. Beauftragt | Absage" tinyint probability "0-100" decimal daily_rate decimal duration_days "Personentage, halbe möglich" decimal fixed_price "Festpreis statt Tagessatz x Laufzeit" date last_interaction_at varchar next_step int owner_id FK datetime won_at "Wechsel nach Beauftragt" datetime deleted_at "Papierkorb; nach 90 Tagen weg" } crm_opportunity_contacts { int opportunity_id PK,FK int lead_id PK,FK varchar role "z.B. Entscheider" } crm_acquisitions { int id PK date found_at "entscheidet Eingang, Archiv, Loeschung" varchar sender "Von" varchar provider "Anbieter" varchar channel "Mail | Telefon | Portal | ..." varchar title "Projekttitel" varchar consultant "Berater" varchar link "macht den Titel klickbar" datetime converted_at "uebernommen -> raus aus der Liste" int opportunity_id FK } crm_acquisition_options { int id PK enum field "sender | channel | consultant" varchar value "Inhalt des Dropdowns" } crm_acquisition_history { date found_on PK varchar consultant PK int applications "verdichtet vor dem Loeschen" } 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 } ``` Acht Eigenheiten, die man kennen muss: - **`crm_leads` führt zwei Bestände.** `mail_pool = 0` ist der gepflegte Kontaktbestand, `mail_pool = 1` der Vorbereich „Aus Mails" mit den Funden des Postfachabgleichs. Nur der Kontaktbestand erscheint in Kontaktliste, Opportunities, Interviews und Netzwerk; der Vorbereich hat seine eigene Ansicht. Eine Spalte statt einer zweiten Tabelle, damit `crm_mail_links` und `crm_mail_contact_candidates` für beide Bestände gelten ([11 Postfachanbindung](11-postfach.md)). - **`tags` ist Text, kein JSON-Typ.** Bewusst, damit es auf älteren MariaDB-Versionen ohne JSON-Funktionen läuft. 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. - **Das Dashboard rechnet aus zwei Quellen.** Jeder Eintrag in `crm_acquisitions` ist eine versendete Bewerbung. Weil die Akquise nach 90 Tagen löscht, sichert der Aufräumlauf vorher Tag, Berater und Anzahl in `crm_acquisition_history` - in derselben Transaktion, sonst würde ein Abbruch zwischen Sichern und Löschen denselben Tag doppelt zählen. Die Auswertung liest beide Quellen und addiert sie; überschneiden können sie sich nicht, weil genau das gesichert wird, was verschwindet. `crm_opportunities.won_at` hält den Zeitpunkt des Wechsels nach *Beauftragt* - die Phase allein sagt nur, wie es jetzt steht, nicht wann es passiert ist. - **Gelöschte Opportunities bleiben stehen.** `crm_opportunities.deleted_at` setzt sie in den Papierkorb; alle Abfragen blenden sie aus. An einer Opportunity hängen Ansprechpartner, Verlauf und Mailzuordnungen - ein Fehlklick soll das nicht kosten. Endgültig entfernt sie erst das Leeren des Papierkorbs oder der Ablauf von 90 Tagen; dann räumen `crm_opportunity_contacts` und `crm_mail_links` per CASCADE mit auf. - **Die Dropdowns der Akquise stehen in einer Tabelle, nicht in drei.** `crm_acquisition_options` unterscheidet die Listen über die Spalte `field`. Die Felder verhalten sich gleich, also ist jede weitere Liste ein Wert im ENUM statt einer neuen Tabelle samt Route und Oberfläche. Der Unique-Schlüssel auf `(field, value)` greift über die Kollation `utf8mb4_unicode_ci` auch bei abweichender Groß-/Kleinschreibung - „mail" ist neben „Mail" kein neuer Wert. Die Akquise-Einträge halten den **Text**, keinen Fremdschlüssel: wird ein Wert aus der Liste entfernt, bleiben die Einträge lesbar und änderbar. `crm_acquisitions.provider` (Anbieter) gehört nicht dazu - das Feld ist Freitext, seine Vorschläge kommen aus den vorhandenen Einträgen. - **`crm_acquisitions` hat keine Zustandsspalte.** Eingang, Archiv und Löschung ergeben sich aus `found_at` (14 bzw. 90 Tage) und `converted_at`. Eine Spalte, die ein Job umsetzen müsste, kann von der Wirklichkeit abweichen - eine gerechnete Grenze nicht. `converted_at` ist dabei die Wahrheit, nicht `opportunity_id`: wird die Opportunity gelöscht, fällt der Verweis auf NULL, der Eintrag soll aber nicht wieder im **Eingang** auftauchen - im **Archiv** schon. Ob eine übernommene Bewerbung dort wieder erscheint, wird aus dem Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert: wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. Die Regeln dafür stehen in `server/src/acquisitionRules.js`. ### Postfach crm@perinco.de Diese Tabellen liegen in derselben Datenbank, hängen aber nur über `crm_mail_links` und `crm_mail_contact_candidates.lead_id` am Rest. Fachliche Beschreibung in [11 Postfachanbindung](11-postfach.md). ```mermaid erDiagram crm_mail_messages ||--o{ crm_mail_links : hat crm_mail_messages ||--o{ crm_mail_contact_candidates : liefert crm_leads ||--o{ crm_mail_links : "lead_id" crm_opportunities ||--o{ crm_mail_links : "opportunity_id" crm_leads ||--o{ crm_mail_contact_candidates : "lead_id, nach Übernahme" crm_mail_messages { int id PK varchar graph_id "Id aus Microsoft Graph" char graph_id_hash UK "SHA-256 hex, trägt die Eindeutigkeit" varchar conversation_id "Unterhaltung" varchar subject varchar from_email longtext to_json "[{name,email}] als Text" datetime received_at "UTC" longtext body_text "aus HTML gewandelt, Zeilen erhalten" longtext body_html "entschärft für die Anzeige" tinyint has_attachments tinyint is_deleted "im Postfach gelöscht" } crm_mail_links { int id PK int message_id FK int lead_id FK "NULL erlaubt" int opportunity_id FK "NULL erlaubt" enum origin "manuell | automatisch" varchar note int created_by FK } crm_mail_contact_candidates { int id PK int message_id FK char dedupe_hash "aus E-Mail bzw. Name+Telefon" varchar first_name varchar last_name varchar email varchar company enum origin "absender | empfaenger | signatur | text" tinyint confidence "0-100" enum status "offen | übernommen | verworfen | dublette" int lead_id FK "gesetzt ab übernommen" } crm_mail_sync_state { tinyint id PK "immer 1" varchar mailbox text delta_link "nächster Graph-Aufruf" tinyint has_more "nextLink offen" datetime last_run_at datetime last_success_at text last_error } crm_settings { varchar key_name PK text value "alles als Text, auch Zahlen und Schalter" } ``` Vier Eigenheiten: - **Die Graph-ID hat einen Hash daneben.** Sie ist mit über 150 Zeichen zu lang für einen Unique-Index unter `utf8mb4`; die Eindeutigkeit trägt deshalb `graph_id_hash` (SHA-256 als Hex, `CHAR(64)`). - **`received_at` steht in UTC**, anders als `crm_events.starts_at` (lokale Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern wäre eine Fehlerquelle. Das Frontend rechnet um (`parseMailDate` in `src/lib/mailApi.ts`). - **`crm_mail_links` erlaubt NULL in beiden Fremdschlüsseln.** MariaDB lässt in einem Unique-Index mehrere NULL zu - die beiden Schlüssel sperren damit genau die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und mehreren Opportunities hängen. - **`crm_settings` enthält keine Geheimnisse.** Zugangsdaten für Microsoft 365 stehen ausschließlich in `server/.env`, damit ein Datenbank-Dump keine Secrets enthält. ## perinco_scanner ```mermaid erDiagram agents ||--o{ matches : bewertet_fuer projects ||--o{ matches : bewertet portals agents { int id PK varchar name varchar email "für 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 Börse" 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 über dieselben Projekte nichts kostet. - **`projects` wird nach 7 Tagen aufgeräumt** (`cleanupOldProjects`), die Treffer verschwinden per `ON DELETE CASCADE` mit. - **`settings` ist ein Schlüssel-Wert-Speicher.** Geheimnisse (`openai_api_key`, `smtp_pass`) liefert die API nur maskiert als `********` aus; ein leeres oder maskiertes Feld beim Speichern lässt den gespeicherten Wert unberührt. ## Statuswege ```mermaid stateDiagram-v2 [*] --> new : Treffer wird angelegt new --> reviewed : "Als Gesehen markieren" new --> applied : "Als Beworben markieren" reviewed --> applied applied --> new : "Zurück auf Neu" reviewed --> new new --> rejected : "Passt nicht" reviewed --> rejected applied --> rejected rejected --> new : "Zurück ins Dashboard" rejected --> [*] : loeschen new --> [*] : loeschen ``` `rejected` ist der einzige Status, der aus dem Dashboard verschwindet und stattdessen im Reiter *Unpassend* auftaucht. Solche Treffer zählen auch in den Kennzahlen nicht mit.