Files
perinco-CRM/docs/08-anleitung-entwickler.md
Sascha WolfandClaude Opus 5 419e57b55d feat(auth): Anmeldung ueber Microsoft Entra (SSO)
OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.

Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.

Bewusst so entschieden:
  - Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
    hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
  - Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
    Notzugang, falls Entra oder das Secret ausfaellt.
  - Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
    die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
    Apache-Log.
  - Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
    kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
    werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.

Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 08:14:23 +02:00

12 KiB

08 Anleitung für Entwickler

Für jemanden, der das Projekt zum ersten Mal öffnet. Was man braucht, wie man es startet, wo man was ändert - und welche Fallen es gibt.

In zwei Minuten zum Bild

  • Ein Vite-Frontend (React 19, TypeScript, Tailwind 4), ein Express-Backend, zwei MariaDB-Datenbanken.
  • Kein Router im Frontend: App.tsx hält die Ansicht in einem useState. Die App liegt hinter einer Anmeldung, tiefe Links braucht sie nicht.
  • Kein ORM im CRM-Teil - dort schreibt das Backend SQL mit mysql2. Der Scanner-Teil benutzt Sequelize, weil er 1:1 aus einer anderen Anwendung übernommen wurde.
  • Alles auf Deutsch: Bezeichner, Kommentare, Meldungen. Kommentare erklären das Warum, nicht das Was.

Voraussetzungen

Version
Node 20 oder neuer (Server läuft 20.20)
MariaDB 10.11 oder neuer, erreichbar mit Benutzer und Passwort
Browser Chrome/Edge für WebGPU beim Diktat (sonst läuft es auf der CPU)

Lokal starten

git clone https://git.perinco.de/Sascha/perinco-CRM.git
cd perinco-CRM

npm install                      # Frontend
cd server && npm install         # Backend
cp .env.example .env             # Werte eintragen, mindestens DB_* und JWT_SECRET
npm start                        # Backend auf :5006
cd .. && npm run dev             # Frontend auf :3100, /api wird geproxied

Was dabei von selbst passiert:

  • perinco_CRM muss existieren; die Tabellen legt server/src/schema.sql beim Start an (idempotent).
  • perinco_scanner legt das Backend komplett selbst an - Datenbank, Tabellen, Portale, Standardeinstellungen.
  • Fehlt ADMIN_PASSWORD, wird beim ersten Start ein Zufallspasswort erzeugt und einmalig ins Log geschrieben. Dort nachsehen, sonst kommt man nicht rein.

Ohne MariaDB startet das Backend nicht. Das Frontend lässt sich trotzdem ansehen - siehe Oberfläche prüfen ohne Datenbank weiter unten.

Prüfen

npm run lint          # = tsc --noEmit, das ist die Wahrheit
npm run build         # muss ohne Fehler durchlaufen
cd server && npm test # Postfachanbindung, Berater-Liste, Benutzerfelder, LinkedIn-Foto, Sitzungsbenutzer

Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines Node und ohne Datenbank:

  • server/test/mail.test.mjs prüft die Kontaktextraktion aus Mails und den Graph-Client. Beides läuft unbeaufsichtigt im Hintergrund und lässt sich von Hand kaum nachstellen - ein falsch erkannter Name landet still in der Kontaktliste, und ein 429 vom echten Dienst kommt genau dann, wenn niemand hinsieht.
  • server/test/acquisition.test.mjs prüft das Zerlegen des Feldes Berater, das mehrere Namen kommagetrennt hält. Es steckt im Speichern, im Verlauf, in der Auswertung und in der Übernahme; ein Fehler darin fällt nicht auf, weil die Liste weiter Namen zeigt und nur die Zahlen falsch wären.
  • server/test/sessionUser.test.mjs vergleicht die Spaltenliste in requireAuth mit den Feldern, die publicUser() ausliefert. Fehlt dort eine Spalte, antwortet die API still mit null - so ist die gespeicherte Designwahl beim Neuladen verloren gegangen.

Für alles andere gilt: tsc, ein Build und ein Blick in die Oberfläche - notfalls mit dem Stub aus Oberfläche prüfen ohne Datenbank.

Aufbau

src/                  Frontend
  App.tsx             Ansicht, zentrale Daten, Layout
  types.ts            Typen, Konstanten, Rechenhilfen (Volumen, Farben)
  lib/                API-Zugriff und Kontexte
  components/         je Bereich ein Ordner
