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