KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand
Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf. - Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu) - Neuer Endpunkt GET /api/ai/models (admin) - Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet; Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist - Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen, Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3f25355a6d
commit
cbac02a96a
@@ -111,6 +111,49 @@ export async function saveAiModel(model) {
|
||||
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Modelle, die der hinterlegte Schluessel nutzen darf.
|
||||
*
|
||||
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
|
||||
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
|
||||
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
|
||||
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
|
||||
*/
|
||||
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
||||
const MODELS_TTL_MS = 10 * 60_000;
|
||||
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
|
||||
let modelCache = { key: '', at: 0, list: [] };
|
||||
|
||||
export async function listAiModels() {
|
||||
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
|
||||
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
|
||||
return modelCache.list;
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(MODELS_ENDPOINT, {
|
||||
headers: { Authorization: `Bearer ${config.key}` },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch {
|
||||
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
|
||||
}
|
||||
|
||||
const list = (payload?.data ?? [])
|
||||
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
|
||||
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
|
||||
// Neueste zuerst - die will man meistens.
|
||||
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
|
||||
|
||||
modelCache = { key: config.key, at: Date.now(), list };
|
||||
return list;
|
||||
}
|
||||
|
||||
class AiError extends Error {
|
||||
constructor(message, status = 502) {
|
||||
super(message);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { pool } from './db.js';
|
||||
import { logHistory } from './history.js';
|
||||
import { normalizeAvatar } from './userFields.js';
|
||||
|
||||
/*
|
||||
* Der Feldabgleich mit dem perinco 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 perinco CRM schreibt hierher (fester Token)
|
||||
* routes/perinco.js ein Benutzer hier gleicht ab und schreibt mit
|
||||
* Beide benutzen diese Abbildung, damit die Felder nicht auseinanderlaufen.
|
||||
*
|
||||
* Bewusst eng gefasst: nur die Felder, die die Bruecke ohnehin herausgibt.
|
||||
* Art, Status, Saetze, Werdegang und Lebenslauf sind nicht erreichbar - die
|
||||
* kennt `crm_leads` nicht, und drueben hat sie niemand zu entscheiden.
|
||||
*/
|
||||
|
||||
const trimOrNull = (value, max) => {
|
||||
const text = String(value ?? '').trim();
|
||||
return text ? text.slice(0, max) : null;
|
||||
};
|
||||
|
||||
/** Wie in routes/contacts.js: getrimmt, ohne Leereintraege, ohne Duplikate. */
|
||||
export function normalizeTags(input) {
|
||||
const list = Array.isArray(input) ? input : String(input ?? '').split(',');
|
||||
const seen = [];
|
||||
for (const raw of list) {
|
||||
const tag = String(raw).trim();
|
||||
if (tag && !seen.some((t) => t.toLowerCase() === tag.toLowerCase())) seen.push(tag);
|
||||
}
|
||||
return seen.length > 0 ? seen.join(', ').slice(0, 1000) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feldname der Bruecke -> Spalte hier, samt Umformung in die eigene Ablage.
|
||||
*
|
||||
* Die Namen sind die der Schnittstelle (`notes`, `source`), nicht die der
|
||||
* eigenen Tabelle - auf beiden Seiten wird dieselbe Sprache gesprochen.
|
||||
*/
|
||||
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, 80) },
|
||||
linkedin: { column: 'linkedin', parse: (v) => trimOrNull(v, 300) },
|
||||
source: { column: 'lead_source', parse: (v) => trimOrNull(v, 190) },
|
||||
notes: { column: 'bio', parse: (v) => (v ? String(v) : null) },
|
||||
tags: { column: 'tags', parse: (v) => normalizeTags(v) },
|
||||
};
|
||||
|
||||
/*
|
||||
* Die Firma fehlt hier mit Absicht.
|
||||
*
|
||||
* Drueben ist sie ein Textfeld, hier ein Verweis auf icon_companies. Ein Name
|
||||
* von drueben liesse sich nur raten - entweder als neue Firma anlegen (und aus
|
||||
* jeder Schreibweise eine eigene machen) oder still verwerfen. Beides ist
|
||||
* schlechter, als die Firma dort zu lassen, wo sie gepflegt wird.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wendet einen Abgleich auf einen Kontakt an.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {number} params.id Kontakt hier
|
||||
* @param {object} params.body Felder in der Sprache der Bruecke
|
||||
* @param {number|null} params.userId Wer abgleicht; null = die Schnittstelle
|
||||
* @param {string} params.note Text fuer die Historie
|
||||
* @returns {Promise<{status: number, body: object}>}
|
||||
*/
|
||||
export async function applyBridgePatch({ id, body, userId = null, note }) {
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { status: 400, body: { error: 'Ungültige 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 übergeben.' } };
|
||||
}
|
||||
|
||||
// Ein Kontakt ohne Nachnamen waere nicht mehr auffindbar - der Abgleich darf
|
||||
// den Namen aendern, aber nicht loeschen.
|
||||
if (data.last_name === null) {
|
||||
return { status: 400, body: { error: 'Der Nachname darf nicht geleert werden.' } };
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT * FROM icon_contacts WHERE id = ? LIMIT 1', [id]);
|
||||
const before = rows[0];
|
||||
if (!before) return { status: 404, body: { error: 'Kontakt nicht gefunden.' } };
|
||||
|
||||
const columns = Object.keys(data);
|
||||
await pool.query(
|
||||
`UPDATE icon_contacts SET ${columns.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...columns.map((c) => data[c]), id]
|
||||
);
|
||||
|
||||
const changes = {};
|
||||
for (const column of columns) {
|
||||
if (String(before[column] ?? '') !== String(data[column] ?? '')) {
|
||||
changes[column] = [before[column] ?? null, data[column] ?? null];
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await logHistory({
|
||||
entityType: 'contact',
|
||||
entityId: id,
|
||||
entityLabel: [before.title, before.first_name, before.last_name].filter(Boolean).join(' '),
|
||||
action: 'updated',
|
||||
text: note,
|
||||
changes,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return { status: 200, body: { ok: true, changed: Object.keys(changes) } };
|
||||
}
|
||||
+46
-1
@@ -50,6 +50,7 @@ export async function initDatabase() {
|
||||
await migrateColumns();
|
||||
await migrateContactKind();
|
||||
await seedOptions();
|
||||
await renameCustomerContactKind();
|
||||
await ensureAdminUser();
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ async function migrateContactKind() {
|
||||
async function seedOptions() {
|
||||
if ((await getSetting('options_seeded')) === 'true') return;
|
||||
|
||||
const kinds = ['Experte', 'Ansprechpartner', 'Lead'];
|
||||
const kinds = ['Experte', 'Ansprechpartner (Kunde)', 'Lead'];
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO icon_options (field, value, position)
|
||||
VALUES ${kinds.map((_, index) => `('contact_kind', ?, ${index})`).join(', ')}`,
|
||||
@@ -112,6 +113,44 @@ async function seedOptions() {
|
||||
console.log(`[ICON CRM] Auswahllisten angelegt - ${count.count} Eintrag/Einträge.`);
|
||||
}
|
||||
|
||||
/*
|
||||
* "Ansprechpartner" heisst jetzt "Ansprechpartner (Kunde)" - der Zusatz sagt,
|
||||
* wessen Ansprechpartner gemeint ist. Umbenannt wird beides: der Eintrag in der
|
||||
* Auswahlliste und jeder Kontakt, der die alte Art traegt.
|
||||
*
|
||||
* Laeuft genau einmal, festgehalten per Merker. Ohne den wuerde eine spaetere
|
||||
* Umbenennung durch den Administrator bei jedem Neustart zurueckgedreht.
|
||||
*/
|
||||
async function renameCustomerContactKind() {
|
||||
if ((await getSetting('contact_kind_customer_renamed')) === 'true') return;
|
||||
|
||||
const OLD = 'Ansprechpartner';
|
||||
const NEW = 'Ansprechpartner (Kunde)';
|
||||
|
||||
// Steht der neue Wert schon in der Liste, waere das Umbenennen ein Verstoss
|
||||
// gegen den eindeutigen Schluessel (field, value) - dann faellt der alte weg.
|
||||
const [existing] = await pool.query(
|
||||
'SELECT id FROM icon_options WHERE field = ? AND value = ? LIMIT 1',
|
||||
['contact_kind', NEW]
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
await pool.query('DELETE FROM icon_options WHERE field = ? AND value = ?', ['contact_kind', OLD]);
|
||||
} else {
|
||||
await pool.query('UPDATE icon_options SET value = ? WHERE field = ? AND value = ?', [
|
||||
NEW,
|
||||
'contact_kind',
|
||||
OLD,
|
||||
]);
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE icon_contacts SET kind = ? WHERE kind = ?', [NEW, OLD]);
|
||||
await setSetting('contact_kind_customer_renamed', 'true');
|
||||
|
||||
if (result.affectedRows > 0) {
|
||||
console.log(`[ICON CRM] Kontaktart "${OLD}" in "${NEW}" umbenannt - ${result.affectedRows} Kontakt(e).`);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Spalten, die zu einer bereits bestehenden Tabelle dazugekommen sind.
|
||||
*
|
||||
@@ -134,6 +173,12 @@ async function migrateColumns() {
|
||||
],
|
||||
// Das im Lebenslauf gefundene Portrait - Zwischenablage zwischen Auslesen
|
||||
// und Uebernehmen (siehe server/src/cvPhoto.js).
|
||||
// Akademischer Titel des Kontakts ("Dr.", "Prof. Dr.") - Freitext.
|
||||
[
|
||||
'icon_contacts',
|
||||
'title',
|
||||
'ALTER TABLE icon_contacts ADD COLUMN title VARCHAR(60) NULL AFTER id',
|
||||
],
|
||||
[
|
||||
'icon_cv_imports',
|
||||
'photo',
|
||||
|
||||
@@ -122,6 +122,7 @@ export function diffFields(before, after, fields) {
|
||||
/** Menschenlesbare Namen der Felder - fuer den Text in der Historie. */
|
||||
export const FIELD_LABELS = {
|
||||
name: 'Name',
|
||||
title: 'Titel',
|
||||
first_name: 'Vorname',
|
||||
last_name: 'Nachname',
|
||||
position: 'Position',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
aiConfigured,
|
||||
aiModel,
|
||||
aiStatus,
|
||||
listAiModels,
|
||||
saveAiKey,
|
||||
saveAiModel,
|
||||
} from '../ai.js';
|
||||
@@ -116,6 +117,15 @@ router.get('/config', requireAdmin, (req, res) => {
|
||||
res.json(aiStatus());
|
||||
});
|
||||
|
||||
// GET /api/ai/models - Chat-Modelle, die der hinterlegte Schluessel nutzen darf.
|
||||
router.get('/models', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
res.json({ models: await listAiModels() });
|
||||
} catch (err) {
|
||||
res.status(err?.status ?? 502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/ai/config - { key?, model? }; key === '' entfernt ihn wieder.
|
||||
router.patch('/config', requireAdmin, async (req, res, next) => {
|
||||
const body = req.body || {};
|
||||
|
||||
@@ -176,7 +176,7 @@ router.get('/:id', async (req, res, next) => {
|
||||
|
||||
const [interviews] = await pool.query(
|
||||
`SELECT i.*, u.name AS user_name,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name
|
||||
FROM icon_interviews i
|
||||
LEFT JOIN icon_users u ON u.id = i.user_id
|
||||
LEFT JOIN icon_contacts k ON k.id = i.contact_id
|
||||
|
||||
@@ -17,8 +17,8 @@ router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT c.*,
|
||||
CONCAT_WS(' ', a.first_name, a.last_name) AS a_name,
|
||||
CONCAT_WS(' ', b.first_name, b.last_name) AS b_name
|
||||
CONCAT_WS(' ', a.title, a.first_name, a.last_name) AS a_name,
|
||||
CONCAT_WS(' ', b.title, b.first_name, b.last_name) AS b_name
|
||||
FROM icon_connections c
|
||||
JOIN icon_contacts a ON a.id = c.contact_a
|
||||
JOIN icon_contacts b ON b.id = c.contact_b
|
||||
@@ -50,7 +50,7 @@ router.post('/', async (req, res, next) => {
|
||||
);
|
||||
|
||||
const [names] = await pool.query(
|
||||
"SELECT id, CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
|
||||
"SELECT id, CONCAT_WS(' ', title, first_name, last_name) AS name FROM icon_contacts WHERE id IN (?, ?)",
|
||||
[low, high]
|
||||
);
|
||||
const nameOf = (id) => names.find((row) => row.id === id)?.name ?? 'Unbekannt';
|
||||
|
||||
@@ -22,6 +22,7 @@ router.use(requireAuth);
|
||||
*/
|
||||
|
||||
const EDITABLE = [
|
||||
'title',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'position',
|
||||
@@ -58,6 +59,7 @@ async function assertKnownKind(kind) {
|
||||
|
||||
function readBody(body) {
|
||||
const out = {};
|
||||
if (body.title !== undefined) out.title = trimOrNull(body.title, 60);
|
||||
if (body.first_name !== undefined) out.first_name = trimOrNull(body.first_name, 120);
|
||||
if (body.last_name !== undefined) out.last_name = String(body.last_name ?? '').trim().slice(0, 120);
|
||||
if (body.position !== undefined) out.position = trimOrNull(body.position, 190);
|
||||
@@ -122,7 +124,7 @@ router.get('/', async (req, res, next) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q) {
|
||||
where.push(
|
||||
`(CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ?
|
||||
`(CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ? OR k.email LIKE ? OR k.position LIKE ?
|
||||
OR k.tags LIKE ? OR k.bio LIKE ? OR co.name LIKE ?)`
|
||||
);
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
|
||||
@@ -663,7 +665,7 @@ router.get('/:id/cv', async (req, res, next) => {
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT k.cv_file, CONCAT_WS(' ', k.first_name, k.last_name) AS name,
|
||||
`SELECT k.cv_file, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name,
|
||||
(SELECT original_name FROM icon_cv_imports i WHERE i.filename = k.cv_file LIMIT 1) AS original_name
|
||||
FROM icon_contacts k WHERE k.id = ? LIMIT 1`,
|
||||
[id]
|
||||
@@ -777,7 +779,9 @@ async function loadOne(id) {
|
||||
}
|
||||
|
||||
function fullName(row) {
|
||||
return [row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt';
|
||||
// Der Titel gehoert zum Namen: er steht in Liste, Karte und Erwaehnung genauso
|
||||
// davor wie auf der Visitenkarte.
|
||||
return [row.title, row.first_name, row.last_name].filter(Boolean).join(' ').trim() || 'Unbenannt';
|
||||
}
|
||||
|
||||
function normalizeRow(row) {
|
||||
|
||||
@@ -120,7 +120,7 @@ router.get('/', async (req, res, next) => {
|
||||
c.extracted, c.error, c.contact_id, c.uploaded_by, c.created_at,
|
||||
c.processed_at, c.photo IS NOT NULL AS has_photo,
|
||||
u.name AS uploaded_by_name,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name
|
||||
FROM icon_cv_imports c
|
||||
LEFT JOIN icon_users u ON u.id = c.uploaded_by
|
||||
LEFT JOIN icon_contacts k ON k.id = c.contact_id
|
||||
|
||||
@@ -18,7 +18,7 @@ router.get('/', async (req, res, next) => {
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM icon_contacts) AS contacts,
|
||||
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Experte') AS experts,
|
||||
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner') AS partners,
|
||||
(SELECT COUNT(*) FROM icon_contacts WHERE kind = 'Ansprechpartner (Kunde)') AS partners,
|
||||
(SELECT COUNT(*) FROM icon_companies) AS companies,
|
||||
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'aktiv') AS customers,
|
||||
(SELECT COUNT(*) FROM icon_companies WHERE is_customer = 1 AND customer_state = 'alumni') AS alumni,
|
||||
|
||||
@@ -128,7 +128,7 @@ async function labelFor(entityType, entityId) {
|
||||
if (!entityId) return null;
|
||||
if (entityType === 'contact') {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT CONCAT_WS(' ', first_name, last_name) AS label FROM icon_contacts WHERE id = ?",
|
||||
"SELECT CONCAT_WS(' ', title, first_name, last_name) AS label FROM icon_contacts WHERE id = ?",
|
||||
[entityId]
|
||||
);
|
||||
return rows[0]?.label ?? null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { pool } from '../db.js';
|
||||
import { applyBridgePatch } from '../contactBridge.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,6 +14,9 @@ const router = express.Router();
|
||||
*
|
||||
* Ausgeliefert wird nur, was drüben ein Feld hat. `crm_leads` kennt keinen
|
||||
* Status, keine Tagessätze und keine Lebensläufe - die bleiben hier.
|
||||
*
|
||||
* Seit dem Abgleich doppelter Kontakte gibt es zusätzlich einen Schreibweg
|
||||
* (PATCH). Er ist bewusst eng gefasst - siehe die Begründung dort.
|
||||
*/
|
||||
|
||||
function tokenMatches(given) {
|
||||
@@ -82,4 +86,30 @@ router.get('/contacts', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Schreibweg: ein Feldabgleich von drüben.
|
||||
*
|
||||
* Nötig geworden, weil derselbe Mensch in beiden Häusern steht und ein
|
||||
* Unterschied in BEIDEN Systemen verschwinden soll. Welche Felder erreichbar
|
||||
* sind und warum nicht mehr, steht in contactBridge.js.
|
||||
*
|
||||
* Hier hängt ein fester Token statt einer Anmeldung, deshalb: nur an einem
|
||||
* bereits vorhandenen Kontakt, kein Anlegen, kein Löschen, keine
|
||||
* Sammeländerung.
|
||||
*/
|
||||
router.patch('/contacts/:id', async (req, res, next) => {
|
||||
try {
|
||||
const result = await applyBridgePatch({
|
||||
id: Number(req.params.id),
|
||||
body: req.body || {},
|
||||
// Kein Benutzer - es war keiner, es war die Schnittstelle.
|
||||
userId: null,
|
||||
note: 'Abgleich aus dem perinco CRM',
|
||||
});
|
||||
res.status(result.status).json(result.body);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -93,7 +93,7 @@ const positiveOrNull = (value) => {
|
||||
const SELECT = `
|
||||
SELECT l.*, co.name AS company_name, co.industry AS company_industry, co.logo AS company_logo,
|
||||
co.rating AS company_rating,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name,
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name,
|
||||
k.email AS contact_email, k.phone AS contact_phone, k.avatar AS contact_avatar,
|
||||
u.name AS owner_name,
|
||||
(SELECT COUNT(*) FROM icon_lead_todos t WHERE t.lead_id = l.id) AS todo_count,
|
||||
|
||||
@@ -51,7 +51,7 @@ router.get('/', async (req, res, next) => {
|
||||
}
|
||||
|
||||
const [contacts] = await pool.query(
|
||||
`SELECT DISTINCT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name,
|
||||
`SELECT DISTINCT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name,
|
||||
k.position, k.avatar, k.kind, k.status, k.company_id
|
||||
FROM icon_contacts k
|
||||
LEFT JOIN icon_employments e ON e.contact_id = k.id
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { applyBridgePatch } from '../contactBridge.js';
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
@@ -7,12 +8,16 @@ router.use(requireAuth);
|
||||
/*
|
||||
* Lesender Zugriff auf die Kontakte des perinco CRM.
|
||||
*
|
||||
* Es wird NICHTS gespeichert. Die Kontakte gehören dem perinco CRM, werden dort
|
||||
* 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.
|
||||
@@ -131,4 +136,126 @@ function toIconShape(row) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -29,6 +29,7 @@ const positiveOrNull = (value) => {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
const num = (value) => (value === null || value === undefined ? null : Number(value));
|
||||
const dateOrNull = (value) => {
|
||||
const text = String(value ?? '').slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
||||
@@ -41,7 +42,7 @@ router.get('/', async (req, res, next) => {
|
||||
const q = String(req.query.q || '').trim();
|
||||
if (q) {
|
||||
where.push(
|
||||
"(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?)"
|
||||
"(p.title LIKE ? OR co.name LIKE ? OR CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ?)"
|
||||
);
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||
}
|
||||
@@ -53,7 +54,8 @@ router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.*, co.name AS company_name,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact_name, k.avatar AS contact_avatar,
|
||||
k.rate_onsite AS contact_rate_onsite, k.rate_remote AS contact_rate_remote
|
||||
FROM icon_projects p
|
||||
LEFT JOIN icon_companies co ON co.id = p.company_id
|
||||
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
||||
@@ -61,7 +63,16 @@ router.get('/', async (req, res, next) => {
|
||||
ORDER BY p.started_on DESC, p.id DESC`,
|
||||
params
|
||||
);
|
||||
res.json({ projects: rows.map((row) => ({ ...row, rate: row.rate === null ? null : Number(row.rate) })) });
|
||||
// Die Saetze des Beraters kommen mit, damit die Liste die Marge ohne einen
|
||||
// zweiten Aufruf ausweisen kann - ein Tagessatz ohne Kosten sagt nichts.
|
||||
res.json({
|
||||
projects: rows.map((row) => ({
|
||||
...row,
|
||||
rate: num(row.rate),
|
||||
contact_rate_onsite: num(row.contact_rate_onsite),
|
||||
contact_rate_remote: num(row.contact_rate_remote),
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -174,7 +185,7 @@ async function logRelated(data, text, userId) {
|
||||
}
|
||||
if (data.contact_id) {
|
||||
const [[contact]] = await pool.query(
|
||||
"SELECT CONCAT_WS(' ', first_name, last_name) AS name FROM icon_contacts WHERE id = ?",
|
||||
"SELECT CONCAT_WS(' ', title, first_name, last_name) AS name FROM icon_contacts WHERE id = ?",
|
||||
[data.contact_id]
|
||||
);
|
||||
await logHistory({
|
||||
|
||||
+10
-10
@@ -33,11 +33,11 @@ router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const [contacts, companies, tenders, projects, leads, history] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS title,
|
||||
`SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS title,
|
||||
k.position, k.email, k.avatar, k.kind, k.status, co.name AS company
|
||||
FROM icon_contacts k
|
||||
LEFT JOIN icon_companies co ON co.id = k.company_id
|
||||
WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ? OR k.email LIKE ?
|
||||
WHERE CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ? OR k.email LIKE ?
|
||||
OR k.phone LIKE ? OR k.position LIKE ? OR k.tags LIKE ? OR k.bio LIKE ?
|
||||
OR k.lead_source LIKE ? OR co.name LIKE ?
|
||||
ORDER BY k.last_name LIMIT ${limit}`,
|
||||
@@ -62,7 +62,7 @@ router.get('/', async (req, res, next) => {
|
||||
),
|
||||
pool.query(
|
||||
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on,
|
||||
co.name AS company, CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
||||
co.name AS company, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||
FROM icon_projects p
|
||||
LEFT JOIN icon_companies co ON co.id = p.company_id
|
||||
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
||||
@@ -72,7 +72,7 @@ router.get('/', async (req, res, next) => {
|
||||
),
|
||||
pool.query(
|
||||
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||
FROM icon_leads l
|
||||
LEFT JOIN icon_companies co ON co.id = l.company_id
|
||||
LEFT JOIN icon_contacts k ON k.id = l.contact_id
|
||||
@@ -159,11 +159,11 @@ router.get('/mentions', async (req, res, next) => {
|
||||
|
||||
try {
|
||||
const [contacts] = await pool.query(
|
||||
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS label,
|
||||
`SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS label,
|
||||
k.avatar, co.name AS hint
|
||||
FROM icon_contacts k
|
||||
LEFT JOIN icon_companies co ON co.id = k.company_id
|
||||
${q ? "WHERE CONCAT_WS(' ', k.first_name, k.last_name) LIKE ?" : ''}
|
||||
${q ? "WHERE CONCAT_WS(' ', k.title, k.first_name, k.last_name) LIKE ?" : ''}
|
||||
ORDER BY k.last_name LIMIT 20`,
|
||||
q ? [like] : []
|
||||
);
|
||||
@@ -227,7 +227,7 @@ router.post('/ai', async (req, res, next) => {
|
||||
|
||||
try {
|
||||
const [contacts] = await pool.query(
|
||||
`SELECT k.id, CONCAT_WS(' ', k.first_name, k.last_name) AS name, k.position, k.kind, k.status,
|
||||
`SELECT k.id, CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name, k.position, k.kind, k.status,
|
||||
k.email, k.phone, k.lead_source, k.tags, k.freelancers, k.contact_state,
|
||||
k.rate_onsite, k.rate_remote, k.linkedin, co.name AS company,
|
||||
co.industry AS company_industry
|
||||
@@ -241,7 +241,7 @@ router.post('/ai', async (req, res, next) => {
|
||||
FROM icon_companies ORDER BY name LIMIT ${AI_LIMIT}`
|
||||
);
|
||||
const [former] = await pool.query(
|
||||
`SELECT CONCAT_WS(' ', k.first_name, k.last_name) AS contact, co.name AS company,
|
||||
`SELECT CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact, co.name AS company,
|
||||
e.position, e.ended_on
|
||||
FROM icon_employments e
|
||||
JOIN icon_contacts k ON k.id = e.contact_id
|
||||
@@ -255,7 +255,7 @@ router.post('/ai', async (req, res, next) => {
|
||||
);
|
||||
const [projects] = await pool.query(
|
||||
`SELECT p.id, p.title, p.status, p.started_on, p.ended_on, co.name AS company,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||
FROM icon_projects p
|
||||
LEFT JOIN icon_companies co ON co.id = p.company_id
|
||||
LEFT JOIN icon_contacts k ON k.id = p.contact_id
|
||||
@@ -263,7 +263,7 @@ router.post('/ai', async (req, res, next) => {
|
||||
);
|
||||
const [leads] = await pool.query(
|
||||
`SELECT l.id, l.title, l.stage, l.archived, co.name AS company,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS contact
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS contact
|
||||
FROM icon_leads l
|
||||
LEFT JOIN icon_companies co ON co.id = l.company_id
|
||||
LEFT JOIN icon_contacts k ON k.id = l.contact_id
|
||||
|
||||
@@ -98,7 +98,7 @@ router.get('/', async (req, res, next) => {
|
||||
if (ids.length > 0) {
|
||||
const [links] = await pool.query(
|
||||
`SELECT tc.tender_id, k.id, k.avatar, k.position, k.status,
|
||||
CONCAT_WS(' ', k.first_name, k.last_name) AS name
|
||||
CONCAT_WS(' ', k.title, k.first_name, k.last_name) AS name
|
||||
FROM icon_tender_contacts tc
|
||||
JOIN icon_contacts k ON k.id = tc.contact_id
|
||||
WHERE tc.tender_id IN (?)
|
||||
|
||||
@@ -84,6 +84,9 @@ CREATE TABLE IF NOT EXISTS icon_companies (
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS icon_contacts (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
-- Akademischer Titel als Freitext ("Dr.", "Prof. Dr."). Bewusst keine
|
||||
-- Auswahlliste: die Schreibweisen sind zu vielfaeltig, um sie zu pflegen.
|
||||
title VARCHAR(60) NULL,
|
||||
first_name VARCHAR(120) NULL,
|
||||
last_name VARCHAR(120) NOT NULL,
|
||||
position VARCHAR(190) NULL,
|
||||
@@ -94,7 +97,7 @@ CREATE TABLE IF NOT EXISTS icon_contacts (
|
||||
-- Aktueller Arbeitgeber. Die Historie der frueheren steht in
|
||||
-- icon_employments - daraus speist sich der Zweig "Verlassen".
|
||||
company_id INT UNSIGNED NULL,
|
||||
kind ENUM('Experte','Ansprechpartner','Lead') NOT NULL DEFAULT 'Experte',
|
||||
kind ENUM('Experte','Ansprechpartner (Kunde)','Lead') NOT NULL DEFAULT 'Experte',
|
||||
status ENUM('Interessiert','Interview','Gebucht','Inaktiv') NOT NULL DEFAULT 'Interessiert',
|
||||
-- Woher kommt der Kontakt (LinkedIn, Empfehlung, Portal, ...).
|
||||
lead_source VARCHAR(190) NULL,
|
||||
|
||||
Reference in New Issue
Block a user