Files
perinco-CRM/docs/02-datenmodell.md
T
Sascha WolfandClaude Opus 5 8741a9ae0c feat: Interviews, Mail-Papierkorb, Kontaktart
Drei Aenderungen aus dem Alltagsgebrauch:

- Der Kalender heisst jetzt "Interviews" - dafuer wird er benutzt. Der Wert in
  ViewState wandert mit, damit Menue und Kopfzeile nicht auseinanderlaufen.
  Sichtbare Reste ("Kalenderfarbe") heissen jetzt Terminfarbe.

- Mails haben einen Papierkorb, der unter dem Menuepunkt aufklappt. Loeschen
  heisst dabei ausschliesslich: im CRM. Im Postfach crm@perinco.de bleibt die
  Mail unberuehrt - Schreibrechte darauf hat die Anwendung nicht. Damit der
  Delta-Abgleich geloeschte Mails nicht wieder einliest, bleibt nach dem Leeren
  eine leere Zeile als Merker stehen (crm_purged); runMailSync() ueberspringt
  alles, was im CRM geloescht ist. Zurueckholen und Leeren darf jeder
  Angemeldete, Leeren nach einer Warnung.

- Der Kontaktstatus (Neu .. Verloren) wird zur Kontaktart: Recruiter, Partner,
  Endkunde. Die Spalte heisst jetzt kind, weil "Status" das Feld falsch
  benannte. migrateLeadKind() stellt bestehende Datenbanken um und setzt alle
  vorhandenen Kontakte auf Recruiter - welche Art jemand ist, sagt der alte
  Status nicht (so mit dem Nutzer entschieden).

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

9.8 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-Übernahme aus der eigenständigen App. Gleiche Tabellennamen, gleiche Spalten - nur in einer eigenen Datenbank, damit beide Anwendungen parallel laufen können.

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 "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 .. Verloren"
        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
    }
    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 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).
  • 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.

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.

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

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

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.