Files
ICON-CRM/server/src/history.js
T
schakal00783andClaude Opus 5.5 cbac02a96a 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>
2026-09-23 10:26:30 +02:00

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`;
}