server/src/           Backend
  server.js           Express-App, Reihenfolge der Routen
  db.js               Verbindungspool, Schema-Init, Admin-Seed
  settings.js         crm_settings lesen und schreiben (ohne Geheimnisse)
  routes/             ein Router je Ressource
  middleware/auth.js  JWT prüfen, Adminrechte
  mail/               Postfach crm@perinco.de über Microsoft Graph
  scanner/            portierter Projekt Scanner (Sequelize, Playwright, OpenAI)
docs/                 diese Dokumentation
deploy/               Apache-Vhosts, systemd-Unit, Deployskript

Wie eine Anfrage läuft

sequenceDiagram
    participant K as Komponente
    participant A as lib/api.ts
    participant E as Express
    participant M as middleware/auth
    participant R as routes/*.js
    participant D as MariaDB

    K->>A: leadsApi.update(7, { status: 'Gewonnen' })
    A->>A: Token anhängen, Content-Type setzen
    A->>E: PATCH /api/leads/7
    E->>M: requireAuth
    M->>D: Benutzer laden, aktiv?
    M->>R: req.user gesetzt
    R->>R: Eingaben prüfen und normalisieren
    R->>D: UPDATE ... ; SELECT (angereichert)
    R-->>A: 200 JSON
    A-->>K: typisiertes Objekt

Jede Route liefert den vollständigen, angereicherten Datensatz zurück (mit created_by_name, owner_name, Ansprechpartnern, Teilnehmern). Das Frontend muss danach nichts nachladen.

Etwas hinzufügen

Neues Feld an einem Kontakt

  1. server/src/schema.sql: Spalte mit ALTER TABLE ... ADD COLUMN IF NOT EXISTS ergänzen - das Statement muss beim nächsten Start wiederholbar sein.
  2. server/src/routes/leads.js: Feld in die Listen TEXT_FIELDS bzw. in INSERT/UPDATE und in den SELECT aufnehmen.
  3. src/types.ts: Feld ins Lead-Interface.
  4. src/components/leads/LeadModal.tsx: Eingabefeld, inputClass benutzen.
  5. Wenn es in Kachel oder Liste sichtbar sein soll: LeadGrid.tsx, LeadList.tsx.

Neuer Menüpunkt

  1. src/types.ts: Wert in ViewState ergänzen.
  2. src/components/Sidebar.tsx: Eintrag in CATEGORY_ENTRIES (Name + SVG-Pfad).
  3. src/App.tsx: Komponente rendern und einen Untertitel setzen.

Der Name in ViewState ist gleichzeitig die Beschriftung und der Titel in der Kopfzeile. Wer eine abweichende Beschriftung braucht, setzt label am MenuItem (so macht es der Unterpunkt Postfach, der auf die Ansicht Mails zeigt).

Soll der Punkt aufklappen, kommt statt eines MenuItem eine MenuGroup in CATEGORY_ENTRIES - mit einer Liste ihrer Ansichten in types.ts (CONTACT_VIEWS, MAIL_VIEWS, SCANNER_VIEWS), damit die Gruppe offen bleibt, solange eine ihrer Ansichten aktiv ist.

Neuer Reiter in den Einstellungen

  1. src/types.ts: Wert in SettingsTab; nur für Admins zusätzlich in ADMIN_SETTINGS_TABS.
  2. src/components/settings/Settings.tsx: Eintrag in TABS und Rendering.

Ein Sprung dorthin aus einer beliebigen Tiefe: goTo('Einstellungen', { tab: 'whisper' }).

Neuer Endpunkt

  1. Router in server/src/routes/ anlegen, router.use(requireAuth) nicht vergessen, requireAdmin wo nötig.
  2. In server/src/server.js einhängen - vor dem /api-404-Fallback.
  3. In src/lib/api.ts eine typisierte Funktion ergänzen.

Neue Einstellung im CRM

Nur für Werte, die im Betrieb verstellbar sein sollen. Zugangsdaten gehören nicht dazu - die stehen in server/.env und werden beim Start gelesen, damit ein Datenbank-Dump keine Geheimnisse enthält.

  1. Schlüssel samt Vorgabe in das Vorgabenobjekt des Bereichs eintragen (Muster: MAIL_SETTING_DEFAULTS in server/src/mail/config.js). Es ist gleichzeitig die Positivliste des Speicher-Endpunkts.
  2. Lesen mit getSettings(defaults) aus server/src/settings.js, schreiben mit setSetting(). Zahlen und Schalter mit asInt/asBool wandeln.
  3. Feld im passenden Einstellungsreiter ergänzen.

Die Tabelle crm_settings legt schema.sql an; ein neuer Schlüssel braucht keine Migration.

Fallen

Diese Punkte haben real Zeit gekostet:

Falle Was passiert
Reihenfolge in server.js Der 404-Fallback app.use('/api', ...) fängt alles, was danach registriert wird. Neue Router davor einhängen.
Content-Type bei FormData apiRequest setzt JSON nur, wenn der Body keine FormData ist - sonst fehlt die multipart-Grenze und der Upload scheitert.
Dateien in neuen Tabs Ein <a href> schickt kein Bearer-Token. Deshalb holt openAgentCv() das PDF per fetch und öffnet einen Blob.
.env ist gleichzeitig EnvironmentFile Also kein export, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen.
Zeitstempel können nur vorziehen Ein „vorgealterter" lastRunTime verzögert keinen Cronlauf. Wer verzögern will, braucht eine eigene Frist.
Sperrfristen bei jedem Start neu Früher schob jedes Deployment den fälligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt last_scan_at in der Datenbank.
sequelize.sync({ alter: true }) Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur sync() benutzen.
Kaskaden beim Löschen Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint.
Doppelte Scanner-Läufe Scheduler und Jetzt scannen teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal.
Anmeldetoken in der Adresszeile Die Rückleitung von Microsoft ist ein GET. Stünde das CRM-Token darin, läge es in Browserverlauf und Apache-Log. Deshalb kommt ein Einmal-Ticket zurück, das der Browser per POST tauscht - siehe 13 Anmeldung über Microsoft.
allow-same-origin bei srcDoc Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann localStorage lesen - also das Anmeldetoken. Fremdes HTML gehört in sandbox="" ohne jedes Flag. Der Preis: die Inhaltshöhe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Höhe.
/me unter App-only Es gibt keinen Benutzer im Token, also auch kein /me - jeder Graph-Aufruf muss /users/{postfach}/… lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprüft wurde.
UTC neben Wandzeit crm_mail_messages.received_at steht in UTC (so liefert Graph es), crm_events.starts_at in lokaler Wandzeit. Beim Lesen im Frontend parseMailDate() benutzen, sonst ist die Anzeige um Stunden verschoben.
Aufzählungen in der Datenbank sind deutsch crm_mail_links.origin (manuell/automatisch), Vorschlagsstatus (offen, uebernommen, verworfen, dublette). Wer englische Werte schickt, bekommt einen Datenbankfehler statt einer Validierungsmeldung.

Oberfläche prüfen ohne Datenbank

Für Bildschirmfotos und Klickwege genügt ein Stub: ein kleiner node:http-Server, der dist/ ausliefert und auf /api/... erfundene Daten zurückgibt. Token vorab setzen, dann ist die Anmeldung übersprungen:

await page.goto(BASE);
await page.evaluate(() => localStorage.setItem('perinco_crm_token', 'stub-token'));
await page.goto(BASE);

Playwright startet dabei am besten das installierte Chrome (chromium.launch({ channel: 'chrome' })) - dann braucht es keinen Browser-Download.

Konventionen

  • Deutsch in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben englisch (Match, Opportunity, Lead).
  • Kommentare erklären das Warum. Was der Code tut, steht im Code.
  • Kein dark: und keine Theme-Klasse im Markup. Nur die Skalen ink-*, brand-*, canvas, surface benutzen, den Rest macht index.css. Das ist nicht Kosmetik: daran hängen vier Farbwelten in je zwei Modi, und eine feste Farbe im Markup bricht sieben davon. Die Rollen der Skalen und die Regeln für ein neues Theme stehen in 05 Frontend - wichtigste davon: auf brand-500/brand-600 steht weisse Schrift.
  • Gemeinsame Klassen statt kopierter Utility-Ketten: inputClass, labelClass, labelTextClass, labelRowClass aus components/leads/leadUi.tsx, dazu cardClass, primaryButtonClass aus components/scanner/scannerUi.tsx.
  • Meldungen als Toast, Rückfragen über ConfirmDialog - nie alert() oder confirm(). Rot (tone="danger", Standard) nur, wenn etwas verloren geht; sonst tone="neutral", damit die Farbe ihre Bedeutung behält.
  • Rechte immer doppelt: Oberfläche blendet aus, Backend antwortet mit 403.

Deployment

Siehe 06 Betrieb. Kurzform: lokal bauen, deploy/deploy.ps1 lädt dist/ und server/src hoch und startet den Dienst neu. Auf dem Server wird nicht gebaut - dort sind RAM und Swap knapp.