Files
Sascha WolfandClaude Opus 5.5 eaef454442 feat(akquise): Mail an Berater bei neuem Projekt
Wird in der Akquise ein Projekt mit Berater angelegt (oder ein Berater
beim Bearbeiten hinzugefuegt), bekommt er eine Mail ueber Graph sendMail
aus crm@perinco.de; Reply-To ist der eintragende Benutzer. Je Berater
abschaltbar unter Einstellungen -> Akquise (Spalte notify, Vorgabe an).
Versand laeuft nach der Antwort und kippt das Speichern nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 09:38:13 +02:00

34 KiB

09 Quellcode-Referenz

Was in welcher Datei steckt und welche Zusagen sie einhält. Gedacht zum Nachschlagen, nicht zum Durchlesen.

Backend

server/src/server.js

Baut die Express-App. Die Reihenfolge ist bedeutsam:

flowchart TD
    J["express.json({ limit: '6mb' })<br/>Avatare kommen als Data-URL"] --> C["cors (nur für den Dev-Server)"]
    C --> H["/api/health"]
    H --> R["/api/auth, /users, /leads,<br/>/connections, /events, /opportunities"]
    R --> S["/api/scanner  (requireAuth + Router)"]
    S --> F["/api → 404 JSON<br/>fängt alles Unbekannte"]
    F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu groß, 500 Rest"]

Beim Start: initDatabase() (CRM-Schema, Admin-Seed) → initScannerDatabase() (darf fehlschlagen, dann läuft das CRM ohne Scanner) → Scheduler, sofern SCANNER_SCHEDULER !== 'false'.

server/src/db.js

Export Zweck
pool mysql2-Pool, connectionLimit: 8, dateStrings: true
initDatabase() führt schema.sql Statement für Statement aus, dann migrateLeadKind(), migrateMailPool(), seedAcquisitionOptions(), dropProviderOptions(), ensureAdminUser()

dateStrings: true ist eine bewusste Entscheidung: Termine kommen als YYYY-MM-DD HH:MM:SS ohne Zeitzonenumrechnung an. Die Terminansicht rechnet mit Wandzeit; ein Date-Objekt aus dem Treiber würde beim Serialisieren nach UTC verschieben.

Die beiden Einmal-Umstellungen laufen bei jedem Start, tun aber nur beim ersten Mal etwas:

  • migrateLeadKind() macht aus dem Vertriebsstatus eines Kontakts seine Art. Erkannt wird der Altstand an der Spalte crm_leads.status selbst - liegt schon kind vor, passiert nichts. Die Auswahl wird erst auf alte plus neue Werte erweitert, dann werden alle Altwerte auf Recruiter gesetzt, dann wird umbenannt und auf die drei neuen Werte verengt. Direkt zu verengen würde jeden Altwert zu '' machen.
  • migrateMailPool() schiebt die vom Postfachabgleich angelegten Kontakte in den Vorbereich. Sie merkt sich das per Schalter in crm_settings, weil an den Daten selbst nicht zu erkennen ist, ob sie schon gelaufen ist.
  • seedAcquisitionOptions() legt den Startbestand der Akquise-Dropdowns an: die Kanäle, die vorher im Quelltext standen, plus alles, was in vorhandenen Einträgen als Freitext steht. Auch hier ein Schalter in crm_settings - ohne ihn wäre jeder Neustart ein Wiederherstellen von Werten, die jemand bewusst entfernt hat.
  • dropProviderOptions() nimmt Anbieter wieder aus den Auswahllisten heraus - das Feld ist Freitext mit Vorschlägen. Erkannt am Spaltentyp: steht provider noch im ENUM, ist die Umstellung offen. Die Zeilen müssen vor dem Verengen weg, sonst weist MariaDB den ALTER ab.
  • addConsultantEmail() legt crm_acquisition_options.email an und belegt die vorhandenen Berater einmalig mit vorname.nachname@perinco.de vor (guessPerincoEmail(), Merker consultant_email_seeded). Namen ohne Nachnamen bleiben leer. Dieselbe Funktion legt notify (Vorgabe 1) an.

ensureAdminUser() legt nur an, wenn crm_users leer ist. Ohne ADMIN_PASSWORD erzeugt es ein Zufallspasswort und schreibt es einmalig ins Log - im Quelltext steht kein Passwort.

server/src/middleware/auth.js

Export Zweck
signToken(user) JWT mit sub und role, Laufzeit aus JWT_EXPIRES_IN (7 Tage)
requireAuth Token prüfen und Benutzer frisch aus der DB laden
requireAdmin 403, wenn req.user.role !== 'admin'

