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;