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>
9.6 KiB
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_leadsfuehrt zwei Bestaende.mail_pool = 0ist der gepflegte Kontaktbestand,mail_pool = 1der 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, damitcrm_mail_linksundcrm_mail_contact_candidatesfuer beide Bestaende gelten (11 Postfachanbindung).tagsist Text, kein JSON-Typ. Bewusst, damit es auf aelteren MariaDB-Versionen ohne JSON-Funktionen laeuft. Das Backend serialisiert.crm_lead_connectionsspeichert das Paar sortiert (kleinere Id infrom_lead_id). Der Unique-Key verhindert damit auch die Umkehrung als Duplikat, obwohl die Verbindung ungerichtet gedacht ist.avatarist 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 deshalbgraph_id_hash(SHA-256 als Hex,CHAR(64)). received_atsteht in UTC, anders alscrm_events.starts_at(lokale Wandzeit). Graph liefert UTC, und eine stille Umrechnung beim Speichern waere eine Fehlerquelle. Das Frontend rechnet um (parseMailDateinsrc/lib/mailApi.ts).crm_mail_linkserlaubt 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_settingsenthaelt keine Geheimnisse. Zugangsdaten fuer Microsoft 365 stehen ausschliesslich inserver/.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"
}
matcheshat 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.projectswird nach 7 Tagen aufgeraeumt (cleanupOldProjects), die Treffer verschwinden perON DELETE CASCADEmit.settingsist 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.