Dass requireAuth bei jeder Anfrage die Datenbank liest, ist Absicht: ein deaktivierter oder gelöschter Benutzer ist sofort draußen und nicht erst, wenn sein Token abläuft. secret() wirft, wenn JWT_SECRET fehlt oder kürzer als 16 Zeichen ist - lieber ein harter Startfehler als eine schwache Signatur.

Die Spaltenliste ist ausgeschrieben (kein SELECT *), damit das Passwort-Hash nicht am Request hängt. Sie muss dafür alles enthalten, was publicUser() ausliefert. Fehlte dort theme bzw. theme_mode, antwortete GET /api/auth/me mit null, und die Oberfläche fiel beim Neuladen auf das Standarddesign zurück - obwohl die Wahl in der Datenbank stand. server/test/sessionUser.test.mjs vergleicht beide Listen, damit ein neues Feld nicht wieder still verloren geht.

server/src/userFields.js

normalizeColor und normalizeAvatar, benutzt von routes/auth.js und routes/users.js. Farben nur als #rrggbb, Avatare nur als Bild-Data-URL oder http(s)-Adresse, jeweils längenbegrenzt.

Die CRM-Router

Alle unter router.use(requireAuth).

Router Endpunkte Rechte
auth.js POST /login, GET /me, PATCH /me, POST /change-password; SSO: GET /sso/status, GET /sso/start, GET /sso/callback, POST /sso/ticket, GET /sso/config /login und die vier offenen SSO-Endpunkte ohne Anmeldung, /sso/config Admin, Rest angemeldet
users.js GET /directory; danach requireAdmin: GET /, POST /, PATCH /:id, DELETE /:id /directory für alle, Rest Admin
leads.js GET /, POST /, PATCH /:id, DELETE /:id; Vorbereich: GET /mail-pool/sources, POST /mail-pool/promote, POST /:id/promote jeder Angemeldete
connections.js GET /, POST /, PATCH /:id, DELETE /:id jeder Angemeldete
events.js GET /, POST /, PATCH /:id, DELETE /:id anlegen jeder; ändern/löschen nur Ersteller oder Admin
opportunities.js GET /?trash=true, POST /, PATCH /:id, DELETE /:id (Papierkorb), POST /:id/restore, POST /:id/acquisition, POST /:id/copy, POST /trash/empty ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. /:id/acquisition nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben acquisition_id in den Papierkorb, beim Eintrag converted_at/opportunity_id leeren (Funddatum bleibt), in einer Transaktion. /restore hängt den Eintrag wieder an die Opportunity, wenn er frei ist. /:id/copy ({ consultants, stage }) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und acquisition_id kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete
acquisitions.js GET /?view=eingang|archiv|papierkorb|alle, POST /, PATCH /:id, DELETE /:id (Papierkorb), DELETE /:id/permanent, POST /trash/empty, POST /:id/opportunity, POST /:id/reopen, POST /:id/restore; Auswahllisten: GET/POST /options, PATCH/DELETE /options/:id Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: /reopen holt aus dem Archiv in den Eingang (setzt found_at auf heute und leert converted_at/opportunity_id - sonst hält die Übernahme den Eintrag im Archiv fest), /restore holt aus dem Papierkorb zurück (leert nur deleted_at/deleted_by, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist
badges.js GET / Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist null, wenn dessen Datenbank fehlt. Für Administratoren zusätzlich ms_secret, sobald das Client-Secret in weniger als 30 Tagen abläuft
acquisitionNotify.js (kein Router) Berater-Mail bei neuem Projekt: addedConsultants() (wer ist neu), buildMessage() (Betreff + escaptes HTML), notifyConsultants() (Versand über Graph sendMail aus MS_MAILBOX, wirft nie). Aufgerufen von POST/PATCH in acquisitions.js nach der Antwort. Siehe 11 Postfach
link.js (kein Router) normalizeLink() für den Link zur Ausschreibung: nur http/https, ohne Schema wird https:// ergänzt, max. 1024 Zeichen. Geteilt von Akquise und Opportunities, damit ein übernommener Link nicht an einer anderen Prüfung scheitert
acquisitionRules.js (kein Router) Fristen und die SQL-Bedingungen IN_INBOX, IN_ARCHIVE, RETURNED, IS_OPEN (nur noch für den Löschlauf), NOT_DUE, OPPORTUNITY_GONE, NOT_DELETED, IN_TRASH. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu splitConsultants / joinConsultants / normalizeConsultants für das Feld Berater, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: src/types.ts)
dashboard.js GET /?from=&to=&bucket=tag|monat|quartal|jahr drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise und crm_acquisition_history; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin
mail.js GET /status, /messages, /messages/:id, /candidates; POST /messages/:id/links, /messages/:id/restore, /candidates/:id/adopt, /candidates/:id/reject, /trash/empty; DELETE /links/:id, /messages/:id; requireAdmin: GET/POST /settings, POST /test, POST /sync lesen, zuordnen und löschen jeder; einrichten Admin

