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>
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 Spaltecrm_leads.statusselbst - liegt schonkindvor, passiert nichts. Die Auswahl wird erst auf alte plus neue Werte erweitert, dann werden alle Altwerte aufRecruitergesetzt, 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 incrm_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 incrm_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: stehtprovidernoch im ENUM, ist die Umstellung offen. Die Zeilen müssen vor dem Verengen weg, sonst weist MariaDB denALTERab.addConsultantEmail()legtcrm_acquisition_options.emailan und belegt die vorhandenen Berater einmalig mitvorname.nachname@perinco.devor (guessPerincoEmail(), Merkerconsultant_email_seeded). Namen ohne Nachnamen bleiben leer. Dieselbe Funktion legtnotify(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 werdenNULL, 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 /loginantwortet 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
fetchauf linkedin.com aus der Seite heraus scheitert an der Same-Origin-Policy. - Browser-Kennung im Kopf. Ohne
User-Agentantwortet 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
httpsvonlicdn.comkommen 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 (
ghostim Pfad) oder eine feste Grafik vonstatic.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
401verwirft das Token genau einmal und wiederholt; so überlebt ein Lauf ein serverseitig zurückgezogenes Token. upsertMessage()lässt die Textspalten unberührt, wennbodyTextim übergebenen Objektundefinedist. 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 (
runninginsync.js) - ein zweiter Aufruf kommt sofort mitskippedzurü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_KEYoder ersatzweiseJWT_SECRET(scrypt). Jeder Aufruf bekommt einen eigenen Zufallsvektor, das Format trägt das Präfixv1:. - Passt der Schlüssel nicht mehr, ist die Quelle
db-unlesbar- und es wird nicht still auf die.envzurü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_CONFIGUREDundSSO_CONFIGUREDwaren Konstanten und sind seitdem die FunktionenmailConfigured()undssoConfigured()- 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 liestopenai_api_keymehr 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.
stateund Ticket gelten je genau einmal - beide werden beim Lesen entfernt.verifyIdToken()prüftaud,tid,nonceundexp, 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 gibtnullzurück - wer kein Profilfoto hat, soll sich trotzdem anmelden können.findSsoUser()inroutes/auth.jslegt nie ein Konto an und sucht über die E-Mail-Adresse nur, solangeentra_oid IS NULList.
server/src/routes/scanner.js
Die portierte Scanner-API unter /api/scanner, vollständig in
03 Projekt Scanner tabelliert. Eigenheiten:
multerschreibt CV-PDFs nachUPLOAD_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 /agentslässtcvTextweg (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 gibtfalsezurück, wenn schon einer läuft.isPipelineRunning()fragt den Zustand ab.- Der Beginn des Laufs landet im
finallyinsettings.last_scan_at- imfinally, 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.jsbenutztsequelize.col('created_at')statt des Attributnamens:Projectbenennt die Spalte um, und inwheregreift 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.