Drei Dinge: - Die Phasen sind jetzt die des Vermittlungsprozesses: Neu, Beworben, Erstgespraech Berater, Kundenvorstellung, Interview Kunde, Zusage, Beauftragt, Absage. Offen ist alles bis einschliesslich Zusage - dort ist der Einsatz bestaetigt, die Beauftragung steht aber noch aus. migrateOpportunityStages() stellt bestehende Datenbanken um und setzt alle vorhandenen Opportunities auf "Neu": in welcher der neuen Phasen ein Projekt steckt, sagt der alte Trichter nicht (so mit dem Nutzer entschieden). Der Datenbankwert heisst 'Erstgespraech Berater' ohne Umlaut - Projektkonvention fuer Datenbankwerte. Angezeigt wird er ueber STAGE_LABELS; dazu gibt es STAGE_HINTS mit der Erlaeuterung je Phase als Titelhinweis an Auswahl, Badge und Spaltenkopf. - Kanban ist die erste Ansicht der Opportunities. Die Pipeline ist die Arbeitsansicht, Kachel und Liste sind zum Nachschauen. - Hinter "Archiv", beiden "Papierkorb"-Punkten und "Unpassend" steht die Anzahl, wie schon bei "Aus Mails". Die Zahlen kommen aus einem eigenen Endpunkt (/api/badges), weil sie alle ins Menue gehoeren und zusammen kommen sollen; Inhalte laedt weiter jede Ansicht selbst. Der Scanner liegt in einer eigenen Datenbank und kann fehlen - sein Zaehler ist dann null und die Leiste zeigt dort einfach keine Zahl. Der Aufruf haengt bewusst hinter den Daten und nicht im Promise.all: faellt er aus, bleibt das Menue ohne Zahlen statt die Ansicht mit einem Fehler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19 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.
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 (12 h) |
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.
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 |
/login offen, 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 /trash/empty |
ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete |
acquisitions.js |
GET /?view=eingang|archiv|alle, POST /, PATCH /:id, DELETE /:id, POST /:id/opportunity; Auswahllisten: GET/POST /options, PATCH/DELETE /options/:id |
Einträge ändern/löschen nur Ersteller oder Admin; Auswahllisten jeder Angemeldete |
badges.js |
GET / |
Zahlen für die Seitenleiste: Akquise-Archiv, beide Papierkörbe, verworfene Scanner-Treffer. Der Scanner-Zähler ist null, wenn dessen Datenbank fehlt |
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/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/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/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() |
importLegacy.js |
einmalige Übernahme aus der alten Datenbank |
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 |
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 |
prüft beim Start das gespeicherte Token gegen /auth/me |
theme.tsx |
hell/dunkel/System | Wahl in localStorage, Doppelklick löst sie wieder |
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/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/acquisition/Acquisitions.tsx |
Akquise und Archiv (view); Suche, Sortierung, Übernahme, Löschen |
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 |
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> |
components/settings/AcquisitionSettings.tsx |
Reiter Einstellungen → Akquise: die vier Auswahllisten pflegen |
components/leads/leadUi.tsx |
inputClass, labelClass, KindBadge, Avatar, Farben der Kontaktart |
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 |
components/Toast.tsx |
useToast().addToast(text, kind) mit success, error oder info |
components/ui/DictationField.tsx |
Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
lib/imageCompress.ts |
compressImage() auf 256 px, WebP → JPEG → PNG |
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(), weightedVolume(), formatEuro() |
Festpreis, sonst Tagessatz x Personentage; gewichtet; formatiert |
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.