feat: globale Suche und Anbindung an das ICON CRM
Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis, waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist der Code jetzt versioniert. Globale Suche: - server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und Akquise, dazu die erweiterte Suche mit dem Modell des Scanners - src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer springt in die Ansicht und oeffnet den Datensatz (navigation.tsx) ICON CRM: - server/src/routes/integrations.js liefert die Kontakte mit gesetztem Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von drueben herein - beide nur lesend, mit festem Token statt JWT - server/src/contactBridge.js und src/lib/contactMatch.ts erkennen Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie zusammenfuehren - server/src/ai.js buendelt die OpenAI-Aufrufe Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad7f806258
commit
49c03f5e1b
@@ -0,0 +1,104 @@
|
||||
import Setting from './scanner/models/Setting.js';
|
||||
|
||||
/*
|
||||
* Anbindung an OpenAI fuer das CRM (bisher nutzte nur der Scanner ein Modell).
|
||||
*
|
||||
* Der Schluessel wird NICHT zum zweiten Mal gepflegt: es ist derselbe, den der
|
||||
* Scanner benutzt - Einstellungen -> Scanner, gespeichert als `openai_api_key`
|
||||
* in der Scanner-Tabelle `settings`. Ein zweiter Schluessel an zweiter Stelle
|
||||
* waere eine Quelle fuer den Fall "die eine Funktion geht, die andere nicht,
|
||||
* und keiner weiss warum".
|
||||
*
|
||||
* Bewusst ohne das `openai`-Paket: gebraucht wird ein einziger Aufruf, und
|
||||
* `fetch` ist in Node eingebaut. Der Scanner behaelt sein SDK - dort haengt
|
||||
* mehr daran.
|
||||
*
|
||||
* Der Schluessel bleibt auf dem Server. Der Browser spricht nie mit OpenAI.
|
||||
*/
|
||||
|
||||
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
|
||||
const TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Dasselbe Modell wie im Scanner - was dort ausreicht, reicht hier auch. */
|
||||
export const AI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
|
||||
|
||||
class AiError extends Error {
|
||||
constructor(message, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Schluessel des Scanners, ersatzweise aus der Umgebung. */
|
||||
export async function aiKey() {
|
||||
const fromDb = await Setting.getVal('openai_api_key');
|
||||
return (fromDb || process.env.OPENAI_API_KEY || '').trim();
|
||||
}
|
||||
|
||||
export async function aiConfigured() {
|
||||
return Boolean(await aiKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Aufruf an das Modell, Antwort als JSON.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.system
|
||||
* @param {string} options.user
|
||||
* @param {number} [options.maxTokens]
|
||||
*/
|
||||
export async function askModelJson({ system, user, maxTokens }) {
|
||||
const key = await aiKey();
|
||||
if (!key) {
|
||||
throw new AiError(
|
||||
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).',
|
||||
503
|
||||
);
|
||||
}
|
||||
|
||||
const body = {
|
||||
model: AI_MODEL,
|
||||
messages: [
|
||||
...(system ? [{ role: 'system', content: system }] : []),
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
response_format: { type: 'json_object' },
|
||||
};
|
||||
if (maxTokens) body.max_tokens = maxTokens;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
|
||||
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
// Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
|
||||
// sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel.
|
||||
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||
}
|
||||
|
||||
const text = payload?.choices?.[0]?.message?.content;
|
||||
if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
|
||||
}
|
||||
}
|
||||
|
||||
export { AiError };
|
||||
@@ -0,0 +1,123 @@
|
||||
import { pool } from './db.js';
|
||||
import { normalizeAvatar } from './userFields.js';
|
||||
|
||||
/*
|
||||
* Der Feldabgleich mit dem ICON CRM - eine Stelle fuer beide Richtungen.
|
||||
*
|
||||
* Derselbe Mensch steht in beiden Haeusern. Unterscheidet sich etwas - andere
|
||||
* Telefonnummer, fehlendes Foto -, soll der Unterschied in BEIDEN Systemen
|
||||
* verschwinden und nicht nur in dem, in dem gerade jemand sitzt.
|
||||
*
|
||||
* Geschrieben wird von zwei Seiten:
|
||||
* routes/integrations.js das ICON CRM schreibt hierher (fester Token)
|
||||
* routes/iconCrm.js ein Benutzer hier gleicht ab und schreibt mit
|
||||
* Beide benutzen diese Abbildung, damit die Felder nicht auseinanderlaufen.
|
||||
* Drueben steht dieselbe Datei mit denselben Feldnamen.
|
||||
*
|
||||
* Bewusst eng gefasst: nur die Felder, die die Bruecke ohnehin herausgibt.
|
||||
* Anschrift, Anrede, Mobilnummer, Website und die Mail-Einwilligung sind nicht
|
||||
* erreichbar - die kennt das ICON CRM nicht und hat drueben niemand zu
|
||||
* entscheiden. Das Haekchen `icon` ebenfalls nicht: wer hier weitergegeben
|
||||
* wird, entscheidet dieses Haus.
|
||||
*/
|
||||
|
||||
const trimOrNull = (value, max) => {
|
||||
const text = String(value ?? '').trim();
|
||||
return text ? text.slice(0, max) : null;
|
||||
};
|
||||
|
||||
/** Wie in routes/leads.js: JSON-Array als Text, ohne Leereintraege und Duplikate. */
|
||||
function serializeTags(value) {
|
||||
const list = Array.isArray(value) ? value : String(value ?? '').split(',');
|
||||
const tags = [...new Set(list.map((t) => String(t).trim()).filter(Boolean))];
|
||||
return JSON.stringify(tags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feldname der Bruecke -> Spalte hier.
|
||||
*
|
||||
* Die Namen sind die der Schnittstelle. Hier heissen sie zufaellig genauso wie
|
||||
* die Spalten - drueben nicht, dort sind es `bio` und `lead_source`.
|
||||
*/
|
||||
export const BRIDGE_FIELDS = {
|
||||
first_name: { column: 'first_name', parse: (v) => trimOrNull(v, 120) },
|
||||
last_name: { column: 'last_name', parse: (v) => trimOrNull(v, 120) },
|
||||
position: { column: 'position', parse: (v) => trimOrNull(v, 190) },
|
||||
email: { column: 'email', parse: (v) => trimOrNull(v, 190) },
|
||||
phone: { column: 'phone', parse: (v) => trimOrNull(v, 64) },
|
||||
linkedin: { column: 'linkedin', parse: (v) => trimOrNull(v, 255) },
|
||||
source: { column: 'source', parse: (v) => trimOrNull(v, 120) },
|
||||
notes: { column: 'notes', parse: (v) => (v ? String(v) : null) },
|
||||
tags: { column: 'tags', parse: (v) => serializeTags(v) },
|
||||
};
|
||||
|
||||
/*
|
||||
* Die Firma fehlt hier mit Absicht.
|
||||
*
|
||||
* Hier ist sie ein Textfeld, drueben ein Verweis auf icon_companies. In diese
|
||||
* Richtung waere sie zwar leicht zu schreiben, in die andere nicht - und ein
|
||||
* Feld, das nur in einer Richtung abgleichbar ist, erklaert sich niemandem.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wendet einen Abgleich auf einen Kontakt an.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {number} params.id Kontakt (crm_leads) hier
|
||||
* @param {object} params.body Felder in der Sprache der Bruecke
|
||||
* @returns {Promise<{status: number, body: object}>}
|
||||
*/
|
||||
export async function applyBridgePatch({ id, body }) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { status: 400, body: { error: 'Ungueltige Kennung.' } };
|
||||
}
|
||||
|
||||
const data = {};
|
||||
for (const [field, config] of Object.entries(BRIDGE_FIELDS)) {
|
||||
if (body?.[field] === undefined) continue;
|
||||
data[config.column] = config.parse(body[field]);
|
||||
}
|
||||
|
||||
// Das Foto laeuft durch dieselbe Pruefung wie im eigenen Formular: von
|
||||
// drueben kommt eine Data-URL, und die darf weder unbegrenzt gross noch
|
||||
// beliebigen Typs sein.
|
||||
if (body?.avatar !== undefined) {
|
||||
const avatar = normalizeAvatar(body.avatar);
|
||||
if (avatar.error) return { status: 400, body: { error: avatar.error } };
|
||||
data.avatar = avatar.value;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { status: 400, body: { error: 'Es wurde kein abgleichbares Feld uebergeben.' } };
|
||||
}
|
||||
|
||||
// Vor- und Nachname sind hier NOT NULL - der Abgleich darf sie aendern, aber
|
||||
// nicht leeren.
|
||||
if (data.last_name === null || data.first_name === null) {
|
||||
return { status: 400, body: { error: 'Vor- und Nachname duerfen nicht geleert werden.' } };
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? LIMIT 1', [id]);
|
||||
const before = rows[0];
|
||||
if (!before) return { status: 404, body: { error: 'Kontakt nicht gefunden.' } };
|
||||
|
||||
/*
|
||||
* Kontakte im Vorbereich "Aus Mails" sind noch keine Kontakte - sie werden
|
||||
* drueben nicht angezeigt und sollen von dort auch nicht geaendert werden.
|
||||
*/
|
||||
if (before.mail_pool) {
|
||||
return { status: 409, body: { error: 'Dieser Eintrag liegt noch im Vorbereich "Aus Mails".' } };
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
await pool.query(
|
||||
`UPDATE crm_leads SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...columns.map((c) => data[c]), id]
|
||||
);
|
||||
|
||||
const changed = columns.filter(
|
||||
(column) => String(before[column] ?? '') !== String(data[column] ?? '')
|
||||
);
|
||||
|
||||
return { status: 200, body: { ok: true, changed } };
|
||||
}
|
||||
@@ -55,9 +55,38 @@ export async function initDatabase() {
|
||||
await migrateOpportunityConsultant();
|
||||
await seedAcquisitionOptions();
|
||||
await dropProviderOptions();
|
||||
await migrateIconFlag();
|
||||
await ensureAdminUser();
|
||||
}
|
||||
|
||||
/*
|
||||
* Merker "an das ICON CRM weitergeben".
|
||||
*
|
||||
* Das ICON CRM (icrm.icon-experts.de) zeigt die hiesigen Kontakte lesend mit an.
|
||||
* Welche das sind, entscheidet ab jetzt dieses Haekchen und nicht mehr die
|
||||
* blosse Existenz des Kontakts.
|
||||
*
|
||||
* Beim Anlegen der Spalte werden alle vorhandenen Kontakte auf 1 gesetzt: sie
|
||||
* waren bis dahin drueben sichtbar, und ein Feld einzufuehren darf nichts
|
||||
* verschwinden lassen. Neue Kontakte starten auf 0 - Weitergabe ist eine
|
||||
* Entscheidung, kein Nebeneffekt.
|
||||
*/
|
||||
async function migrateIconFlag() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'crm_leads' AND COLUMN_NAME = 'icon' LIMIT 1`
|
||||
);
|
||||
if (rows.length > 0) return;
|
||||
|
||||
await pool.query(
|
||||
"ALTER TABLE crm_leads ADD COLUMN icon TINYINT(1) NOT NULL DEFAULT 0 AFTER kind, ADD KEY idx_crm_leads_icon (icon)"
|
||||
);
|
||||
const [result] = await pool.query('UPDATE crm_leads SET icon = 1 WHERE mail_pool = 0');
|
||||
console.log(
|
||||
`[perinco CRM] Merker "ICON" ergaenzt - ${result.affectedRows} vorhandene(r) Kontakt(e) uebernommen.`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: aus dem Vertriebsstatus eines Kontakts
|
||||
* (Neu, Kontaktiert, Qualifiziert, Angebot, Gewonnen, Verloren) wird seine Art
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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 ICON CRM.
|
||||
*
|
||||
* Das Gegenstueck zu `routes/integrations.js`: dort gibt dieses CRM seine
|
||||
* Kontakte heraus, hier holt es die von drueben. Beide Richtungen sind gleich
|
||||
* gebaut, damit man nur ein Muster verstehen muss.
|
||||
*
|
||||
* Es wird NICHTS gespiegelt. Die Kontakte gehoeren dem ICON CRM, werden dort
|
||||
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt.
|
||||
*
|
||||
* Eine Ausnahme gibt es: steht derselbe Mensch nachweislich in beiden Haeusern,
|
||||
* kann ein Benutzer die Unterschiede abgleichen. Dann wird auch nach drueben
|
||||
* geschrieben - Feld fuer Feld und nur, was er selbst entschieden hat.
|
||||
*/
|
||||
|
||||
/** Eine Minute Zwischenspeicher - die Liste aendert sich nicht im Sekundentakt. */
|
||||
const CACHE_MS = 60_000;
|
||||
let cache = { at: 0, contacts: [] };
|
||||
|
||||
const CONFIGURED = () => Boolean(process.env.ICON_CRM_URL && process.env.ICON_CRM_TOKEN);
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
res.json({ configured: CONFIGURED(), url: process.env.ICON_CRM_URL ?? null });
|
||||
});
|
||||
|
||||
/**
|
||||
* Die Kontakte von drueben holen - mit Zwischenspeicher.
|
||||
*
|
||||
* Liegt ausserhalb der Route, weil auch die Suche sie braucht. Ohne den
|
||||
* gemeinsamen Zwischenspeicher loeste jede Suchanfrage einen zweiten Aufruf
|
||||
* nach drueben aus.
|
||||
*
|
||||
* Wirft nie: die Suche darf nicht scheitern, nur weil das andere Haus gerade
|
||||
* nicht erreichbar ist. Der Grund steht in `error`.
|
||||
*/
|
||||
export async function fetchIconContacts() {
|
||||
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.ICON_CRM_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.ICON_CRM_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 ICON CRM hat den Zugang abgelehnt (Token stimmt nicht).'
|
||||
: `Das ICON CRM antwortete mit HTTP ${response.status}.`,
|
||||
status: 502,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const contacts = (payload?.contacts ?? []).map(toLeadShape);
|
||||
cache = { at: Date.now(), contacts };
|
||||
return { contacts, cached: false };
|
||||
} catch (err) {
|
||||
return {
|
||||
contacts: [],
|
||||
cached: false,
|
||||
error:
|
||||
err?.name === 'AbortError'
|
||||
? 'Das ICON CRM hat nicht rechtzeitig geantwortet.'
|
||||
: 'Das ICON CRM ist gerade nicht erreichbar.',
|
||||
status: err?.name === 'AbortError' ? 504 : 502,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/contacts', async (req, res) => {
|
||||
if (!CONFIGURED()) {
|
||||
return res.status(503).json({
|
||||
error: 'Der Zugriff auf das ICON CRM ist nicht eingerichtet (ICON_CRM_URL, ICON_CRM_TOKEN).',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await fetchIconContacts();
|
||||
if (result.error) return res.status(result.status ?? 502).json({ error: result.error });
|
||||
res.json({ contacts: result.contacts, cached: result.cached });
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 patchIconContact(remoteId, fields) {
|
||||
if (!CONFIGURED()) {
|
||||
return { ok: false, error: 'Der Zugriff auf das ICON CRM ist nicht eingerichtet.' };
|
||||
}
|
||||
|
||||
const base = String(process.env.ICON_CRM_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.ICON_CRM_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 ICON CRM hat den Zugang abgelehnt (Token stimmt nicht).'
|
||||
: `Das ICON CRM antwortete mit HTTP ${response.status}.`),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, body: await response.json().catch(() => ({})) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
err?.name === 'AbortError'
|
||||
? 'Das ICON CRM hat nicht rechtzeitig geantwortet.'
|
||||
: 'Das ICON 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 ("icon-31").
|
||||
const remoteId = Number(String(req.params.remoteId).replace(/^icon-/, ''));
|
||||
const leadId = Number(req.body?.lead_id);
|
||||
const local = req.body?.local ?? {};
|
||||
const remote = req.body?.remote ?? {};
|
||||
|
||||
if (!Number.isInteger(remoteId) || remoteId <= 0) {
|
||||
return res.status(400).json({ error: 'Ungueltige Kennung im ICON CRM.' });
|
||||
}
|
||||
if (!Number.isInteger(leadId) || leadId <= 0) {
|
||||
return res.status(400).json({ error: 'Ungueltige 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 ausgewaehlt.' });
|
||||
}
|
||||
|
||||
try {
|
||||
let remoteChanged = [];
|
||||
if (wantsRemote) {
|
||||
const result = await patchIconContact(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: leadId, body: local });
|
||||
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 ICON 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);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Ein ICON-Kontakt in der Form, die diese Oberflaeche ohnehin darstellt.
|
||||
*
|
||||
* Die Kennung bekommt ein Praefix: sie zaehlt in einer anderen Datenbank und
|
||||
* darf nie mit einer hiesigen verwechselt werden - sonst oeffnete ein Klick
|
||||
* den falschen Datensatz.
|
||||
*/
|
||||
function toLeadShape(row) {
|
||||
return {
|
||||
id: `icon-${row.id}`,
|
||||
first_name: row.first_name ?? '',
|
||||
last_name: row.last_name ?? '',
|
||||
company: row.company ?? null,
|
||||
position: row.position ?? null,
|
||||
email: row.email ?? null,
|
||||
phone: row.phone ?? null,
|
||||
linkedin: row.linkedin ?? null,
|
||||
// Druebens Arten (Experte / Ansprechpartner / Lead) sind andere als hier -
|
||||
// sie werden unveraendert gezeigt und nicht auf Recruiter/Partner/Endkunde
|
||||
// umgebogen.
|
||||
kind: row.kind ?? null,
|
||||
source: row.source ?? null,
|
||||
tags: Array.isArray(row.tags) ? row.tags : [],
|
||||
notes: row.notes ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
created_at: row.created_at ?? null,
|
||||
updated_at: row.updated_at ?? null,
|
||||
/** Kennzeichen fuer die Oberflaeche: goldener Rahmen, keine Bearbeitung. */
|
||||
source_system: 'icon',
|
||||
};
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,142 @@
|
||||
import express from 'express';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { pool } from '../db.js';
|
||||
import { applyBridgePatch } from '../contactBridge.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/*
|
||||
* Schnittstelle fuer das ICON CRM.
|
||||
*
|
||||
* Das ICON CRM (icrm.icon-experts.de) zeigt die Kontakte dieses Hauses
|
||||
* zusaetzlich zu seinen eigenen an - gekennzeichnet, nicht vermischt, und
|
||||
* ausschliesslich lesend. Beide Systeme gehoeren demselben Haus, tauschen aber
|
||||
* bewusst nur diesen einen Ausschnitt aus.
|
||||
*
|
||||
* Drei Festlegungen:
|
||||
*
|
||||
* * Lesen, und seit dem Kontaktabgleich ein eng gefasstes PATCH. Bis dahin
|
||||
* galt hier "nur Lesen, auch spaeter nicht"; aufgegeben wurde das, weil
|
||||
* derselbe Mensch in beiden Haeusern steht und ein Unterschied sonst nur
|
||||
* auf einer Seite verschwindet. Angelegt oder geloescht wird hier weiterhin
|
||||
* nichts, und geaendert nur, was die Liste unten ohnehin herausgibt.
|
||||
* * Kein JWT, sondern ein fester Token. Der Aufrufer ist kein Mensch mit
|
||||
* Sitzung, sondern das ICON-Backend; ein Token in dessen .env ist das
|
||||
* passende Mittel.
|
||||
* * Ausgeliefert wird nur, was drueben ein Feld hat. Anschrift, Anrede und
|
||||
* Website bleiben hier - das ICON CRM kennt sie bei Kontakten nicht, und
|
||||
* dafuer neue Felder anzulegen war ausdruecklich nicht gewollt.
|
||||
*
|
||||
* Nicht ausgeliefert werden ausserdem Zustaendiger und Ersteller: das sind
|
||||
* Benutzer DIESES Systems, deren Kennungen drueben nichts bedeuten.
|
||||
*/
|
||||
|
||||
/** Vergleich ohne Laufzeitunterschied - sonst laesst sich der Token erraten. */
|
||||
function tokenMatches(given) {
|
||||
const expected = process.env.ICON_API_TOKEN || '';
|
||||
if (!expected || !given) return false;
|
||||
|
||||
const a = Buffer.from(given);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function requireToken(req, res, next) {
|
||||
if (!process.env.ICON_API_TOKEN) {
|
||||
return res.status(503).json({ error: 'Schnittstelle ist nicht eingerichtet.' });
|
||||
}
|
||||
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!tokenMatches(token)) return res.status(401).json({ error: 'Nicht berechtigt.' });
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
router.use(requireToken);
|
||||
|
||||
/*
|
||||
* Ausgeliefert werden alle Kontakte.
|
||||
*
|
||||
* Frueher entschied das Haekchen "ICON" am einzelnen Datensatz darueber. Das
|
||||
* ist entfallen: ob die Kontakte dieses Hauses drueben erscheinen, entscheidet
|
||||
* dort der Schalter in der Kontaktliste - eine Ansichtssache und keine, die an
|
||||
* jedem Datensatz einzeln zu pflegen waere. Die Spalte `icon` bleibt vorerst in
|
||||
* der Tabelle stehen, wird aber nicht mehr ausgewertet.
|
||||
*
|
||||
* Kontakte des Vorbereichs "Aus Mails" bleiben aussen vor: sie sind noch nicht
|
||||
* uebernommen, oft unvollstaendig und teils Dubletten.
|
||||
*/
|
||||
router.get('/contacts', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, first_name, last_name, company, position, email, phone, mobile,
|
||||
linkedin, kind, source, tags, notes, avatar, created_at, updated_at
|
||||
FROM crm_leads
|
||||
WHERE mail_pool = 0
|
||||
ORDER BY last_name, first_name`
|
||||
);
|
||||
|
||||
res.set('Cache-Control', 'private, max-age=60');
|
||||
res.json({
|
||||
contacts: rows.map((row) => ({
|
||||
id: row.id,
|
||||
first_name: row.first_name,
|
||||
last_name: row.last_name,
|
||||
position: row.position,
|
||||
company: row.company,
|
||||
email: row.email,
|
||||
// Drueben gibt es genau ein Telefonfeld. Festnetz zuerst, sonst mobil -
|
||||
// eine leere Zelle waere die schlechtere Antwort.
|
||||
phone: row.phone || row.mobile || null,
|
||||
linkedin: row.linkedin,
|
||||
kind: row.kind,
|
||||
source: row.source,
|
||||
tags: parseTags(row.tags),
|
||||
notes: row.notes,
|
||||
avatar: row.avatar,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})),
|
||||
count: rows.length,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Schreibweg: ein Feldabgleich von drueben.
|
||||
*
|
||||
* Welche Felder erreichbar sind und warum nicht mehr, steht in
|
||||
* contactBridge.js. Kein Anlegen, kein Loeschen, keine Sammeländerung -
|
||||
* geaendert wird nur, was die Liste oben ohnehin herausgibt, und nur an einem
|
||||
* Kontakt, den es schon gibt. Eintraege aus dem Vorbereich "Aus Mails" weist
|
||||
* contactBridge.js ab; sie werden drueben nicht gezeigt.
|
||||
*/
|
||||
router.patch('/contacts/:id', async (req, res, next) => {
|
||||
try {
|
||||
const result = await applyBridgePatch({ id: Number(req.params.id), body: req.body || {} });
|
||||
res.status(result.status).json(result.body);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Die Schlagworte liegen als JSON-Array in einem Textfeld. */
|
||||
function parseTags(value) {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === 'string') : [];
|
||||
} catch {
|
||||
// Aeltere Eintraege koennen kommagetrennt sein.
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -62,6 +62,8 @@ function mapLead(row) {
|
||||
owner_id: row.owner_id,
|
||||
created_by: row.created_by,
|
||||
// TINYINT(1) kommt als 0/1 - im Frontend sollen es Schalter sein.
|
||||
// Haekchen "an das ICON CRM weitergeben".
|
||||
icon: Boolean(row.icon),
|
||||
mail_pool: Boolean(row.mail_pool),
|
||||
mail_consent: Boolean(row.mail_consent),
|
||||
};
|
||||
@@ -149,12 +151,13 @@ router.post('/', async (req, res, next) => {
|
||||
|
||||
const consent = consentUpdate(req.body?.mail_consent, null);
|
||||
|
||||
const columns = [...TEXT_FIELDS, 'kind', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)];
|
||||
const columns = [...TEXT_FIELDS, 'kind', 'icon', 'tags', 'owner_id', 'created_by', ...Object.keys(consent)];
|
||||
const values = [
|
||||
...TEXT_FIELDS.map((field) =>
|
||||
field === 'first_name' ? firstName : field === 'last_name' ? lastName : emptyToNull(req.body?.[field])
|
||||
),
|
||||
kind,
|
||||
req.body?.icon ? 1 : 0,
|
||||
serializeTags(req.body?.tags) ?? '[]',
|
||||
ownerId,
|
||||
req.user.id,
|
||||
@@ -450,6 +453,11 @@ router.patch('/:id', async (req, res, next) => {
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
if (req.body?.icon !== undefined) {
|
||||
fields.push('icon = ?');
|
||||
values.push(req.body.icon ? 1 : 0);
|
||||
}
|
||||
|
||||
if (req.body?.kind !== undefined) {
|
||||
if (!KINDS.has(req.body.kind)) return res.status(400).json({ error: 'Ungültige Kontaktart.' });
|
||||
fields.push('kind = ?');
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { askModelJson, aiConfigured, AI_MODEL } from '../ai.js';
|
||||
import { fetchIconContacts } from './iconCrm.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
/*
|
||||
* Globale Suche - "wie Google, nur ueber das CRM".
|
||||
*
|
||||
* Uebernommen aus dem ICON CRM, damit beide Haeuser dieselbe Geste haben: ein
|
||||
* Feld oben im Kopf, zwei Modi (Lupe und Funke), ein Treffer oeffnet direkt den
|
||||
* Datensatz.
|
||||
*
|
||||
* Durchsucht werden Kontakte, Opportunities, Akquise, Termine - und die
|
||||
* Kontakte des ICON CRM, sofern die Schnittstelle eingerichtet ist.
|
||||
*
|
||||
* Bewusst LIKE statt Volltextindex: die Datenmengen sind klein (Tausende
|
||||
* Zeilen, nicht Millionen), und ein FULLTEXT-Index in MariaDB wuerde bei
|
||||
* Teilwortsuche - "informat" soll "Finanz Informatik" finden - nicht helfen.
|
||||
*/
|
||||
|
||||
const LIMIT_PER_GROUP = 12;
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q.length < 2) return res.json({ query: q, groups: [], total: 0 });
|
||||
|
||||
const like = `%${q}%`;
|
||||
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || LIMIT_PER_GROUP));
|
||||
|
||||
try {
|
||||
const [leads, opportunities, acquisitions, events] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT l.id, CONCAT_WS(' ', l.first_name, l.last_name) AS title,
|
||||
l.position, l.company, l.email, l.avatar, l.kind, l.source, l.mail_pool
|
||||
FROM crm_leads l
|
||||
WHERE CONCAT_WS(' ', l.first_name, l.last_name) LIKE ? OR l.company LIKE ?
|
||||
OR l.email LIKE ? OR l.phone LIKE ? OR l.mobile LIKE ? OR l.position LIKE ?
|
||||
OR l.tags LIKE ? OR l.notes LIKE ? OR l.city LIKE ?
|
||||
ORDER BY l.mail_pool, l.last_name LIMIT ${limit}`,
|
||||
[like, like, like, like, like, like, like, like, like]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT o.id, o.title, o.company, o.stage, o.probability, o.daily_rate, o.next_step
|
||||
FROM crm_opportunities o
|
||||
WHERE o.deleted_at IS NULL
|
||||
AND (o.title LIKE ? OR o.company LIKE ? OR o.description LIKE ?
|
||||
OR o.notes LIKE ? OR o.tags LIKE ? OR o.next_step LIKE ?)
|
||||
ORDER BY o.updated_at DESC LIMIT ${limit}`,
|
||||
[like, like, like, like, like, like]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT a.id, a.title, a.company, a.provider, a.channel, a.consultant,
|
||||
a.found_at, a.converted_at
|
||||
FROM crm_acquisitions a
|
||||
WHERE a.deleted_at IS NULL
|
||||
AND (a.title LIKE ? OR a.company LIKE ? OR a.description LIKE ?
|
||||
OR a.notes LIKE ? OR a.tags LIKE ? OR a.sender LIKE ?
|
||||
OR a.provider LIKE ? OR a.consultant LIKE ?)
|
||||
ORDER BY a.found_at DESC LIMIT ${limit}`,
|
||||
[like, like, like, like, like, like, like, like]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT e.id, e.title, e.location, e.starts_at, e.all_day
|
||||
FROM crm_events e
|
||||
WHERE e.title LIKE ? OR e.description LIKE ? OR e.location LIKE ?
|
||||
ORDER BY e.starts_at DESC LIMIT ${limit}`,
|
||||
[like, like, like]
|
||||
),
|
||||
]);
|
||||
|
||||
/*
|
||||
* Die Kontakte des ICON CRM kommen ueber HTTP und liegen nicht in dieser
|
||||
* Datenbank - gefiltert wird deshalb hier im Speicher. Faellt das andere
|
||||
* Haus aus, fehlt nur diese Gruppe; die uebrige Suche bleibt benutzbar.
|
||||
*/
|
||||
const icon = await fetchIconContacts();
|
||||
const needle = q.toLowerCase();
|
||||
const iconHits = icon.contacts
|
||||
.filter((contact) =>
|
||||
[
|
||||
`${contact.first_name} ${contact.last_name}`,
|
||||
contact.position,
|
||||
contact.company,
|
||||
contact.email,
|
||||
contact.phone,
|
||||
(contact.tags ?? []).join(' '),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(needle)
|
||||
)
|
||||
.slice(0, limit);
|
||||
|
||||
const groups = [
|
||||
{ type: 'lead', label: 'Kontakte', items: leads[0] },
|
||||
{
|
||||
type: 'icon',
|
||||
label: 'Kontakte (ICON CRM)',
|
||||
items: iconHits.map((contact) => ({
|
||||
id: contact.id,
|
||||
title: `${contact.first_name} ${contact.last_name}`.trim(),
|
||||
position: contact.position,
|
||||
company: contact.company,
|
||||
email: contact.email,
|
||||
avatar: contact.avatar,
|
||||
kind: contact.kind,
|
||||
})),
|
||||
},
|
||||
{ type: 'opportunity', label: 'Opportunities', items: opportunities[0] },
|
||||
{ type: 'acquisition', label: 'Akquise', items: acquisitions[0] },
|
||||
{ type: 'event', label: 'Termine', items: events[0] },
|
||||
].filter((group) => group.items.length > 0);
|
||||
|
||||
res.json({
|
||||
query: q,
|
||||
groups,
|
||||
total: groups.reduce((sum, group) => sum + group.items.length, 0),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------- KI-Suche */
|
||||
|
||||
/*
|
||||
* Erweiterte Suche: eine Frage in Worten statt eines Suchbegriffs.
|
||||
*
|
||||
* Vorgehen: der Bestand wird zusammengefasst und zusammen mit der Frage an das
|
||||
* Modell gegeben. Bewusst kein erzeugtes SQL - ein Modell, das Abfragen auf die
|
||||
* Datenbank schreiben darf, ist eine Angriffsflaeche, und der Bestand ist klein
|
||||
* genug, um ihn vollstaendig mitzugeben.
|
||||
*
|
||||
* Die Obergrenze je Liste ist das Sicherheitsnetz: waechst der Bestand ueber
|
||||
* diese Groesse, wird der Kontext gekuerzt, und die Antwort sagt das dazu.
|
||||
*/
|
||||
const AI_LIMIT = 400;
|
||||
|
||||
const AI_SYSTEM = `Du beantwortest Fragen zum Datenbestand eines CRM für die Vermittlung von IT- und Management-Beratern.
|
||||
Du bekommst den Bestand als JSON und eine Frage. Antworte ausschließlich mit JSON:
|
||||
{ "answer": string, "refs": [{ "type": "lead"|"opportunity"|"acquisition"|"event", "id": number, "label": string }] }
|
||||
Regeln:
|
||||
- Antworte auf Deutsch, knapp und sachlich. Keine Anrede, keine Einleitung.
|
||||
- Stütze dich AUSSCHLIESSLICH auf die übergebenen Daten. Nichts hinzuerfinden, nicht schätzen.
|
||||
- Findest du nichts, sage das klar ("Dazu steht nichts im Bestand.") und gib refs als leere Liste.
|
||||
- "refs" enthält jeden Datensatz, den die Antwort nennt, damit die Oberfläche ihn verlinken kann. Höchstens 30.
|
||||
- Zahlenfragen ("wie viele") beantwortest du mit der Zahl und nennst die Datensätze in refs.
|
||||
- "leads" sind die Kontakte. "kind" ist die Art (Recruiter, Partner, Endkunde), "source" die Herkunft. Kontakte mit "mail_pool": true stehen erst im Vorbereich "Aus Mails" und sind noch nicht gepflegt - sie zählen mit, aber sag dazu, dass sie aus dem Postfach stammen.
|
||||
- "opportunities" sind laufende Vermittlungen. "stage" ist die Phase, "daily_rate" ein TAGESsatz in Euro, "probability" die Wahrscheinlichkeit in Prozent. null heißt: nicht hinterlegt, nicht null Euro. Bei Fragen nach dem höchsten oder niedrigsten Satz nur Datensätze mit hinterlegtem Wert vergleichen und dazusagen, wie viele keinen haben.
|
||||
- "acquisitions" sind eingegangene Projektangebote (Akquise). "converted_at" gesetzt heißt: daraus wurde eine Opportunity.
|
||||
- "icon_contacts" sind Kontakte des Schwestersystems ICON CRM. Sie zählen mit, sind aber klar als solche zu benennen. Ihre "id" ist eine Zeichenkette ("icon-31") und gehört NICHT in refs.`;
|
||||
|
||||
router.post('/ai', async (req, res, next) => {
|
||||
const question = String(req.body?.question || '').trim().slice(0, 2000);
|
||||
if (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' });
|
||||
if (!(await aiConfigured())) {
|
||||
return res.status(503).json({
|
||||
error: 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const [leads] = await pool.query(
|
||||
`SELECT l.id, CONCAT_WS(' ', l.first_name, l.last_name) AS name, l.position, l.company,
|
||||
l.kind, l.source, l.email, l.phone, l.city, l.tags, l.mail_pool,
|
||||
u.name AS owner
|
||||
FROM crm_leads l
|
||||
LEFT JOIN crm_users u ON u.id = l.owner_id
|
||||
ORDER BY l.mail_pool, l.last_name LIMIT ${AI_LIMIT}`
|
||||
);
|
||||
const [opportunities] = await pool.query(
|
||||
`SELECT o.id, o.title, o.company, o.stage, o.probability, o.daily_rate, o.duration_days,
|
||||
o.fixed_price, o.start_date, o.end_date, o.next_step, o.tags,
|
||||
o.last_interaction_at, u.name AS owner
|
||||
FROM crm_opportunities o
|
||||
LEFT JOIN crm_users u ON u.id = o.owner_id
|
||||
WHERE o.deleted_at IS NULL
|
||||
ORDER BY o.updated_at DESC LIMIT ${AI_LIMIT}`
|
||||
);
|
||||
const [acquisitions] = await pool.query(
|
||||
`SELECT a.id, a.title, a.company, a.provider, a.channel, a.consultant, a.daily_rate,
|
||||
a.start_date, a.found_at, a.converted_at, a.tags
|
||||
FROM crm_acquisitions a
|
||||
WHERE a.deleted_at IS NULL
|
||||
ORDER BY a.found_at DESC LIMIT ${AI_LIMIT}`
|
||||
);
|
||||
const [events] = await pool.query(
|
||||
`SELECT e.id, e.title, e.location, e.starts_at, e.ends_at, e.all_day
|
||||
FROM crm_events e ORDER BY e.starts_at DESC LIMIT ${AI_LIMIT}`
|
||||
);
|
||||
|
||||
// Die Kontakte von drueben gehoeren zum Bild - sonst antwortet das Modell
|
||||
// "dazu steht nichts im Bestand", obwohl die Person in der Liste steht.
|
||||
const icon = await fetchIconContacts();
|
||||
|
||||
const truncated = [leads, opportunities, acquisitions, events].some(
|
||||
(list) => list.length >= AI_LIMIT
|
||||
);
|
||||
|
||||
const result = await askModelJson({
|
||||
system: AI_SYSTEM,
|
||||
user: `Frage: ${question}\n\nBestand:\n${JSON.stringify({
|
||||
leads: leads.map((row) => ({ ...row, mail_pool: Boolean(row.mail_pool) })),
|
||||
icon_contacts: icon.contacts.map((contact) => ({
|
||||
id: contact.id,
|
||||
name: `${contact.first_name} ${contact.last_name}`.trim(),
|
||||
position: contact.position,
|
||||
company: contact.company,
|
||||
email: contact.email,
|
||||
kind: contact.kind,
|
||||
tags: contact.tags,
|
||||
})),
|
||||
opportunities,
|
||||
acquisitions,
|
||||
events,
|
||||
})}`,
|
||||
maxTokens: 4000,
|
||||
});
|
||||
|
||||
res.json({
|
||||
answer: String(result?.answer ?? '').trim(),
|
||||
refs: Array.isArray(result?.refs) ? result.refs.slice(0, 30) : [],
|
||||
model: AI_MODEL,
|
||||
truncated,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.status) return res.status(err.status).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -13,6 +13,9 @@ import badgeRoutes from './routes/badges.js';
|
||||
import dashboardRoutes from './routes/dashboard.js';
|
||||
import scannerRoutes from './routes/scanner.js';
|
||||
import mailRoutes from './routes/mail.js';
|
||||
import integrationRoutes from './routes/integrations.js';
|
||||
import iconCrmRoutes from './routes/iconCrm.js';
|
||||
import searchRoutes from './routes/search.js';
|
||||
import { requireAuth } from './middleware/auth.js';
|
||||
import { initScannerDatabase } from './scanner/models/index.js';
|
||||
import { startScheduler } from './scanner/services/scheduler.js';
|
||||
@@ -44,9 +47,20 @@ app.get('/api/health', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Leseschnittstelle fuer das ICON CRM. Eigener Token statt JWT - der Aufrufer
|
||||
* ist das ICON-Backend, kein angemeldeter Mensch. Nur GET.
|
||||
*/
|
||||
app.use('/api/integrations', integrationRoutes);
|
||||
|
||||
// Gegenrichtung: die Kontakte des ICON CRM lesend anzeigen. Nichts wird gespeichert.
|
||||
app.use('/api/icon', iconCrmRoutes);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/leads', leadRoutes);
|
||||
// Globale Suche (Volltext) und die erweiterte Suche mit dem Modell des Scanners.
|
||||
app.use('/api/search', searchRoutes);
|
||||
app.use('/api/connections', connectionRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
app.use('/api/opportunities', opportunityRoutes);
|
||||
|
||||
Reference in New Issue
Block a user