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