Wiederkehrende Muster in diesen Routern:

  • emptyToNull() - leere Zeichenketten werden NULL, damit „nicht gesetzt" und „leer" in der Datenbank nicht zwei Zustände sind.
  • normalizeDate() prüft echte Kalendertage; der 31. Februar wird abgelehnt und nicht stillschweigend verschoben.
  • normalizeNumber() akzeptiert Komma als Dezimaltrennzeichen und begrenzt nach oben - ein Tagessatz von 10 Millionen ist ein Tippfehler.
  • parseTags() liest die als Text gespeicherten JSON-Arrays defensiv: kaputter Inhalt ergibt eine leere Liste, keinen Absturz.
  • GET /login antwortet bei unbekanntem Benutzer und falschem Passwort identisch, damit sich über die API keine Benutzernamen aufzählen lassen.
  • Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder gelöscht werden.

server/src/settings.js

getSetting, getSettings, setSetting auf crm_settings, dazu asBool und asInt. Alle Werte liegen als Text in der Datenbank. getSettings(defaults) nimmt die Vorgaben gleichzeitig als Positivliste der erlaubten Schlüssel - ein unbekannter Name kommt so nie in die Datenbank.

Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst ohne Geheimnisse: Zugangsdaten stehen in server/.env.

server/src/linkedinPhoto.js

Holt das Profilfoto zu einer LinkedIn-Adresse. Bedient wird damit POST /api/leads/linkedin-photo.

Export Zweck
normalizeProfileUrl(eingabe) macht aus linkedin.com/in/name eine https-URL, wirft bei fremden Hosts
fetchLinkedInPhoto(eingabe) Profilseite lesen, og:image ziehen, Bild laden, als Data-URL zurückgeben
PhotoError Fehler mit status; die Route reicht Status und Text unverändert durch

Der Weg in Stichpunkten und die Gründe dafür:

  • Über das Backend, nicht aus dem Browser. Ein fetch auf linkedin.com aus der Seite heraus scheitert an der Same-Origin-Policy.
  • Browser-Kennung im Kopf. Ohne User-Agent antwortet LinkedIn oft gar nicht erst.
  • Nur og:image/twitter:image - und sonst nichts. Im übrigen Quelltext wird bewusst nicht gesucht: eine Profilseite enthält Dutzende Adressen des LinkedIn-CDN (Firmenlogos, Beitragsbilder, die Fotos der „ähnlichen Profile" am Rand). Die erste davon zu nehmen, hängt einem Kontakt irgendwann das Gesicht einer fremden Person an. Es wird nichts angemeldet und nichts Geschütztes gelesen.
  • Herkunft wird geprüft. Das Bild muss über https von licdn.com kommen und /dms/image/ im Pfad haben - sonst könnte eine manipulierte Seite eine beliebige Adresse unterschieben.
  • Platzhalter werden abgewiesen. Statt eines Fotos liefert LinkedIn dann die graue Silhouette (ghost im Pfad) oder eine feste Grafik von static.licdn.com (/aero-v1/sc/h/...). Das passiert in zwei Fällen, die von außen nicht unterscheidbar sind: Das Profil hat kein Foto, oder dessen Sichtbarkeit steht auf „Meine Kontakte" statt „Alle LinkedIn-Mitglieder". Im zweiten Fall sieht man das Foto angemeldet sehr wohl - der Abruf hier ist aber nicht angemeldet. Die Meldung nennt deshalb beide Möglichkeiten.
  • Grenzen: 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild.

Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen Anfragen von einer Adresse), kommt eine Meldung im Klartext zurück - das ist der Normalfall, mit dem die Oberfläche rechnet, kein Serverfehler.

server/src/mail/

Postfachanbindung, fachlich in 11 Postfachanbindung.

Datei Inhalt
config.js .env lesen, MAIL_CONFIGURED, missingCredentials(), verstellbare Einstellungen mit Vorgaben (mailSettings())
graph.js Token mit Zwischenspeicher, graphRequest() mit Wiederholung bei 429/5xx und Beachtung von Retry-After, testConnection()
html.js htmlToText() (Zeilen bleiben erhalten, Linkziele werden hinter den Linktext gesetzt), sanitizeHtml()
extract.js extractCandidates(), parsePersonName(), parseSignature(), AUTO_ADOPT_MIN_CONFIDENCE
store.js Zustandszeile, upsertMessage(), insertCandidates(), linkMessage(), findLeadByEmail() je Bestand
contacts.js adoptCandidate(): Eintrag in „Aus Mails" (target: 'pool') oder richtiger Kontakt ('contacts'), sonst ergänzen; markCandidate()
sync.js ein Abgleichslauf, Delta-Seiten, Einzelsperre, Fehlerbehandlung
scheduler.js Minutentakt, Fälligkeit aus last_run_at, kein Zeitfenster

