# 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. ### 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), `error`, `contact_id` (nach der Übernahme), `uploaded_by`. ### 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= node src/migrate.js # Probelauf SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY= node src/migrate.js --write # schreibt ```