# 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 entra_oid UK "Objekt-ID des Microsoft-Kontos, NULL = nie per SSO angemeldet" varchar name enum role "admin | user" varchar color "Terminfarbe, NULL = aus Id abgeleitet" longtext avatar "Data-URL" varchar theme "Farbwelt, NULL = perinco" varchar theme_mode "light | dark, NULL = folgt dem System" 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 varchar consultant "fuer welchen Berater" int acquisition_id "Herkunft in der Akquise, ohne FK" varchar link "Ausschreibung, http(s)" 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 stage_changed_at "letzter Phasenwechsel" int stage_changed_by "wer verschoben hat, ohne FK" 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, mehrere kommagetrennt" 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" varchar email "nur bei consultant" tinyint notify "Mail bei neuem Projekt, Vorgabe 1" } 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. - **Der Berater steht an der Opportunity, nicht nur in ihren Notizen.** Bis zum 09.09.2026 schrieb die Übernahme ihn in den Herkunftsvermerk, weil `crm_opportunities` kein Feld dafür hatte - in Pipeline, Liste und Kacheln war damit nicht zu sehen, für wen ein Projekt läuft. Jetzt trägt `crm_opportunities.consultant` genau einen Namen; bei mehreren Beratern in der Akquise entstehen mehrere Opportunities. Bestehende Zeilen füllt `migrateOpportunityConsultant()` in `db.js` aus den Notizen nach - nur dort, wo die Spalte leer ist, damit der Lauf wiederholbar bleibt und nichts überschreibt. - **Mehrere Berater stehen in einem Feld.** `crm_acquisitions.consultant` nimmt eine kommagetrennte Liste auf (`VARCHAR(500)`), keine eigene Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in `crm_acquisition_options` ist eine Pflegehilfe und kein Fremdschlüssel. Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem Feld. Zerlegt wird an drei Stellen - im Verlauf, in der Auswertung und bei der Übernahme; die Funktionen dafür stehen in `server/src/acquisitionRules.js`, das Gegenstück fürs Frontend in `src/types.ts`. - **Der Verlauf zählt je Berater.** `crm_acquisition_history` hält einen Berater pro Zeile (Primärschlüssel `found_on, consultant`), auch wenn der Eintrag drei hatte: ein Fund für drei Berater sind drei versendete Profile. Die Summe im Balkendiagramm liegt deshalb über der Gesamtzahl *Bewerbungen*, die weiter die Einträge zählt. - **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. - **Letzter Phasenwechsel.** `crm_opportunities.stage_changed_at` (UTC) und `stage_changed_by` setzt `PATCH /api/opportunities/:id` nur, wenn sich die Phase wirklich ändert - Anlegen und Kopieren zählen nicht. Der Dialog zeigt daraus unter „Angelegt von" die Zeile „Verschoben von … am … um … in …". Es ist nur der letzte Wechsel, kein Verlauf; Opportunities von vor dem 01.10.2026 haben keinen Eintrag, bis sie das nächste Mal verschoben werden. - **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 gehört dann aber ins **Archiv** und nicht in den **Eingang**. Übernommene Einträge bleiben in ihrer Liste stehen und sind dort als *Übernommen* gekennzeichnet; erst als **Rückfaller** (`RETURNED`: übernommen und die Opportunity im Papierkorb oder weg) wechseln sie ins Archiv, unabhängig vom Alter. Das wird aus dem Zustand der Opportunity abgeleitet und nicht gespeichert: wird sie zurückgeholt, gilt der Eintrag von selbst wieder als übernommen. Die Regeln dafür stehen in `server/src/acquisitionRules.js`. Aus derselben Logik folgt, was **Zurück in den Eingang** (`POST /:id/reopen`) tut: Es setzt `found_at` auf heute und leert `converted_at` samt `opportunity_id` - ohne das Zurücksetzen von `converted_at` bliebe ein Rückfaller im Archiv stehen, gleich welches Datum er trägt. Ein Kennzeichen „wiederhergestellt" gibt es nicht; es wäre der Zustand, den diese Tabelle gerade nicht führt. - **Der Rückweg von der Opportunity** (`POST /api/opportunities/:id/acquisition`) braucht die Gegenrichtung: `crm_acquisitions.opportunity_id` kennt nur die *erste* Opportunity einer Übernahme, bei mehreren Beratern entstehen aber mehrere. Deshalb trägt jede Opportunity `acquisition_id` (seit 17.09.2026, ohne Fremdschlüssel - die Akquise löscht nach 90 Tagen). `schema.sql` trägt den Verweis für ältere Übernahmen nach, kann das aber nur für die erste. Der Rückweg legt alle lebenden Opportunities mit dieser `acquisition_id` in den Papierkorb und leert beim Eintrag `converted_at` und `opportunity_id`; `found_at` bleibt. Wird eine davon wiederhergestellt, setzt `/restore` den Verweis wieder, sofern der Eintrag frei ist. - **Der Link zur Ausschreibung** steht seit 23.09.2026 auch in `crm_opportunities.link` (VARCHAR 1024). Die Übernahme kopiert ihn aus `crm_acquisitions.link`, `/copy` von der Quelle. Beide Seiten prüfen mit derselben Regel (`server/src/link.js`: nur http/https, ohne Schema wird `https://` ergänzt). `schema.sql` trägt den Link für ältere Übernahmen über `acquisition_id` nach - nur in leere Felder. - **Der Papierkorb ist die Ausnahme davon.** Löschen von Hand ist eine Entscheidung und kein Ablauf, deshalb steht sie in `deleted_at` (plus `deleted_by`, ohne Fremdschlüssel - siehe `schema.sql`). Vorher war das Löschen ein echtes `DELETE` und damit endgültig; jetzt bleibt die Zeile 90 Tage stehen. Was dort liegt, blenden **alle** anderen Abfragen aus (`NOT_DELETED`): Eingang, Archiv, Menüzahlen und Dashboard. Der Papierkorb rechnet ab `deleted_at`, nicht ab `found_at` - der Aufräumlauf über das Funddatum lässt gelöschte Zeilen deshalb ausdrücklich liegen, sonst wäre ein alter Fund im Papierkorb sofort weg. `POST /:id/restore` räumt nur `deleted_at`/`deleted_by` weg und lässt das Funddatum in Ruhe: der Eintrag soll dort wieder auftauchen, wo er hingehört. ### 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 tinyint already_applied "in Akquise/Opportunities gefunden" text applied_refs "JSON: type, id, title, consultant, reason" datetime applied_check_at "NULL = noch nicht abgeglichen" 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. `openai_api_key` wird seit dem 23.09.2026 unter *Einstellungen → KI* gepflegt (siehe [14 KI](14-ki.md)); die Modellwahl dazu steht in `crm_settings` als `ai_model_matching`, `ai_model_applied` und `ai_model_search`. ## 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.