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>
262 lines
9.4 KiB
JavaScript
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;
|