Die bisherige Dokumentation beschrieb die Architektur, half aber niemandem, der die Anwendung zum ersten Mal oeffnet. Dazu jetzt vier Kapitel: - 07 Anleitung fuer Benutzer: was die Anwendung tut, jeder Bereich Schritt fuer Schritt, warum der Signalton nur im Dashboard klingelt, was Loeschen eines Suchagenten mitnimmt - 08 Anleitung fuer Entwickler: einrichten, pruefen, Feld/Menuepunkt/Reiter/ Endpunkt hinzufuegen, dazu eine Tabelle der Fallen, die real Zeit gekostet haben (Routen-Reihenfolge, FormData, Leerzeichen in Pfaden, Zeitstempel koennen nur vorziehen, Kaskaden beim Loeschen) - 09 Quellcode-Referenz: jede Datei mit ihren Exporten und den Zusagen, auf die sich der Rest verlaesst - 10 Glossar: Agent, Match, Lead, Opportunity und die Statuswerte der Datenbank Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
09 Quellcode-Referenz
Was in welcher Datei steckt und welche Zusagen sie einhaelt. 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 fuer 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/>faengt alles Unbekannte"]
F --> E["Fehlerbehandlung:<br/>400 kaputtes JSON, 413 zu gross, 500 Rest"]
Beim Start: initDatabase() (CRM-Schema, Admin-Seed) → initScannerDatabase()
(darf fehlschlagen, dann laeuft das CRM ohne Scanner) → Scheduler, sofern
SCANNER_SCHEDULER !== 'false'.
server/src/db.js
| Export | Zweck |
|---|---|
pool |
mysql2-Pool, connectionLimit: 8, dateStrings: true |
initDatabase() |
fuehrt schema.sql Statement fuer Statement aus, dann ensureAdminUser() |
dateStrings: true ist eine bewusste Entscheidung: Termine kommen als
YYYY-MM-DD HH:MM:SS ohne Zeitzonenumrechnung an. Der Kalender rechnet mit
Wandzeit; ein Date-Objekt aus dem Treiber wuerde beim Serialisieren nach UTC
verschieben.
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 pruefen 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 geloeschter Benutzer ist sofort draussen und nicht erst, wenn
sein Token ablaeuft. secret() wirft, wenn JWT_SECRET fehlt oder kuerzer 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 laengenbegrenzt.
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 fuer alle, Rest Admin |
leads.js |
GET /, POST /, PATCH /:id, DELETE /:id |
jeder Angemeldete |
connections.js |
GET /, POST /, PATCH /:id, DELETE /:id |
jeder Angemeldete |
events.js |
GET /, POST /, PATCH /:id, DELETE /:id |
anlegen jeder; aendern/loeschen nur Ersteller oder Admin |
opportunities.js |
GET /, POST /, PATCH /:id, DELETE /:id |
aendern/loeschen nur Ersteller, Zustaendiger oder Admin |
Wiederkehrende Muster in diesen Routern:
emptyToNull()- leere Zeichenketten werdenNULL, damit „nicht gesetzt" und „leer" in der Datenbank nicht zwei Zustaende sind.normalizeDate()prueft 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 ueber die API keine Benutzernamen aufzaehlen lassen.- Der letzte aktive Administrator kann nicht degradiert, deaktiviert oder geloescht werden.
server/src/routes/scanner.js
Die portierte Scanner-API unter /api/scanner, vollstaendig 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 zaehlt der Dateiname.GET /agentslaesstcvTextweg (mehrere Hundert KB pro Agent).SECRET_KEYS(openai_api_key,smtp_pass) werden als********ausgeliefert; kommt dieser Wert oder ein leeres Feld zurueck, 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 geprueft |
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 |
Schluessel-Wert mit getVal/setVal |
services/scraper.js |
Playwright je Portal; unveraendert 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 aelter als 7 Tage loeschen (Treffer kaskadieren) |
services/pdfParser.js |
pdf-parse, doppelte Leerzeilen raus |
utils/crypto.js |
AES ueber crypto-js; decryptWith() fuer fremde Schluessel |
uploadDir.js |
UPLOAD_DIR, ensureUploadDir(), resolveCvPath() |
importLegacy.js |
einmalige Uebernahme aus der alten Datenbank |
Zusagen, auf die sich der Rest verlaesst:
runMatchingPipeline()laeuft nie zweimal gleichzeitig und gibtfalsezurueck, wenn schon einer laeuft.isPipelineRunning()fragt den Zustand ab.- Der Zeitpunkt landet im
finallyinsettings.last_scan_at- auch nach einem Abbruch, damit sich ein gescheiterter Lauf nicht sofort wiederholt. 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 haengt sich hier ein |
ApiError |
Fehler mit status |
authApi, usersApi, leadsApi, opportunitiesApi, eventsApi, connectionsApi |
typisierte Endpunkte |
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 moeglich. Nur ScannerProject
traegt beide Schreibweisen fuer created_at, weil das Modell die Spalte selbst
umbenennt.
Kontexte
| Datei | Haelt | Eigenheit |
|---|---|---|
auth.tsx |
Benutzer, isAdmin, login, logout, updateUser |
prueft beim Start das gespeicherte Token gegen /auth/me |
theme.tsx |
hell/dunkel/System | Wahl in localStorage, Doppelklick loest sie wieder |
navigation.tsx |
currentView, goTo(view, { tab }) |
erspart es, Callbacks durch vier Ebenen zu reichen |
whisper.tsx |
Modellstatus, Worker, Fortschritt, transcribe |
ein Worker fuer die ganze App |
Whisper
| Datei | Inhalt |
|---|---|
whisper.tsx |
Konstanten (WHISPER_MODEL, WHISPER_DTYPE, WHISPER_LANGUAGE), Provider, Fristen, Haenger-Erkennung |
whisper.worker.ts |
WorkerRequest/WorkerResponse, laedt und erkennt |
whisperModels.ts |
fetchModelSize() holt die echte Groesse 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/leads/leadUi.tsx |
inputClass, labelClass, StatusBadge, Avatar, Statusfarben |
components/scanner/scannerUi.tsx |
cardClass, Buttonklassen, MatchBadge, MatchStatusBadge, PortalStatusPill, formatDateTime, projectTimestamp |
components/ui/Modal.tsx |
Modal (Escape schliesst) und ConfirmDialog |
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 |
Ansichten und Reiter |
USER_COLORS, userColor() |
Farbe gesetzt, sonst stabil aus der Benutzer-Id |
opportunityVolume(), weightedVolume(), formatEuro() |
Tagessatz x Personentage, gewichtet, formatiert |
OPPORTUNITY_STAGES, OPEN_STAGES, LEAD_STATUSES, CONNECTION_TYPES |
die Aufzaehlungen der Datenbank |
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"]
Die fuenf CRM-Endpunkte laufen parallel, nicht nacheinander - sonst addieren
sich fuenf Rundreisen beim Anmelden. Die Scanner-Ansichten haengen bewusst nicht
an loadData: andere Datenbank, andere Auffrischung.