Eigenheiten, die man kennen muss:

  • graphRequest() nimmt Pfade und vollständige URLs - Delta-Links kommen absolut von Graph zurück.
  • Ein 401 verwirft das Token genau einmal und wiederholt; so überlebt ein Lauf ein serverseitig zurückgezogenes Token.
  • upsertMessage() lässt die Textspalten unberührt, wenn bodyText im übergebenen Objekt undefined ist. Ein reines Metadaten-Update darf den schon geholten Mailtext nicht löschen.
  • extractCandidates() verlangt im Fliesstext eine Anrede vor dem Namen. Ohne diese Bedingung liest der Musterabgleich jede Folge großgeschriebener Wörter als Namen.
  • Der Lauf ist einfach gesperrt (running in sync.js) - ein zweiter Aufruf kommt sofort mit skipped zurück, statt parallel zu laufen.

server/src/msSecret.js

Das Client-Secret der Microsoft-App-Registrierung, wenn es nicht aus der .env kommt, sondern in der Oberfläche hinterlegt wurde.

Export Zweck
effectiveSecret() / requireSecret() gültiges Secret, Datenbank vor .env
hasSecret() für die Konfigurationsprüfung von Postfach und Anmeldung
secretStatus() Quelle, Ablaufdatum, Resttage, Warnstufe - ohne das Secret
saveSecret() / clearStoredSecret() schreiben und zurücknehmen
verifySecret() Probe bei Microsoft, bevor gespeichert wird
daysUntil(), expiryLevel(), normalizeExpiry() Ablaufrechnung, in test/msSecret.test.mjs geprüft

Eigenheiten, die man kennen muss:

  • Verschlüsselt wird mit AES-256-GCM, Schlüssel aus CRM_ENCRYPTION_KEY oder ersatzweise JWT_SECRET (scrypt). Jeder Aufruf bekommt einen eigenen Zufallsvektor, das Format trägt das Präfix v1:.
  • Passt der Schlüssel nicht mehr, ist die Quelle db-unlesbar - und es wird nicht still auf die .env zurückgefallen.
  • Ist die Datenbank nicht erreichbar, gilt das dagegen als „nichts hinterlegt"; dann greift die .env, damit der Postfachabgleich keine irreführende Meldung liefert.
  • Kein Zwischenspeicher: eine Änderung wirkt sofort, ohne Neustart. Das Graph-Token wird beim Speichern verworfen, es gehört zum alten Secret.
  • MAIL_CONFIGURED und SSO_CONFIGURED waren Konstanten und sind seitdem die Funktionen mailConfigured() und ssoConfigured() - die Prüfung muss in die Datenbank sehen.

server/src/ai.js

OpenAI-Schlüssel, Modellwahl je Einsatzzweck und der Aufruf der erweiterten Suche. Gepflegt unter Einstellungen → KI, Details in 14 KI.

Export Zweck
aiKey() / aiConfigured() gültiger Schlüssel, Datenbank vor .env
keyStatus() Quelle und letzte vier Zeichen - nie der ganze Schlüssel
verifyKey() / saveKey() / clearKey() Probe bei OpenAI, schreiben, zurücknehmen
AI_PURPOSES, modelFor(zweck) Einsatzzwecke mit Vorgabe; eingestelltes Modell oder Vorgabe
modelSettings() / saveModels() / availableModels() Modellwahl lesen, schreiben, Auswahlliste von OpenAI
isReasoningModel() reasoning_effort statt temperature, in test/ai.test.mjs geprüft
askModelJson() ein Chat-Completions-Aufruf mit JSON-Antwort

Eigenheiten, die man kennen muss:

  • Der Schlüssel liegt in der Scanner-Datenbank (settings.openai_api_key), die Modellwahl in der CRM-Datenbank (crm_settings.ai_model_*). Der Schlüssel stand dort schon immer; die Modellwahl ist CRM-weit.
  • Scanner-Dienste (matchingEngine.js, appliedCheck.js) holen Schlüssel und Modell ebenfalls hier - keiner liest openai_api_key mehr selbst.
  • Kein Zwischenspeicher: jede Änderung wirkt beim nächsten Aufruf.

server/src/sso/

