Files
perinco-CRM/docs/02-datenmodell.md
T
Sascha WolfandClaude Opus 5 ea55acdb5c feat(kontakte): Vorbereich "Aus Mails" mit Dublettenabgleich
Der Postfachabgleich hat gefundene Personen bisher direkt in den
Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt
mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem
gescheitert.

Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als
Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der
Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in
Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf.

Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und
crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so
behält der Vorbereich seine Mailbezüge.

Übernehmen kennt zwei Wege:
- neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit
- Dublette (Adresse, sonst voller Name): gold markiert, abweichende
  Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den
  vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer
  alten Signatur soll nichts Gepflegtes stillschweigend ersetzen.

db.js verschiebt beim ersten Start die vorhandenen Kontakte mit
source = 'Mail' in den Vorbereich, einmalig über den Merker
leads_mail_pool_migrated in crm_settings.

Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte
und Scanner benutzen jetzt denselben Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:59:14 +02:00

9.6 KiB

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-Uebernahme aus der eigenstaendigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer eigenen Datenbank, damit beide Anwendungen parallel laufen koennen.

perinco_CRM

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"
        varchar source "z.B. 'Mail' bei Herkunft Postfach"
        tinyint mail_pool "1 = Vorbereich 'Aus Mails', noch kein Kontakt"
        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
    }

Vier Eigenheiten, die man kennen muss:

  • crm_leads fuehrt zwei Bestaende. 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, Kalender und Netzwerk; der Vorbereich hat seine eigene Ansicht. Eine Spalte statt einer zweiten Tabelle, damit crm_mail_links und crm_mail_contact_candidates fuer beide Bestaende gelten (11 Postfachanbindung).
  • 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.

Postfach crm@perinco.de

Diese Tabellen liegen in derselben Datenbank, haengen aber nur ueber crm_mail_links und crm_mail_contact_candidates.lead_id am Rest. Fachliche Beschreibung in 11 Postfachanbindung.

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 Uebernahme"

    crm_mail_messages {
        int id PK
        varchar graph_id "Id aus Microsoft Graph"
        char graph_id_hash UK "SHA-256 hex, traegt 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 "entschaerft fuer die Anzeige"
        tinyint has_attachments
        tinyint is_deleted "im Postfach geloescht"
    }
    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 | uebernommen | verworfen | dublette"
        int lead_id FK "gesetzt ab uebernommen"
    }
    crm_mail_sync_state {
        tinyint id PK "immer 1"
        varchar mailbox
        text delta_link "naechster 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 ueber 150 Zeichen zu lang fuer einen Unique-Index unter utf8mb4; die Eindeutigkeit traegt 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 waere eine Fehlerquelle. Das Frontend rechnet um (parseMailDate in src/lib/mailApi.ts).
  • crm_mail_links erlaubt NULL in beiden Fremdschluesseln. MariaDB laesst in einem Unique-Index mehrere NULL zu - die beiden Schluessel sperren damit genau die Doppelung desselben Paares, und eine Mail kann an mehreren Kontakten und mehreren Opportunities haengen.
  • crm_settings enthaelt keine Geheimnisse. Zugangsdaten fuer Microsoft 365 stehen ausschliesslich in server/.env, damit ein Datenbank-Dump keine Secrets enthaelt.

perinco_scanner

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

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.