requireAuth hat den Benutzer mit einer ausgeschriebenen Spaltenliste geladen, in der theme und theme_mode fehlten. GET /api/auth/me antwortete deshalb mit null, und der ThemeProvider hat das beim Neuladen als "nichts gewaehlt" gelesen: die Anzeige fiel auf perinco/System zurueck und hat den localStorage-Zwischenspeicher gleich mit ueberschrieben. Direkt nach dem Klick und nach dem Anmelden sah es richtig aus, weil PATCH /me den geaenderten Wert selbst zurueckgibt und der Login SELECT * benutzt - nur das Neuladen nicht. Dazu ein Test, der die Spaltenliste mit den Feldern von publicUser() vergleicht, damit ein neues Benutzerfeld nicht wieder still verloren geht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
246 lines
12 KiB
Markdown
246 lines
12 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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](#oberflaeche-pruefen-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
|
|
|
|
```mermaid
|
|
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. |
|
|
| **`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:
|
|
|
|
```js
|
|
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](05-frontend.md#themes-ohne-dark-varianten) -
|
|
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](06-betrieb.md). 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.
|