210 lines
8.9 KiB
Markdown
210 lines
8.9 KiB
Markdown
# 02 Datenmodell
|
||
|
||
Eine Datenbank, `icon_CRM`, alle Tabellen mit dem Präfix `icon_`. Das Schema
|
||
steht als idempotentes SQL in `server/src/schema.sql` und wird beim Start des
|
||
Backends angewandt; spätere Feldänderungen liegen als Migrationen in
|
||
`server/src/db.js`.
|
||
|
||
## Überblick
|
||
|
||
```mermaid
|
||
erDiagram
|
||
icon_users ||--o{ icon_companies : "erstellt / zustaendig"
|
||
icon_users ||--o{ icon_contacts : "erstellt / zustaendig"
|
||
icon_users ||--o{ icon_history : "hat getan"
|
||
icon_companies ||--o{ icon_contacts : "beschaeftigt"
|
||
icon_companies ||--o{ icon_employments : "Stationen"
|
||
icon_contacts ||--o{ icon_employments : "Werdegang"
|
||
icon_contacts ||--o{ icon_interviews : "Gespraeche"
|
||
icon_contacts ||--o{ icon_connections : "kennt"
|
||
icon_companies ||--o{ icon_tenders : "Kunde"
|
||
icon_tenders ||--o{ icon_tender_contacts : "zugewiesen"
|
||
icon_contacts ||--o{ icon_tender_contacts : "zugewiesen"
|
||
icon_companies ||--o{ icon_projects : "Kunde"
|
||
icon_contacts ||--o{ icon_projects : "Experte"
|
||
icon_companies ||--o{ icon_leads : "Ziel"
|
||
icon_contacts ||--o{ icon_leads : "Ansprechpartner"
|
||
icon_leads ||--o{ icon_lead_todos : "Checkliste"
|
||
icon_contacts ||--o{ icon_cv_imports : "wurde daraus"
|
||
```
|
||
|
||
`icon_mentions`, `icon_options` und `icon_settings` hängen an keiner
|
||
Fremdschlüsselbeziehung und stehen deshalb nicht im Bild.
|
||
|
||
## Die Tabellen im Einzelnen
|
||
|
||
### icon_users
|
||
|
||
| Feld | Typ | Bedeutung |
|
||
| --- | --- | --- |
|
||
| `username`, `email` | VARCHAR, je eindeutig | Anmeldung geht mit beidem |
|
||
| `name` | VARCHAR | Anzeigename |
|
||
| `role` | ENUM `admin`,`user` | siehe [01 Architektur](01-architektur.md) |
|
||
| `password_hash` | VARCHAR(255) | bcrypt; Altbestand SHA-256, wird beim Login umgeschrieben |
|
||
| `color` | VARCHAR(9) | persönliche Farbe für Avatar und Historie |
|
||
| `avatar` | MEDIUMTEXT | Foto als Data-URL |
|
||
| `theme`, `theme_mode` | VARCHAR / ENUM | Design und hell/dunkel, gilt geräteübergreifend |
|
||
| `is_active` | TINYINT | deaktiviert = sofort ausgesperrt |
|
||
|
||
### icon_companies
|
||
|
||
`name`, `group_name` (Konzern), `industry` (aus den Stammdaten), `employees`,
|
||
`website`, `address`, `logo` (Data-URL), `rating` (0–5 Sterne), `is_customer`,
|
||
`customer_state` (`aktiv`/`alumni`), `freelancers`
|
||
(`unbekannt`/`ja`/`nein`), `notes`, `created_by`, `owner_id`.
|
||
|
||
**Ein Kunde ist kein eigener Datentyp**, sondern ein Unternehmen mit
|
||
`is_customer = 1`. Der Umschalter in der Oberfläche filtert nur darauf – es gibt
|
||
keine zweite Liste, die auseinanderlaufen könnte.
|
||
|
||
### icon_contacts
|
||
|
||
| Feld | Bedeutung |
|
||
| --- | --- |
|
||
| `first_name`, `last_name`, `position` | Person |
|
||
| `email`, `phone`, `linkedin`, `avatar` | Kontaktdaten und Foto |
|
||
| `company_id` | aktueller Arbeitgeber |
|
||
| `kind` | Art: Experte, Ansprechpartner, Lead – seit einer Migration VARCHAR statt ENUM, weil die Liste in den Stammdaten wächst |
|
||
| `status` | Interessiert, Interview, Gebucht, Inaktiv |
|
||
| `lead_source` | woher der Kontakt kam |
|
||
| `contact_state` | unbekannt / persönlich / kein Kontakt |
|
||
| `freelancers` | arbeitet freiberuflich |
|
||
| `bio` | Notiz, darf @-Erwähnungen enthalten |
|
||
| `tags` | Schlagworte, kommagetrennt |
|
||
| `rate_onsite`, `rate_remote` | **Stundensätze** in Euro, `NULL` = nicht hinterlegt |
|
||
| `cv_url`, `cv_file` | Link bzw. hochgelegter Lebenslauf |
|
||
|
||
**Experten und Ansprechpartner liegen in einer Tabelle.** Ein Ansprechpartner,
|
||
der die Firma wechselt und plötzlich als Experte interessant wird, ist derselbe
|
||
Mensch und behält seine Historie.
|
||
|
||
> `rate_onsite`/`rate_remote` stammen aus `hourlyRateOnsite`/`hourlyRateRemote`
|
||
> des alten CRM und sind **Stundensätze**. Die Oberfläche schreibt die Einheit
|
||
> überall mit (`125,00 €/h`), und die erweiterte Suche weiß es ebenfalls.
|
||
|
||
### icon_employments
|
||
|
||
Werdegang: `contact_id`, `company_id`, `position`, `started_on`, `ended_on`,
|
||
`is_current`. Daraus speist sich der Zweig **Verlassen** im Stammbaum eines
|
||
Unternehmens. Ein Arbeitgeberwechsel schreibt hier automatisch fort; daneben
|
||
lassen sich Stationen in der Kontaktkarte von Hand pflegen.
|
||
|
||
Zwei Regeln halten Station und Kontakt zusammen (`server/src/routes/contacts.js`):
|
||
|
||
* Genau **eine** Station kann `is_current = 1` sein. Wird eine dazu erklärt,
|
||
bekommen die übrigen ein Enddatum.
|
||
* Die laufende Station **ist** der Arbeitgeber: `icon_contacts.company_id` wird
|
||
mitgeführt – wird sie beendet oder gelöscht, steht dort `NULL`. Beides
|
||
auseinanderlaufen zu lassen wäre die Quelle für „im Kontakt steht A, im
|
||
Werdegang B".
|
||
|
||
### icon_interviews
|
||
|
||
`contact_id` oder `company_id`, `user_id`, `held_on`, `notes` – wann wurde mit
|
||
wem gesprochen.
|
||
|
||
### icon_history
|
||
|
||
Jede Tätigkeit: `entity_type` (contact, company, tender, project, lead, user,
|
||
system), `entity_id`, `entity_label`, `action`, `text`, `changes` (JSON mit
|
||
Vorher/Nachher je Feld), `user_id`, `created_at`.
|
||
|
||
Geschrieben wird **ausschließlich** über `logHistory()` in
|
||
`server/src/history.js` – nicht von den Routen selbst. Dadurch gilt an einer
|
||
Stelle, was überall gelten soll: in jeder Historie steht, welcher Benutzer was
|
||
geändert hat.
|
||
|
||
### icon_mentions
|
||
|
||
Index der `@`-Erwähnungen: `source_type`/`source_id` → `target_type`/`target_id`,
|
||
eindeutig je Paar. Ohne diesen Index müsste eine Karte jeden Freitext der
|
||
Datenbank durchsuchen, um zu zeigen, wo sie genannt wurde.
|
||
|
||
### icon_connections
|
||
|
||
Wer kennt wen: `contact_a`, `contact_b`, `kind`, `strength`. Das Paar wird
|
||
aufsteigend gespeichert, ein Unique-Key verhindert, dass dieselbe Verbindung
|
||
zweimal entsteht.
|
||
|
||
### icon_tenders, icon_tender_contacts
|
||
|
||
| Feld | Bedeutung |
|
||
| --- | --- |
|
||
| `title`, `source`, `link` | Ausschreibung und Herkunft |
|
||
| `company_id` | Kunde |
|
||
| `status` | Aktiv, Inaktiv, Ausgelaufen, **Archiviert** |
|
||
| `stage` | Phase: Ausschreibung → Kontakte sichten → Interview → Kunden Interview → Vertragsgestaltung → Abschluss → Geschlossen |
|
||
| `online` | erscheint auf der ICON Website |
|
||
| `notes` | **der veröffentlichte Ausschreibungstext**, als HTML |
|
||
| `details` | im gesamten Bestand leer, ungenutzt |
|
||
| `deadline` | Frist |
|
||
|
||
> **Die wichtigste Zeile dieses Kapitels:** der Text der Website steht in
|
||
> `notes`, nicht in `details`. Wer den Editor auf `details` legt, zeigt ein
|
||
> leeres Feld und schreibt am veröffentlichten Text vorbei.
|
||
|
||
`icon_tender_contacts` verbindet Ausschreibung und Kontakt (`role` = Rolle im
|
||
Projekt).
|
||
|
||
### icon_projects
|
||
|
||
`title`, `company_id`, `contact_id`, `started_on`, `ended_on`, `status`
|
||
(Aktiv / Nicht aktiv / Abgeschlossen), `rate`, `notes` (HTML). Im Menü heißt die
|
||
Ansicht **Laufende Projekte**.
|
||
|
||
Im übernommenen Bestand steht im Kopf von `notes` ein Textblock aus dem alten
|
||
CRM (`KUNDE:`, `KONTAKT:`, `EXPERTE:`, `START:`, `ENDE:`), darunter der
|
||
übernommene Ausschreibungstext als HTML. `src/lib/richText.ts` macht daraus im
|
||
Editor Absätze plus HTML – deshalb der Sonderfall „Text, dann HTML".
|
||
|
||
### icon_leads, icon_lead_todos
|
||
|
||
`title`, `company_id`, `contact_id`, `stage` (acht Phasen, erste = `Fuer
|
||
Spaeter`, in der Oberfläche **Backlog**), `contact_state`, `archived`,
|
||
`position` (Reihenfolge in der Spalte), `notes`, `remind_at`, `reminder_days`,
|
||
`last_contact_at`, `owner_id`.
|
||
|
||
`icon_lead_todos`: `lead_id`, `text`, `done`, `due_on`, `position` – die
|
||
Checkliste, deren Fortschritt auf der Kachel als Prozentwert steht.
|
||
|
||
### icon_cv_imports
|
||
|
||
`filename`, `original_name`, `mime`, `size_bytes`, `status`
|
||
(`eingang` → `screening` → `auswahl`, oder `verworfen`), `extracted` (das JSON
|
||
der KI), `photo`, `error`, `contact_id` (nach der Übernahme), `uploaded_by`.
|
||
|
||
`photo` ist das im Lebenslauf gefundene Bewerbungsfoto, unverändert als
|
||
Data-URL. Es ist eine **Zwischenablage**: beim Anlegen des Kontakts geht das
|
||
Bild verkleinert an `icon_contacts.avatar` und wird hier geleert.
|
||
|
||
### icon_options
|
||
|
||
Die gepflegten Auswahllisten: `field` (`industry`, `contact_kind`), `value`,
|
||
`position`. Umbenennen zieht den Bestand mit; Entfernen geht nur, solange ein
|
||
Wert nirgends benutzt wird.
|
||
|
||
### icon_settings
|
||
|
||
Schlüssel/Wert, unter anderem für Migrationsmerker – damit eine einmalige
|
||
Umstellung nicht bei jedem Start erneut läuft.
|
||
|
||
## Migration aus Supabase
|
||
|
||
`server/src/migrate.js` übernimmt den Altbestand einmalig und wiederholbar
|
||
(Unternehmen und Kontakte werden über ihren Namen wiedererkannt):
|
||
|
||
| alt | neu |
|
||
| --- | --- |
|
||
| `icon_customers` | `icon_companies` mit `is_customer = 1` |
|
||
| `icon_contacts` | `icon_contacts`, `kind = 'Ansprechpartner'` |
|
||
| `icon_experts` | `icon_contacts`, `kind = 'Experte'` |
|
||
| `hourlyRateOnsite`, `hourlyRateRemote` | `rate_onsite`, `rate_remote` |
|
||
| Status `Verfügbar` | `Interessiert` |
|
||
| `kanbanStatus: Experten sichten` | Phase `Kontakte sichten` |
|
||
|
||
```bash
|
||
cd /var/www/icon_CRM/server
|
||
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js # Probelauf
|
||
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js --write # schreibt
|
||
```
|