Ein gefundenes Projekt passt oft zu mehr als einem Berater, und fuer jeden geht eine eigene Bewerbung heraus. Das Feld "Berater" liess bisher nur einen Namen zu. Gespeichert wird eine kommagetrennte Liste in crm_acquisitions.consultant (auf VARCHAR(500) erweitert, Migration in db.js) und keine eigene Zuordnungstabelle: die Namen sind reiner Text, die Auswahlliste in crm_acquisition_options ist eine Pflegehilfe und kein Fremdschluessel. Suche, Sortierung und Anzeige der Liste arbeiten damit weiter auf einem Feld. Die Funktionen zum Zerlegen stehen in acquisitionRules.js, das Gegenstueck fuer das Frontend in src/types.ts, geprueft von server/test/acquisition.test.mjs. Zerlegt wird an vier Stellen: - Uebernahme: je Berater eine Opportunity, alle in einer Transaktion. Ihre Titel tragen den Namen in Klammern, weil die Opportunity kein eigenes Feld fuer den Berater hat und in Pipeline und Liste sonst identische Zeilen stehen; bei einem einzigen Berater bleibt der Titel unveraendert. In den Notizen steht neben dem eigenen Berater auch, mit wem er sich das Projekt teilt. Der Eintrag merkt sich die erste Opportunity - sie entscheidet nur darueber, ob er wieder im Eingang auftaucht. - Verlauf (crm_acquisition_history): ein Berater je Zeile. Ein Eintrag mit drei Beratern sind drei Bewerbungen, "Nico, Lisa" waere sonst ein eigener Schluessel neben "Nico" und "Lisa". - Dashboard: dieselbe Zaehlung. Die Summe der Balken liegt damit ueber der Gesamtzahl "Bewerbungen", die weiter die Eintraege zaehlt. - Auswahlliste pflegen: Umbenennen ersetzt den Namen innerhalb der Liste, Loeschen zaehlt auch, wer nur einer von mehreren ist. Ein Gleichheitsvergleich uebersah beides. In der Oberflaeche haengt das Auswahlfeld jeweils einen Namen an, die Gewaehlten stehen als Plaketten darunter und lassen sich einzeln entfernen; ein bereits gewaehlter Berater verschwindet aus der Liste. Bewusst kein <select multiple>: Strg-Klick ist mit der Maus unauffindbar und mit einer Beruehrung gar nicht bedienbar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
11 KiB
Markdown
238 lines
11 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 und Berater-Liste
|
|
```
|
|
|
|
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.
|
|
|
|
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. |
|
|
| **Leerzeichen in Pfaden** | Der alte Scanner liegt unter `/var/www/Projekt Scanner/…`. In der `.env` muss der Wert gequotet sein, sonst schneidet systemd ihn am Leerzeichen ab. |
|
|
| **`.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:` im Markup.** Nur die Skalen `ink-*`, `brand-*`, `canvas`,
|
|
`surface` benutzen, den Rest macht `index.css`.
|
|
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
|
|
`labelClass` 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.
|