Anmeldung über Microsoft Entra, fachlich in 13 Anmeldung über Microsoft. Dieselbe App-Registrierung wie das Postfach, aber delegiert statt app-only - deshalb ein eigenes Modul und kein Import aus mail/: die Anmeldung läuft auch, wenn die Postfachanbindung aus ist.

Datei Inhalt
config.js .env lesen, SSO_CONFIGURED, missingSsoConfig(), domainAllowed()
flow.js beginLogin() (PKCE, state, nonce), takePendingLogin(), exchangeCode(), verifyIdToken(), fetchProfile(), fetchPhoto(), createTicket() / redeemTicket()

Eigenheiten, die man kennen muss:

  • Offene Anmeldungen und Tickets liegen im Prozessspeicher, nicht in der Datenbank: Lebensdauer 10 Minuten bzw. 60 Sekunden, und das Backend läuft als einzelne Instanz. Ein Neustart verwirft höchstens eine gerade laufende Anmeldung.
  • state und Ticket gelten je genau einmal - beide werden beim Lesen entfernt.
  • verifyIdToken() prüft aud, tid, nonce und exp, aber nicht die Signatur. Das Token kommt über TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7), und der anschliessende /me-Aufruf belegt ohnehin, dass das Zugriffstoken gilt. Das spart eine JWKS-Bibliothek.
  • fetchPhoto() schluckt jeden Fehler und gibt null zurück - wer kein Profilfoto hat, soll sich trotzdem anmelden können.
  • findSsoUser() in routes/auth.js legt nie ein Konto an und sucht über die E-Mail-Adresse nur, solange entra_oid IS NULL ist.

server/src/routes/scanner.js

Die portierte Scanner-API unter /api/scanner, vollständig in 03 Projekt Scanner tabelliert. Eigenheiten:

  • multer schreibt CV-PDFs nach UPLOAD_DIR, Grenze 20 MB.
  • resolveCvPath() findet eine Datei auch dann, wenn in der Datenbank noch ein relativer Pfad aus der alten App steht - es zählt der Dateiname.
  • GET /agents lässt cvText weg (mehrere Hundert KB pro Agent).
  • SECRET_KEYS (openai_api_key, smtp_pass) werden als ******** ausgeliefert; kommt dieser Wert oder ein leeres Feld zurück, bleibt der gespeicherte Wert stehen.
  • normalizeCookie() biegt Exporte von Cookie-Editor auf das zurecht, was Playwright akzeptiert (sameSite, expirationDate → expires).

server/src/scanner/

Datei Inhalt
db/config.js Sequelize-Instanz, eigene Datenbank, underscored: true
db/ensureDatabase.js CREATE DATABASE IF NOT EXISTS, Name gegen Zeichenklasse geprüft
models/index.js initScannerDatabase(): verbinden, sync(), Portale und Standardeinstellungen seeden
models/Agent.js cvText, cvFilePath, keywords, excludeKeywords, isActive
models/Project.js Unique auf (portal, portal_project_id), updatedAt: false
models/Match.js Unique auf (agent_id, project_id), pros/cons als JSON-Text mit Getter/Setter
models/Portal.js Zugangsdaten, sessionState, connectionStatus
models/Setting.js Schlüssel-Wert mit getVal/setVal
services/scraper.js Playwright je Portal; unverändert aus der alten App
services/matchingEngine.js OpenAI gpt-4o-mini, response_format: json_object, temperature: 0.1
services/appliedCheck.js Abgleich mit Akquise/Opportunities, OpenAI gpt-5.6-luna, Ergebnis an projects
services/pipeline.js ein Lauf: scrapen, speichern, bewerten, benachrichtigen
services/scheduler.js node-cron jede Minute, Zeitfenster, Sperrfrist, last_scan_at
services/cleanup.js Projekte älter als 7 Tage löschen (Treffer kaskadieren)
services/pdfParser.js pdf-parse, doppelte Leerzeilen raus
utils/crypto.js AES über crypto-js; decryptWith() für fremde Schlüssel
uploadDir.js UPLOAD_DIR, ensureUploadDir(), resolveCvPath()

Zusagen, auf die sich der Rest verlässt:

  • runMatchingPipeline() läuft nie zweimal gleichzeitig und gibt false zurück, wenn schon einer läuft. isPipelineRunning() fragt den Zustand ab.
  • Der Beginn des Laufs landet im finally in settings.last_scan_at - im finally, damit sich ein gescheiterter Lauf nicht sofort wiederholt, aber mit dem Startzeitpunkt, weil das Intervall ein Takt ist und keine Pause zwischen zwei Läufen. Mit dem Endzeitpunkt wurden aus 15 Minuten 19.
  • cleanup.js benutzt sequelize.col('created_at') statt des Attributnamens: Project benennt die Spalte um, und in where greift die Zuordnung nicht.

