Quellcode des ICON CRM (4)

This commit is contained in:
2026-09-14 16:55:36 +02:00
parent 28763fed6d
commit e383400d7c
25 changed files with 6030 additions and 0 deletions
+134
View File
@@ -0,0 +1,134 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
const router = express.Router();
router.use(requireAuth);
/*
* Lesender Zugriff auf die Kontakte des perinco CRM.
*
* Es wird NICHTS gespeichert. 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.
*
* 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',
};
}
export default router;