Files
perinco-CRM/docs/02-datenmodell.md
T
Sascha WolfandClaude Opus 5 6e66bd39d7 feat(akquise): Papierkorb fuer geloeschte Eintraege
Loeschen in der Akquise war ein echtes DELETE: der Eintrag stand danach
in keiner Liste, auch nicht im Archiv, und war nicht zurueckholbar. Jetzt
setzt es `deleted_at`/`deleted_by`, die Zeile bleibt 90 Tage stehen.

Der Papierkorb ist die eine Ausnahme von der Regel, dass diese Tabelle
keinen Zustand fuehrt: Loeschen von Hand ist eine Entscheidung, kein
Ablauf. Was dort liegt, blenden alle anderen Abfragen ueber NOT_DELETED
aus - Eingang, Archiv, Menuezahlen und Dashboard. Der Aufraeumlauf ueber
`found_at` laesst geloeschte Zeilen ausdruecklich liegen, sonst waere ein
alter Fund im Papierkorb sofort weg; der Papierkorb raeumt ab
`deleted_at`.

Zwei Rueckwege, deshalb zwei Routen. `/reopen` (bisher `/restore`) holt
aus dem Archiv in den Eingang und setzt das Funddatum auf heute.
`/restore` holt aus dem Papierkorb zurueck und laesst das Funddatum in
Ruhe - der Eintrag soll dort auftauchen, wo er hingehoert, also im
Archiv, wenn er alt ist. Die Meldung nennt das Ziel.

Dazu `DELETE /:id/permanent` und `POST /trash/empty`. Im Papierkorb wird
nicht gepflegt: Bearbeiten und Uebernehmen antworten 409.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 10:17:21 +02:00

357 lines
14 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 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 .. 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"
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.
- **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 soll aber nicht wieder im **Eingang** auftauchen - im **Archiv**
schon. Ob eine übernommene Bewerbung dort wieder erscheint, wird aus dem
Zustand der Opportunity abgeleitet (Papierkorb oder weg), nicht gespeichert:
wird sie zurückgeholt, verschwindet der Eintrag von selbst wieder. 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 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
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.