Die Uebernahme aus der Akquise schrieb den Link bisher nur in die Notizen. Jetzt hat die Opportunity eine Spalte `link`, gefuellt bei Uebernahme und Kopie; im Dialog steht sie rechts neben der Phase mit Knopf "Oeffnen". schema.sql traegt den Link fuer aeltere Uebernahmen nach. Die Pruefung liegt in server/src/link.js und gilt fuer Akquise und Opportunities gleich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
18 KiB
Markdown
407 lines
18 KiB
Markdown
# 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 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"
|
|
}
|
|
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.
|
|
- **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.
|
|
|
|
## 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.
|