Files
ICON-CRM/server/src/routes/perinco.js
T
schakal00783andClaude Opus 5.5 cbac02a96a KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand
Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:26:30 +02:00

262 lines
9.4 KiB
JavaScript

import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { applyBridgePatch } from '../contactBridge.js';
const router = express.Router();
router.use(requireAuth);
/*
* Lesender Zugriff auf die Kontakte des perinco CRM.
*
* Es wird NICHTS gespiegelt. Die Kontakte gehören dem perinco CRM, werden dort
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. 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.
*
* Eine Ausnahme gibt es: steht derselbe Mensch nachweislich in beiden Häusern,
* kann ein Benutzer die Unterschiede abgleichen. Dann wird auch nach drüben
* geschrieben - Feld für Feld und nur, was er selbst entschieden hat.
*
* Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet.
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
* Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben.
*/
/** Eine Minute Zwischenspeicher - die Liste ändert sich nicht im Sekundentakt. */
const CACHE_MS = 60_000;
let cache = { at: 0, contacts: [] };
const CONFIGURED = () => Boolean(process.env.PERINCO_API_URL && process.env.PERINCO_API_TOKEN);
router.get('/status', (req, res) => {
res.json({ configured: CONFIGURED(), url: process.env.PERINCO_API_URL ?? null });
});
/**
* Die Kontakte von drueben holen - mit Zwischenspeicher.
*
* Liegt ausserhalb der Route, weil auch die Suche sie braucht. Ohne den
* gemeinsamen Zwischenspeicher wuerde jede Suchanfrage einen zweiten Aufruf
* nach drueben ausloesen.
*
* Wirft nie: die Suche darf nicht scheitern, nur weil das andere Haus gerade
* nicht erreichbar ist.
*/
export async function fetchPerincoContacts() {
if (!CONFIGURED()) return { contacts: [], cached: false, error: 'nicht eingerichtet' };
if (Date.now() - cache.at < CACHE_MS) return { contacts: cache.contacts, cached: true };
const base = String(process.env.PERINCO_API_URL).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(`${base}/api/integrations/contacts`, {
headers: {
Authorization: `Bearer ${process.env.PERINCO_API_TOKEN}`,
Accept: 'application/json',
},
signal: controller.signal,
});
if (!response.ok) {
// Die Meldung nennt den Grund, aber niemals den Token.
return {
contacts: [],
cached: false,
error:
response.status === 401
? 'Das perinco CRM hat den Zugang abgelehnt (Token stimmt nicht).'
: `Das perinco CRM antwortete mit HTTP ${response.status}.`,
};
}
const payload = await response.json();
const contacts = (payload?.contacts ?? []).map(toIconShape);
cache = { at: Date.now(), contacts };
return { contacts, cached: false };
} catch (err) {
return {
contacts: [],
cached: false,
error:
err?.name === 'AbortError'
? 'Das perinco CRM hat nicht rechtzeitig geantwortet.'
: 'Das perinco CRM ist gerade nicht erreichbar.',
};
} finally {
clearTimeout(timer);
}
}
router.get('/contacts', async (req, res) => {
if (!CONFIGURED()) {
return res.status(503).json({
error: 'Der Zugriff auf das perinco CRM ist nicht eingerichtet (PERINCO_API_URL, PERINCO_API_TOKEN).',
});
}
const result = await fetchPerincoContacts();
if (result.error) return res.status(502).json({ error: result.error });
res.json({ contacts: result.contacts, cached: result.cached });
});
/**
* Ein perinco-Kontakt in der Form, die die ICON-Oberfläche ohnehin darstellt.
*
* Die Kennung bekommt ein Präfix: sie zählt in einer anderen Datenbank und darf
* nie mit einer ICON-Kennung verwechselt werden - sonst öffnete ein Klick den
* falschen Datensatz.
*/
function toIconShape(row) {
const name = [row.first_name, row.last_name].filter(Boolean).join(' ').trim();
return {
id: `perinco-${row.id}`,
first_name: row.first_name ?? null,
last_name: row.last_name ?? '',
name: name || 'Unbenannt',
position: row.position ?? null,
company_name: row.company ?? null,
email: row.email ?? null,
phone: row.phone ?? null,
linkedin: row.linkedin ?? null,
avatar: row.avatar ?? null,
// Drüben sind das Recruiter / Partner / Endkunde - die Werte werden
// unverändert gezeigt und NICHT auf die ICON-Arten umgebogen. Eine
// erfundene Zuordnung wäre schlechter als die ehrliche Fremdbezeichnung.
kind: row.kind ?? null,
lead_source: row.source ?? null,
tags: Array.isArray(row.tags) ? row.tags : [],
bio: row.notes ?? null,
created_at: row.created_at ?? null,
updated_at: row.updated_at ?? null,
/** Kennzeichen für die Oberfläche: grüner Rahmen, keine Bearbeitung. */
source_system: 'perinco',
};
}
/**
* Einen Kontakt drueben aendern.
*
* Gegenstueck zu `PATCH /api/integrations/contacts/:id` hier - dieselbe enge
* Feldliste, derselbe feste Token. Wirft nicht, sondern meldet den Grund:
* der Aufrufer muss wissen, ob die andere Seite geschrieben hat, bevor er
* hier schreibt.
*/
async function patchRemoteContact(remoteId, fields) {
if (!CONFIGURED()) return { ok: false, error: 'Der Zugriff auf das perinco CRM ist nicht eingerichtet.' };
const base = String(process.env.PERINCO_API_URL).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(`${base}/api/integrations/contacts/${remoteId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.PERINCO_API_TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(fields),
signal: controller.signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
return {
ok: false,
error:
payload?.error ??
(response.status === 401
? 'Das perinco CRM hat den Zugang abgelehnt (Token stimmt nicht).'
: `Das perinco CRM antwortete mit HTTP ${response.status}.`),
};
}
return { ok: true, body: await response.json().catch(() => ({})) };
} catch (err) {
return {
ok: false,
error:
err?.name === 'AbortError'
? 'Das perinco CRM hat nicht rechtzeitig geantwortet.'
: 'Das perinco CRM ist gerade nicht erreichbar.',
};
} finally {
clearTimeout(timer);
}
}
/*
* Der Abgleich eines doppelt gefuehrten Kontakts.
*
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
* gelten sollen - beide in der Sprache der Bruecke und beide ausschliesslich
* aus dem, was der Benutzer im Dialog entschieden hat.
*
* Zuerst drueben, dann hier. Der Weg ueber das Netz ist der, der scheitern
* kann; scheitert er, ist hier noch nichts passiert und der Benutzer kann es
* unveraendert erneut versuchen. Andersherum bliebe ein halber Abgleich stehen.
*/
router.post('/contacts/:remoteId/merge', async (req, res, next) => {
// Die Kennung kommt mit Praefix aus der Oberflaeche ("perinco-31").
const remoteId = Number(String(req.params.remoteId).replace(/^perinco-/, ''));
const contactId = Number(req.body?.contact_id);
const local = req.body?.local ?? {};
const remote = req.body?.remote ?? {};
if (!Number.isInteger(remoteId) || remoteId <= 0) {
return res.status(400).json({ error: 'Ungültige Kennung im perinco CRM.' });
}
if (!Number.isInteger(contactId) || contactId <= 0) {
return res.status(400).json({ error: 'Ungültige Kennung des Kontakts.' });
}
const wantsRemote = Object.keys(remote).length > 0;
const wantsLocal = Object.keys(local).length > 0;
if (!wantsRemote && !wantsLocal) {
return res.status(400).json({ error: 'Es wurde nichts zum Abgleichen ausgewählt.' });
}
try {
let remoteChanged = [];
if (wantsRemote) {
const result = await patchRemoteContact(remoteId, remote);
if (!result.ok) return res.status(502).json({ error: result.error });
remoteChanged = result.body?.changed ?? [];
// Der Zwischenspeicher zeigt sonst bis zu einer Minute den alten Stand.
cache = { at: 0, contacts: [] };
}
let localChanged = [];
if (wantsLocal) {
const result = await applyBridgePatch({
id: contactId,
body: local,
userId: req.user.id,
note: 'Abgleich mit dem perinco CRM',
});
if (result.status !== 200) {
// Drueben steht die Aenderung dann schon - das gehoert gesagt, sonst
// sucht jemand den Unterschied auf der falschen Seite.
return res.status(result.status).json({
error: result.body.error,
remote_changed: remoteChanged,
hint: remoteChanged.length > 0
? 'Im perinco CRM wurde bereits geschrieben, hier nicht.'
: undefined,
});
}
localChanged = result.body.changed;
}
res.json({ ok: true, local_changed: localChanged, remote_changed: remoteChanged });
} catch (err) {
next(err);
}
});
export default router;