Frontend

src/lib/api.ts

Export Zweck
apiRequest<T>(path, options) zentraler Fetch: Token, JSON-Header, 401-Behandlung, Fehlermeldung aus dem Body
getToken / setToken / clearToken Token im localStorage (perinco_crm_token)
setUnauthorizedHandler wird bei 401 gerufen; AuthProvider hängt sich hier ein
ApiError Fehler mit status
authApi, usersApi, leadsApi, opportunitiesApi, eventsApi, connectionsApi typisierte Endpunkte
leadsApi.mailPoolSources / .promote / .promoteMany Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen
leadsApi.linkedinPhoto(url) Profilfoto zu einer LinkedIn-Adresse als Data-URL; speichert nichts, das erledigt das Formular beim Sichern

Zwei Feinheiten: Der Content-Type wird nicht gesetzt, wenn der Body FormData ist. Und ein Netzwerkfehler wird zu ApiError(status 0) mit „Server nicht erreichbar" - das Frontend muss fetch-Ausnahmen nicht kennen.

src/lib/scannerApi.ts

Typen (ScannerAgent, ScannerPortal, ScannerProject, ScannerMatch, ScannerSettings, ScannerStatus), das Objekt scannerApi mit allen Aufrufen, SCANNER_SETTINGS_DEFAULTS, SECRET_MASK und openAgentCv().

Die Feldnamen sind absichtlich die der Scanner-Datenbank (camelCase aus Sequelize), inklusive der Grossbuchstaben Agent und Project an den Joins - damit bleibt der Vergleich mit der alten App möglich. Nur ScannerProject trägt beide Schreibweisen für created_at, weil das Modell die Spalte selbst umbenennt.

src/lib/mailApi.ts

Typen (MailMessageSummary, MailMessageDetail, MailCandidate, MailLink, MailStatus, MailSyncResult, MailConnectionTest, MailSettingsValues), das Objekt mailApi, MAIL_SETTINGS_DEFAULTS und die Datumshelfer.

parseMailDate() hängt ein Z an den MariaDB-Zeitstempel, bevor daraus ein Date wird. Ohne das liest der Browser den Wert als Ortszeit, und die Anzeige wäre je Zeitzone um Stunden verschoben - die Mailzeiten stehen als UTC in der Datenbank, anders als die Termine unter Interviews. formatMailDate() zeigt heute nur die Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.

Kontexte

Datei Hält Eigenheit
auth.tsx Benutzer, isAdmin, login, logout, updateUser, ssoError prüft beim Start das gespeicherte Token gegen /auth/me; kommt der Browser von Microsoft zurück, hat ?sso_ticket= Vorrang - es wird eingelöst und die Adresszeile per history.replaceState() geleert
theme.tsx Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk/Rainy/Snow, Hintergrundgrafiken in public/themes/; bei Rainy hängt er zusätzlich components/RainyWeather.tsx mit Regen und Wetterleuchten ein, bei Snow components/SnowWeather.tsx mit Schneefall, Schneedecke und Räumfahrzeug, bei Aura components/AuraWeather.tsx mit Sonnenstrahlen) und Modus (hell/dunkel/System) Beides gehört dem Benutzer und steht in crm_users; localStorage ist nur der Zwischenspeicher fürs Inline-Skript in index.html. Doppelklick auf den Modus-Schalter folgt wieder dem System
navigation.tsx currentView, goTo(view, { tab, leadId }) erspart es, Callbacks durch vier Ebenen zu reichen; leadId öffnet den Kontakt direkt
whisper.tsx Modellstatus, Worker, Fortschritt, transcribe ein Worker für die ganze App

Whisper

Datei Inhalt
whisper.tsx Konstanten (WHISPER_MODEL, WHISPER_DTYPE, WHISPER_LANGUAGE), Provider, Fristen, Hänger-Erkennung
whisper.worker.ts WorkerRequest/WorkerResponse, lädt und erkennt
whisperModels.ts fetchModelSize() holt die echte Größe von Hugging Face
audio.ts toWhisperAudio() - Blob zu 16-kHz-Mono-PCM
dictation.tsx useRecorder() - Mikrofon, Dauer, Abbruch

Warum genau diese Konstanten festgelegt sind, steht in 04 Diktat.

Gemeinsame Bausteine

