Quellcode des ICON CRM (4)
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user