CV-Import: Bewerbungsfoto uebernehmen

This commit is contained in:
2026-09-14 16:24:13 +02:00
parent 41ef90076c
commit 0276e5621e
+199 -195
View File
@@ -1,195 +1,199 @@
# 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=<anon-key> node src/migrate.js # Probelauf
SUPABASE_URL=https://icondb.perinco.de SUPABASE_KEY=<anon-key> node src/migrate.js --write # schreibt
```
# 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), `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
```