Datei Inhalt
components/opportunities/OpportunityModal.tsx Formular der Opportunity. Beim Neuanlegen ist der angemeldete Benutzer Zuständig (useAuth(), Rückfallebene users[0]) - beim Bearbeiten bleibt die gespeicherte Zuständigkeit stehen. Beraterliste kommt aus der Akquise (acquisitionOptionsApi)
components/opportunities/OpportunityCopyDialog.tsx Kopie für andere Berater: Auswahl aus der Beraterliste der Akquise (ohne den eigenen), Phase der Kopie
components/opportunities/OpportunityTrash.tsx Papierkorb der Opportunities: wiederherstellen, leeren, Restlaufzeit je Eintrag
components/opportunities/opportunityUi.tsx STAGE_LABELS (Anzeige, mit Umlaut), STAGE_HINTS (Erläuterung als Titelhinweis), STAGE_STYLES, STAGE_COLORS, StageBadge
components/dashboard/Dashboard.tsx Startansicht: Zeitraumwahl, vier Kennzahlen, Verlauf, Berater, Termine, „Beim Kunden". buildAxis() erzeugt auch leere Abschnitte
components/dashboard/dashboardUi.tsx useCountUp() (Zahl läuft hoch, respektiert prefers-reduced-motion), StatCard, SeriesChart, BarList, Panel - von Hand statt Diagrammbibliothek
components/acquisition/Acquisitions.tsx Akquise und Archiv (view); Suche, Sortierung, Übernahme, Löschen, Wiederherstellen. Das Formular steht im Archiv nur beim Bearbeiten - ohne diesen Zweig blieb der Stift dort ohne Wirkung
components/acquisition/AcquisitionForm.tsx Eingabe über der Liste; behält Datum, Anbieter, Kanal und Berater für den nächsten Eintrag
components/acquisition/AcquisitionTable.tsx die Liste; safeLink() lässt nur http(s) zum Link werden. isConverted() (übernommen und die Opportunity lebt noch) schaltet die Zeile um: statt Opportunity steht dort der blaue Knopf Übernommen ✓, der zur Opportunity springt, Stift und Papierkorb sind gesperrt und der Pfeil zurück in den Eingang fehlt. Der Pfeil erscheint ohnehin nur, wenn onReopen gesetzt ist - im Eingang gibt es nichts zurückzuholen. Die Titelspalte ist auf 28rem festgelegt und bricht um (wrap-anywhere), lange Titel liefen sonst in die Spalte „Berater“
components/acquisition/AcquisitionTrash.tsx Papierkorb der Akquise: zurückholen, endgültig löschen, leeren. Die Meldung nach dem Zurückholen nennt das Ziel (Eingang oder Archiv) - das Funddatum bleibt, also landet ein alter Fund im Archiv
components/acquisition/acquisitionUi.tsx fieldClass (abgesetzte Felder) und OptionSelect - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; alwaysOffer hält den eigenen Namen für Von immer wählbar. Anbieter nutzt es nicht: Freitext mit <datalist>. Dazu ConsultantSelect (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und ConsultantList für die Anzeige in der Liste
components/settings/AcquisitionSettings.tsx Reiter Einstellungen → Akquise: die vier Auswahllisten pflegen
components/leads/leadUi.tsx inputClass, labelClass (Beschriftung als eigene Zeile), labelTextClass + labelRowClass (Zeile mit Knopf rechts, z. B. Diktat - min-h-7 haelt sie so hoch wie den Knopf, damit Felder in Nachbarspalten auf einer Linie beginnen), KindBadge, Avatar, Farben der Kontaktart
components/leads/LeadModal.tsx Kontaktformular. Beim Neuanlegen ist der angemeldete Benutzer Zuständig (useAuth(), Rückfallebene users[0]) - beim Bearbeiten bleibt die gespeicherte Zuständigkeit stehen. Neben dem LinkedIn-Feld holt Foto das Profilbild; beim Verlassen des Feldes geschieht das einmal je Adresse von selbst, aber nur solange noch kein Bild gesetzt ist (triedProfiles) - ein hochgeladenes Foto wird nie überschrieben. Fehler des automatischen Versuchs stehen als Hinweis unter dem Feld, nur der Klick auf Foto meldet sich als Toast. In der Liste der Verbindungen ist die Art ein Auswahlfeld: die Änderung geht als PATCH /api/connections/:id sofort raus, ohne den Speichern-Knopf - eine Verbindung ist ein eigener Datensatz und hängt nicht am Formular
components/leads/LeadNetwork.tsx Netzwerkdiagramm (d3-force). Ein Klick auf leere Fläche hebt die Auswahl auf, das Diagramm ist danach wieder so hell wie beim Wechsel in die Ansicht; ein Ziehen des Hintergrunds (mehr als 4 px) verschiebt nur und lässt die Auswahl stehen. Knoten stoppen das pointerdown, ihre Klicks erreichen den Hintergrund also nicht. In der Seitenleiste des gewählten Kontakts ist die Art jeder Verbindung umstellbar, genau wie im Kontaktdialog
components/leads/mailPool.ts findMailPoolMatch(), diffFields(), isConflict(), MAIL_POOL_FIELDS - Dublettenabgleich des Vorbereichs
components/leads/MailPoolLeads.tsx Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme
components/leads/MailPoolMergeDialog.tsx Feldauswahl beim Ergänzen eines vorhandenen Kontakts
components/leads/MailingListDialog.tsx Adressen der Kontakte mit Einverständnis: Zwischenablage und mailto: mit BCC, Längengrenze 1900 Zeichen
components/scanner/scannerUi.tsx cardClass, Buttonklassen, MatchBadge, MatchStatusBadge, PortalStatusPill, formatDateTime, projectTimestamp
components/mail/mailUi.tsx Badge, ConfidenceBar, Herkunfts- und Statusbeschriftungen, formatAddresses, avatarColor
components/mail/MailBody.tsx Mailinhalt im <iframe sandbox="">, externe Bilder per CSP gesperrt
components/ui/Modal.tsx Modal und ConfirmDialog; useDismiss() schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel openModals), der Hintergrundklick muss dort beginnen und enden. ConfirmDialog kennt zwei Tonlagen: tone="danger" (Standard, rot - es geht etwas verloren) und tone="neutral" (grün, bloße Rückfrage, z. B. beim Wiederherstellen)
components/Toast.tsx useToast().addToast(text, kind) mit success, error oder info
components/ui/DictationField.tsx Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat
lib/confetti.ts fireConfetti(): Canvas-Konfetti ohne Abhängigkeit, räumt sich nach 4,5 s selbst weg, entfällt bei prefers-reduced-motion. Aufgerufen beim Phasenwechsel auf Zusage (Opportunities.changeStage, OpportunityModal.handleSubmit)
lib/imageCompress.ts compressImage(datei) auf 256 px, WebP → JPEG → PNG; compressImageSource(dataUrl) geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto)

