# 06 Schnittstellen Zwei fremde Systeme hängen am CRM: die **ICON Website** liest die Projektbörse, und das **perinco CRM** tauscht Kontakte in beide Richtungen aus. ## ICON Website Seit dem 14.09.2026 liegt die Website auf demselben Server wie das CRM (`/var/www/icon_website/dist`, Vhost `icon-experts.de` + `www`). Vorher lief sie auf `212.227.135.41` – derselben Maschine wie perinco CRM, n8n, Docker und Supabase. ```mermaid flowchart LR V(("Besucher")) --> W["Apache
icon-experts.de"] W -->|"/"| S["Website (dist)"] W -->|"/api/public
ProxyPass"| E["Express :5007"] E --> DB[("icon_CRM")] ``` Der Vhost reicht **`/api/public` an das CRM-Backend durch**. Das ist kein Schönheitsfehler, sondern der Grund, warum die Content-Security-Policy der Seite weiter mit `connect-src 'self'` auskommt: der Aufruf ist gleicher Herkunft. Zeigte die Seite direkt auf `icrm.icon-experts.de`, müsste dieser Host in der CSP stehen und der Browser bräuchte einen CORS-Vorabruf. ### Der öffentliche Endpunkt `GET /api/public/tenders` – ohne Anmeldung, nur Lesen, neueste zuerst. Dazu `GET /api/public/tenders/:id` für einen direkten Link. Beide antworten mit `Access-Control-Allow-Origin: *` und `Cache-Control: public, max-age=60`. ```json [{ "id": 8, "title": "…", "status": "Aktiv", "notes": "

…

", "created_at": "…", "updated_at": "…" }] ``` **Nicht** ausgeliefert werden Kunde, zugewiesene Experten, Quelle, Fristen und Phase. Das ist der eigentliche Gewinn gegenüber dem vorherigen Weg: die Website las mit dem Supabase-Anon-Key direkt in der Tabelle und durfte `select('*')` – der Schlüssel lag dafür im Browser-Bundle. > **Zwei Bedingungen, nicht eine:** `online = 1` **und** `status <> 'Archiviert'`. > Im Altbestand steht eine archivierte Ausschreibung, deren Schalter noch an ist – > über Supabase war sie unsichtbar, über diesen Endpunkt wäre sie mit der > Kategorie „Archiviert" auf der Website aufgetaucht. Auf Seiten der Website hängt das an `src/lib/iconCrm.js`; `src/pages/Experts.jsx` ruft nur noch `fetchTenders()` auf. `@supabase/supabase-js` wird dort seither nirgends mehr benutzt. **Der Schalter `online` in der Liste der Ausschreibungen entscheidet also unmittelbar, was auf der Website steht** – einen Klick entfernt, ohne Umweg. ## perinco CRM, in beide Richtungen *Kontakte → Häkchen „perinco".* Die Kontakte des perinco CRM werden zusätzlich angezeigt – **grün umrandet**, rein lesend, und **nichts davon wird gespeichert**. Ein nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären, welcher Eintrag gilt. Dasselbe gibt es spiegelbildlich drüben: im perinco CRM erscheinen die ICON-Kontakte hinter dem Häkchen „ICON", **gold** umrandet. ```mermaid flowchart LR I["ICON CRM
icrm.icon-experts.de"] -->|"GET /api/integrations/contacts
Bearer-Token"| P["perinco CRM
pcrm.perinco.de"] P -->|"GET /api/integrations/contacts
Bearer-Token"| I ``` | Richtung | Wer fragt | Wer antwortet | Token | | --- | --- | --- | --- | | ICON liest perinco | `server/src/routes/perinco.js` | drüben `server/src/routes/integrations.js` | hier `PERINCO_API_TOKEN` (+ `PERINCO_API_URL`), drüben `ICON_API_TOKEN` | | perinco liest ICON | drüben `server/src/routes/iconCrm.js` | hier `server/src/routes/integrations.js` | hier `PERINCO_INBOUND_TOKEN`, drüben der gleiche Wert | Mitgeschickt wird der Token als `Authorization: Bearer …`. Beide Router arbeiten mit **festem Token statt JWT** – der Aufrufer ist ein Backend, kein angemeldeter Mensch – und verglichen wird mit `crypto.timingSafeEqual`. Beide kennen ausschließlich `GET`; einen Schreibweg gibt es nicht, auch später nicht. Fehlt einer der Werte, bleibt das Häkchen unsichtbar und beide Systeme laufen normal weiter. Ein Knopf, der nur eine Fehlermeldung erzeugt, ist keiner. ### Wer weitergegeben wird **Ein Häkchen am einzelnen Datensatz entscheidet.** Im perinco CRM trägt jeder Kontakt das Feld *„Im ICON CRM anzeigen"* (`crm_leads.icon`); nur wer es gesetzt hat, taucht hier auf. Beim Anlegen der Spalte wurden alle vorhandenen Kontakte auf gesetzt gestellt – sie waren bis dahin sichtbar, und ein neues Feld darf nichts verschwinden lassen. **Neue Kontakte starten ungesetzt.** Kontakte im Vorbereich „Aus Mails" (`mail_pool = 1`) bleiben außen vor: unvollständig und teils Dubletten. ### Feldabbildung Abgebildet wird auf die **vorhandenen** Felder; neue wurden ausdrücklich keine angelegt: | perinco `crm_leads` | ICON | | --- | --- | | `first_name`, `last_name`, `position` | gleichnamig | | `company` | wird als Unternehmensname angezeigt | | `email`, `phone` bzw. `mobile` | `email`, `phone` | | `linkedin`, `avatar`, `tags` | gleichnamig | | `kind` (Recruiter/Partner/Endkunde) | `kind` – **unverändert**, nicht auf die ICON-Arten umgebogen | | `source` | `lead_source` | | `notes` | `bio` | Bleiben drüben, weil ICON bei Kontakten kein Gegenstück hat: Anrede, Website, Anschrift. Ebenso Zuständiger und Ersteller – das sind Benutzer *jenes* Systems, deren Kennungen hier nichts bedeuten. Die Kennung bekommt ein Präfix (`perinco-31`), damit sie nie mit einer ICON-Kennung verwechselt wird. Sie ist eine Zeichenkette und gehört nicht in die Verweise der erweiterten Suche. ### Wo sie auftauchen * **Kontaktliste und Kacheln** – grün umrandet, mit Herkunftsmerkmal * **Netzwerkansicht** – grün umrandete Kreise, über eine fein gestrichelte grüne Kante an ihr Unternehmen gehängt. Die Verbindung läuft über den **Firmennamen**, denn drüben steht dort nur Text und keine Kennung; groß/klein wird ignoriert, damit „KfW" und „KFW" zusammenfinden. Passt kein Name, steht der Kontakt für sich – das ist ehrlicher als eine geratene Zuordnung. * **Erweiterte Suche** – sie zählen mit, werden aber klar als perinco-Kontakte benannt. Verbinden lassen sie sich nicht, und ihre Seitenleiste bietet keine Karte an – gepflegt werden sie drüben. > **Erfahrung aus dem Betrieb:** Ein zusätzlicher Filter in der Kontaktliste > kann die Liste still leeren und sieht dann aus wie Datenverlust. Deshalb sind > aktive Filter sichtbar ausgezeichnet, der Leerzustand benennt sie, und daneben > steht *Filter zurücksetzen*.