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:
schakal00783
2026-09-17 23:07:59 +02:00
co-authored by Claude Opus 5
parent ad7f806258
commit 49c03f5e1b
22 changed files with 2339 additions and 23 deletions
+104
View File
@@ -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 };
+123
View File
@@ -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 } };
}
+29
View File
@@ -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
+256
View File
@@ -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;
+142
View File
@@ -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;
+9 -1
View File
@@ -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 = ?');
+237
View File
@@ -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;
+14
View File
@@ -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);