src/types.ts

Neben den Interfaces stehen hier die Rechenregeln, damit Frontend und Anzeige nicht auseinanderlaufen:

Export Zweck
ViewState, SettingsTab, ADMIN_SETTINGS_TABS, SCANNER_VIEWS, MAIL_VIEWS, ACQUISITION_VIEWS, OPPORTUNITY_VIEWS, CONTACT_VIEWS Ansichten, Reiter und aufklappbare Gruppen
VIEW_TITLES, viewTitle() abweichende Beschriftung, wo der Ansichtsname nur intern eindeutig sein muss (Opportunity-Papierkorb → Papierkorb)
MAIL_LEAD_SOURCE, LeadOriginFilter Herkunft „Mail" in Lead.source und ihr Filter
USER_COLORS, userColor() Farbe gesetzt, sonst stabil aus der Benutzer-Id
opportunityVolume(), formatEuro() Festpreis, sonst Tagessatz x Personentage; formatiert. Ein gewichtetes Volumen gibt es bewusst nicht
OPPORTUNITY_STAGES, OPEN_STAGES, LEAD_KINDS, CONNECTION_TYPES die Aufzählungen der Datenbank
ACQUISITION_OPTION_FIELDS, ACQUISITION_FIELD_LABELS, ACQUISITION_FIELD_HINTS die vier Auswahllisten der Akquise samt Beschriftung
eventColor(), leadFullName(), leadInitials(), contactFullName() Anzeigelogik

Datenfluss im Frontend

flowchart TD
    A["App.tsx: loadData()"] -->|"Promise.all"| B["5 Endpunkte parallel"]
    B --> C["leads, connections, events, users, opportunities"]
    C --> D["als Props in die Ansichten"]
    D --> E["Dialog speichert"]
    E --> F["reload() = loadData(true)"]
    F -->|"still, ohne Ladeanzeige"| C

    G["Scanner-Ansichten"] -->|"eigener 30-s-Takt"| H["/api/scanner"]
    I["Mailansicht"] -->|"blättert, sucht, filtert"| J["/api/mail"]
    D --> I
    I -->|"nach Übernahme eines Vorschlags"| F

Die Mailansicht lädt ihre Mails selbst, bekommt Kontakte und Opportunities aber als Props - der Zuordnungsdialog braucht beide Listen zum Vorschlagen. Nach einer Kontaktübernahme ruft sie reload(), damit der neue Kontakt in der Kontaktliste der App auftaucht.

Die fünf CRM-Endpunkte laufen parallel, nicht nacheinander - sonst addieren sich fünf Rundreisen beim Anmelden. Die Scanner-Ansichten hängen bewusst nicht an loadData: andere Datenbank, andere Auffrischung.