import { pool } from '../db.js'; /* * Datenbankzugriffe des Mailabgleichs. Lesende Abfragen der Oberflaeche stehen * wie im uebrigen Backend direkt in routes/mail.js - hier liegt nur, was sowohl * der Hintergrundlauf als auch die Routen brauchen. */ /** Zustandszeile des Abgleichs (immer id = 1). */ export async function getSyncState() { const [rows] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1'); if (rows[0]) return rows[0]; // Sollte die Startzeile fehlen (fremder Dump), hier nachziehen. await pool.query('INSERT IGNORE INTO crm_mail_sync_state (id) VALUES (1)'); const [again] = await pool.query('SELECT * FROM crm_mail_sync_state WHERE id = 1 LIMIT 1'); return again[0]; } const SYNC_STATE_FIELDS = new Set([ 'mailbox', 'delta_link', 'has_more', 'last_run_at', 'last_success_at', 'last_error', 'last_new_messages', 'last_candidates', ]); /** Teilweises Aktualisieren der Zustandszeile. */ export async function saveSyncState(patch) { const entries = Object.entries(patch).filter(([key]) => SYNC_STATE_FIELDS.has(key)); if (entries.length === 0) return; await pool.query( `UPDATE crm_mail_sync_state SET ${entries.map(([key]) => `${key} = ?`).join(', ')} WHERE id = 1`, entries.map(([, value]) => value) ); } /** Vollstaendigen Neuaufbau anstossen: Delta-Verweis verwerfen. */ export async function resetDelta() { await saveSyncState({ delta_link: null, has_more: 0 }); } /** * Ist die Mail schon bekannt, und liegt ihr Text vor? Der Delta-Abgleich holt * absichtlich nur Metadaten; der Mailtext wird einzeln nachgeladen und nur dann, * wenn er wirklich fehlt. */ export async function getMessageStateByHash(graphIdHash) { const [rows] = await pool.query( `SELECT id, (body_text IS NOT NULL AND body_text <> '') AS has_body FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1`, [graphIdHash] ); if (!rows[0]) return null; return { id: rows[0].id, hasBody: Boolean(Number(rows[0].has_body)) }; } /** * Nachricht anlegen oder aktualisieren. Bewusst mit vorherigem SELECT statt * ON DUPLICATE KEY UPDATE: so ist eindeutig, ob es eine neue Mail war - nur dann * werden Kontakt-Vorschlaege erzeugt. * * Fehlen die Textfelder im uebergebenen Objekt (undefined), bleiben die * gespeicherten Werte unangetastet - ein reines Metadaten-Update darf den * bereits geholten Mailtext nicht loeschen. */ export async function upsertMessage(message) { const [existing] = await pool.query( 'SELECT id FROM crm_mail_messages WHERE graph_id_hash = ? LIMIT 1', [message.graphIdHash] ); const columns = { graph_id: message.graphId, graph_id_hash: message.graphIdHash, internet_message_id: message.internetMessageId, conversation_id: message.conversationId, subject: message.subject, from_name: message.fromName, from_email: message.fromEmail, to_json: JSON.stringify(message.to ?? []), cc_json: JSON.stringify(message.cc ?? []), bcc_json: JSON.stringify(message.bcc ?? []), received_at: message.receivedAt, sent_at: message.sentAt, body_preview: message.bodyPreview, body_text: message.bodyText, body_html: message.bodyHtml, has_attachments: message.hasAttachments ? 1 : 0, importance: message.importance, web_link: message.webLink, is_read_in_mailbox: message.isReadInMailbox ? 1 : 0, // Wiederaufgetauchte Mail gilt wieder als vorhanden. is_deleted: 0, }; if (message.bodyText === undefined) { delete columns.body_text; delete columns.body_html; delete columns.body_preview; } if (existing[0]) { const keys = Object.keys(columns).filter((key) => key !== 'graph_id_hash'); await pool.query( `UPDATE crm_mail_messages SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`, [...keys.map((key) => columns[key]), existing[0].id] ); return { id: existing[0].id, isNew: false }; } const keys = Object.keys(columns); const [result] = await pool.query( `INSERT INTO crm_mail_messages (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, keys.map((key) => columns[key]) ); return { id: result.insertId, isNew: true }; } /** * Im Postfach geloeschte Mail markieren, nicht entfernen: eine bereits an eine * Opportunity gehaengte Mail soll nicht aus dem CRM verschwinden, nur weil * jemand das Postfach aufraeumt. */ export async function markMessageDeleted(graphIdHash) { const [result] = await pool.query( 'UPDATE crm_mail_messages SET is_deleted = 1 WHERE graph_id_hash = ?', [graphIdHash] ); return result.affectedRows > 0; } const CANDIDATE_COLUMNS = [ 'message_id', 'dedupe_hash', 'first_name', 'last_name', 'salutation', 'email', 'phone', 'mobile', 'company', 'position', 'street', 'zip', 'city', 'website', 'linkedin', 'origin', 'confidence', ]; /** * Vorschlaege zu einer Mail speichern. INSERT IGNORE zusammen mit dem * Unique-Schluessel (message_id, dedupe_hash) macht den Aufruf wiederholbar. * * Zurueck kommen die tatsaechlich neu angelegten Zeilen - und zwar in der Form * der Datenbank (snake_case), damit sie direkt an adoptCandidate() weitergegeben * werden koennen. Eine Umformung an der Aufrufstelle wuerde bei jedem neuen Feld * still auseinanderlaufen. */ export async function insertCandidates(messageId, candidates) { const created = []; for (const candidate of candidates) { const row = { message_id: messageId, dedupe_hash: candidate.dedupeHash, first_name: candidate.firstName, last_name: candidate.lastName, salutation: candidate.salutation, email: candidate.email, phone: candidate.phone, mobile: candidate.mobile, company: candidate.company, position: candidate.position, street: candidate.street, zip: candidate.zip, city: candidate.city, website: candidate.website, linkedin: candidate.linkedin, origin: candidate.origin, confidence: candidate.confidence, }; const [result] = await pool.query( `INSERT IGNORE INTO crm_mail_contact_candidates (${CANDIDATE_COLUMNS.join(', ')}) VALUES (${CANDIDATE_COLUMNS.map(() => '?').join(', ')})`, CANDIDATE_COLUMNS.map((column) => row[column]) ); if (result.affectedRows > 0) created.push({ id: result.insertId, ...row }); } return created; } /** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */ export async function findLeadByEmail(email) { if (!email) return null; const [rows] = await pool.query( 'SELECT id, first_name, last_name FROM crm_leads WHERE LOWER(email) = LOWER(?) ORDER BY id LIMIT 1', [email] ); return rows[0] ?? null; } /** Verknuepfung Mail <-> Kontakt/Opportunity; doppelte Paare werden ignoriert. */ export async function linkMessage({ messageId, leadId = null, opportunityId = null, origin = 'manuell', note = null, userId = null }) { const [result] = await pool.query( `INSERT IGNORE INTO crm_mail_links (message_id, lead_id, opportunity_id, origin, note, created_by) VALUES (?, ?, ?, ?, ?, ?)`, [messageId, leadId, opportunityId, origin, note, userId] ); return result.affectedRows > 0; }