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>
178 lines
5.5 KiB
JavaScript
178 lines
5.5 KiB
JavaScript
import { pool } from './db.js';
|
|
|
|
/*
|
|
* Historie und Erwaehnungen.
|
|
*
|
|
* Jede schreibende Route ruft hier herein, statt selbst in icon_history zu
|
|
* schreiben. Damit gilt an einer Stelle, was die Featureliste verlangt: in
|
|
* jeder Historie steht, welcher Benutzer was geaendert hat, und jedes @ im
|
|
* Text taucht als eigener Eintrag auf.
|
|
*/
|
|
|
|
/**
|
|
* Mention-Token im Text: `@[Anzeigename](contact:12)`.
|
|
*
|
|
* Der Name steht mit im Token, damit ein Text auch ohne geladene Listen
|
|
* lesbar bleibt; beim Rendern gewinnt der aktuelle Name aus den Daten.
|
|
*/
|
|
const MENTION_TOKEN = /@\[([^\]\n]+)\]\((contact|company|user):(\d+)\)/g;
|
|
|
|
export function extractMentions(text) {
|
|
if (!text) return [];
|
|
const found = new Map();
|
|
MENTION_TOKEN.lastIndex = 0;
|
|
let match;
|
|
while ((match = MENTION_TOKEN.exec(String(text))) !== null) {
|
|
const key = `${match[2]}:${match[3]}`;
|
|
if (!found.has(key)) found.set(key, { type: match[2], id: Number(match[3]), label: match[1] });
|
|
}
|
|
return [...found.values()];
|
|
}
|
|
|
|
/** Text ohne Token-Syntax - fuer Suche und Vorschauen. */
|
|
export function mentionPlainText(text) {
|
|
if (!text) return '';
|
|
return String(text).replace(MENTION_TOKEN, (_all, label) => `@${label}`);
|
|
}
|
|
|
|
/**
|
|
* Schreibt einen Historieneintrag und indiziert die darin enthaltenen
|
|
* Erwaehnungen.
|
|
*
|
|
* @param {object} entry
|
|
* @param {'contact'|'company'|'tender'|'project'|'lead'|'user'|'system'} entry.entityType
|
|
* @param {number|null} entry.entityId
|
|
* @param {string|null} entry.entityLabel Name zum Zeitpunkt des Eintrags
|
|
* @param {string} entry.action created | updated | deleted | note | ...
|
|
* @param {string|null} entry.text
|
|
* @param {object|null} entry.changes { feld: [vorher, nachher] }
|
|
* @param {number|null} entry.userId
|
|
* @returns {Promise<number>} id des Eintrags
|
|
*/
|
|
export async function logHistory({
|
|
entityType,
|
|
entityId = null,
|
|
entityLabel = null,
|
|
action = 'note',
|
|
text = null,
|
|
changes = null,
|
|
userId = null,
|
|
}) {
|
|
const [result] = await pool.query(
|
|
`INSERT INTO icon_history (entity_type, entity_id, entity_label, action, text, changes, user_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
entityType,
|
|
entityId,
|
|
entityLabel ? String(entityLabel).slice(0, 300) : null,
|
|
action,
|
|
text ?? null,
|
|
changes && Object.keys(changes).length > 0 ? JSON.stringify(changes) : null,
|
|
userId,
|
|
]
|
|
);
|
|
|
|
const historyId = result.insertId;
|
|
await indexMentions('history', historyId, text);
|
|
return historyId;
|
|
}
|
|
|
|
/**
|
|
* Traegt die Erwaehnungen eines Textes in icon_mentions nach.
|
|
*
|
|
* Erst loeschen, dann neu schreiben: so verschwindet ein @, das aus einer
|
|
* Notiz wieder entfernt wurde, auch aus dem Index.
|
|
*/
|
|
export async function indexMentions(sourceType, sourceId, text) {
|
|
const mentions = extractMentions(text);
|
|
await pool.query('DELETE FROM icon_mentions WHERE source_type = ? AND source_id = ?', [
|
|
sourceType,
|
|
sourceId,
|
|
]);
|
|
if (mentions.length === 0) return [];
|
|
|
|
const values = mentions.flatMap((m) => [sourceType, sourceId, m.type, m.id]);
|
|
await pool.query(
|
|
`INSERT IGNORE INTO icon_mentions (source_type, source_id, target_type, target_id)
|
|
VALUES ${mentions.map(() => '(?, ?, ?, ?)').join(', ')}`,
|
|
values
|
|
);
|
|
return mentions;
|
|
}
|
|
|
|
/**
|
|
* Vergleicht alten und neuen Datensatz und liefert nur die geaenderten Felder.
|
|
*
|
|
* Verglichen wird ueber String(), weil MariaDB Zahlen mal als Number und mal
|
|
* als String liefert (DECIMAL etwa immer als String) - ein strikter Vergleich
|
|
* wuerde daraus eine Scheinaenderung machen.
|
|
*/
|
|
export function diffFields(before, after, fields) {
|
|
const changes = {};
|
|
for (const field of fields) {
|
|
if (after[field] === undefined) continue;
|
|
const from = before?.[field] ?? null;
|
|
const to = after[field] ?? null;
|
|
if (String(from ?? '') === String(to ?? '')) continue;
|
|
changes[field] = [from, to];
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
/** 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',
|
|
email: 'E-Mail',
|
|
phone: 'Telefon',
|
|
linkedin: 'LinkedIn',
|
|
company_id: 'Unternehmen',
|
|
kind: 'Art',
|
|
status: 'Status',
|
|
lead_source: 'Lead-Quelle',
|
|
contact_state: 'Kontaktstand',
|
|
freelancers: 'Freelancer-Einsatz',
|
|
bio: 'Notiz',
|
|
tags: 'Schlagworte',
|
|
rate_onsite: 'Stundensatz vor Ort',
|
|
rate_remote: 'Stundensatz remote',
|
|
cv_url: 'CV-Link',
|
|
avatar: 'Foto',
|
|
owner_id: 'Zustaendig',
|
|
group_name: 'Gruppe',
|
|
industry: 'Branche',
|
|
employees: 'Mitarbeiterzahl',
|
|
website: 'Website',
|
|
address: 'Adresse',
|
|
logo: 'Logo',
|
|
rating: 'Bewertung',
|
|
is_customer: 'Kunde',
|
|
customer_state: 'Kundenstatus',
|
|
notes: 'Notizen',
|
|
title: 'Titel',
|
|
source: 'Quelle',
|
|
link: 'Link',
|
|
stage: 'Phase',
|
|
online: 'Online',
|
|
details: 'Details',
|
|
deadline: 'Frist',
|
|
started_on: 'Beginn',
|
|
ended_on: 'Ende',
|
|
rate: 'Satz',
|
|
contact_id: 'Kontakt',
|
|
archived: 'Archiviert',
|
|
remind_at: 'Erinnerung',
|
|
reminder_days: 'Erinnerungsintervall',
|
|
};
|
|
|
|
/** "Status, Telefon und 2 weitere Felder geaendert" */
|
|
export function describeChanges(changes) {
|
|
const labels = Object.keys(changes).map((key) => FIELD_LABELS[key] || key);
|
|
if (labels.length === 0) return 'ohne Änderung gespeichert';
|
|
if (labels.length <= 3) return `${labels.join(', ')} geändert`;
|
|
return `${labels.slice(0, 2).join(', ')} und ${labels.length - 2} weitere Felder geändert`;
|
|
}
|