feat(kontakte): Vorbereich "Aus Mails" mit Dublettenabgleich
Der Postfachabgleich hat gefundene Personen bisher direkt in den Kontaktbestand geschrieben. Damit wächst eine gepflegte Liste ungefragt mit jeder Adresse aus jeder Mail - genau daran ist das Altsystem gescheitert. Funde landen jetzt in einem Vorbereich (crm_leads.mail_pool = 1), der als Unterpunkt "Aus Mails" unter dem Menüpunkt Kontakte aufklappt. Der Kontaktbestand bleibt unberührt, bis dort jemand entscheidet; in Opportunities, Kalender und Netzwerk tauchen die Funde nicht auf. Eine Spalte statt einer zweiten Tabelle, weil crm_mail_links und crm_mail_contact_candidates per Fremdschlüssel auf crm_leads zeigen - so behält der Vorbereich seine Mailbezüge. Übernehmen kennt zwei Wege: - neue Person: Kopie mit mail_pool = 0, die Mailverknüpfungen wandern mit - Dublette (Adresse, sonst voller Name): gold markiert, abweichende Felder mit altem und neuem Wert; die Feldauswahl schreibt sie in den vorhandenen Kontakt. Vorausgewählt sind nur Lücken - ein Wert aus einer alten Signatur soll nichts Gepflegtes stillschweigend ersetzen. db.js verschiebt beim ersten Start die vorhandenen Kontakte mit source = 'Mail' in den Vorbereich, einmalig über den Merker leads_mail_pool_migrated in crm_settings. Die Sidebar-Gruppe ist dabei generisch geworden (MenuGroup) - Kontakte und Scanner benutzen jetzt denselben Code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8090d0aae1
commit
ea55acdb5c
@@ -48,9 +48,39 @@ export async function initDatabase() {
|
||||
conn.release();
|
||||
}
|
||||
|
||||
await migrateMailPool();
|
||||
await ensureAdminUser();
|
||||
}
|
||||
|
||||
/*
|
||||
* Einmalige Umstellung: die vom Postfachabgleich bereits angelegten Kontakte
|
||||
* (source = 'Mail') wandern in den Vorbereich "Aus Mails" (mail_pool = 1).
|
||||
*
|
||||
* Das laeuft absichtlich nur ein einziges Mal und wird per Merker in
|
||||
* crm_settings festgehalten - ein Kontakt, der aus dem Vorbereich uebernommen
|
||||
* wurde, behaelt die Herkunft 'Mail' und wuerde bei jedem Start wieder
|
||||
* zurueckwandern. Direkter SQL-Zugriff statt settings.js, weil das Modul
|
||||
* seinerseits db.js braucht.
|
||||
*/
|
||||
async function migrateMailPool() {
|
||||
const flag = 'leads_mail_pool_migrated';
|
||||
const [rows] = await pool.query('SELECT value FROM crm_settings WHERE key_name = ? LIMIT 1', [flag]);
|
||||
if (rows[0]?.value === 'true') return;
|
||||
|
||||
const [result] = await pool.query("UPDATE crm_leads SET mail_pool = 1 WHERE source = 'Mail'");
|
||||
await pool.query(
|
||||
`INSERT INTO crm_settings (key_name, value) VALUES (?, 'true')
|
||||
ON DUPLICATE KEY UPDATE value = 'true'`,
|
||||
[flag]
|
||||
);
|
||||
|
||||
if (result.affectedRows > 0) {
|
||||
console.log(
|
||||
`[perinco CRM] ${result.affectedRows} Kontakt(e) aus Mails in den Vorbereich "Aus Mails" verschoben.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS count FROM crm_users');
|
||||
if (rows[0].count > 0) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { findLeadByEmail, linkMessage } from './store.js';
|
||||
/*
|
||||
* Aus einem Kontakt-Vorschlag einen Kontakt machen.
|
||||
*
|
||||
* Zwei Dinge macht das bewusst anders als das Altsystem:
|
||||
* Drei Dinge macht das bewusst anders als das Altsystem:
|
||||
*
|
||||
* 1. Es werden keine Platzhalter-Adressen erfunden. Wer keine E-Mail-Adresse hat,
|
||||
* wird angelegt ohne - das Feld ist in crm_leads ohnehin optional. Das
|
||||
@@ -13,6 +13,13 @@ import { findLeadByEmail, linkMessage } from './store.js';
|
||||
*
|
||||
* 2. Ein bereits vorhandener Kontakt wird nicht ein zweites Mal angelegt, sondern
|
||||
* ergaenzt: leere Felder werden gefuellt, gepflegte Werte bleiben unangetastet.
|
||||
*
|
||||
* 3. Der Hintergrundlauf schreibt nicht in den Kontaktbestand, sondern in den
|
||||
* Vorbereich "Aus Mails" (mail_pool = 1). Der Kontaktbestand ist gepflegt und
|
||||
* soll nicht ohne Zutun wachsen; wer aus einer Mail auftaucht, wartet dort auf
|
||||
* Sichtung. `target` waehlt den Bestand:
|
||||
* 'pool' - Vorbereich, Standard des Hintergrundlaufs
|
||||
* 'contacts' - richtiger Kontakt, so wie beim Uebernehmen aus der Mailansicht
|
||||
*/
|
||||
|
||||
/** Herkunftskennzeichen in crm_leads.source - traegt die Unterkategorie. */
|
||||
@@ -73,13 +80,22 @@ async function enrichLead(leadId, candidate) {
|
||||
*
|
||||
* `mode` steuert nur die Protokollierung: 'automatisch' kommt aus dem
|
||||
* Hintergrundlauf, 'manuell' aus der Oberflaeche. `userId` ist bei automatischer
|
||||
* Uebernahme null.
|
||||
* Uebernahme null. `target` waehlt den Zielbestand (siehe Kopf der Datei).
|
||||
*
|
||||
* Die Dublettenpruefung laeuft immer nur im Zielbestand: ein Eintrag im
|
||||
* Vorbereich soll die Uebernahme in den Kontaktbestand nicht blockieren, sonst
|
||||
* wuerde die Uebernahme aus der Mailansicht den Vorbereichseintrag ergaenzen
|
||||
* statt einen Kontakt anzulegen.
|
||||
*
|
||||
* Rueckgabe: { status, leadId, enrichedFields } - status ist 'uebernommen' bei
|
||||
* einem neuen Kontakt und 'dublette', wenn die Adresse schon vorhanden war.
|
||||
* einem neuen Eintrag und 'dublette', wenn die Adresse dort schon vorhanden war.
|
||||
*/
|
||||
export async function adoptCandidate(candidate, { userId = null, ownerId = null, mode = 'manuell' } = {}) {
|
||||
const existing = candidate.email ? await findLeadByEmail(candidate.email) : null;
|
||||
export async function adoptCandidate(
|
||||
candidate,
|
||||
{ userId = null, ownerId = null, mode = 'manuell', target = 'pool' } = {}
|
||||
) {
|
||||
const mailPool = target === 'contacts' ? 0 : 1;
|
||||
const existing = candidate.email ? await findLeadByEmail(candidate.email, { mailPool }) : null;
|
||||
|
||||
if (existing) {
|
||||
const enrichedFields = await enrichLead(existing.id, candidate);
|
||||
@@ -95,7 +111,15 @@ export async function adoptCandidate(candidate, { userId = null, ownerId = null,
|
||||
}
|
||||
|
||||
const fields = candidateToLeadFields(candidate);
|
||||
const columns = { ...fields, status: 'Neu', source: MAIL_LEAD_SOURCE, tags: '[]', owner_id: ownerId, created_by: userId };
|
||||
const columns = {
|
||||
...fields,
|
||||
status: 'Neu',
|
||||
source: MAIL_LEAD_SOURCE,
|
||||
mail_pool: mailPool,
|
||||
tags: '[]',
|
||||
owner_id: ownerId,
|
||||
created_by: userId,
|
||||
};
|
||||
const keys = Object.keys(columns);
|
||||
|
||||
const [result] = await pool.query(
|
||||
|
||||
@@ -200,12 +200,21 @@ export async function insertCandidates(messageId, candidates) {
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal). */
|
||||
export async function findLeadByEmail(email) {
|
||||
/**
|
||||
* Bestehenden Kontakt zu einer Adresse finden (Gross-/Kleinschreibung egal).
|
||||
*
|
||||
* `mailPool` waehlt den Bestand: 0 sind die richtigen Kontakte, 1 der Vorbereich
|
||||
* "Aus Mails". Beide werden getrennt durchsucht - ein Eintrag im Vorbereich darf
|
||||
* die Uebernahme in den Kontaktbestand nicht als Dublette erscheinen lassen und
|
||||
* umgekehrt. Ohne Angabe wird ueber beide Bestaende gesucht.
|
||||
*/
|
||||
export async function findLeadByEmail(email, { mailPool = null } = {}) {
|
||||
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]
|
||||
`SELECT id, first_name, last_name, mail_pool FROM crm_leads
|
||||
WHERE LOWER(email) = LOWER(?)${mailPool === null ? '' : ' AND mail_pool = ?'}
|
||||
ORDER BY id LIMIT 1`,
|
||||
mailPool === null ? [email] : [email, mailPool]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -240,13 +240,18 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
* Automatisch uebernommen wird nur, wer eine echte Adresse hat und aus
|
||||
* Absender oder Empfaengerliste stammt. Signatur- und Textfunde liegen
|
||||
* unter der Schwelle und warten auf eine Entscheidung.
|
||||
*
|
||||
* Ziel ist der Vorbereich "Aus Mails" (mail_pool = 1), nicht der
|
||||
* Kontaktbestand: der Hintergrundlauf soll die gepflegte Kontaktliste
|
||||
* nicht von selbst fuellen. Der Weg in die Kontakte fuehrt ueber die
|
||||
* Ansicht "Aus Mails".
|
||||
*/
|
||||
if (settings.autoContacts) {
|
||||
for (const candidate of created) {
|
||||
if (!candidate.email) continue;
|
||||
if (candidate.confidence < AUTO_ADOPT_MIN_CONFIDENCE) continue;
|
||||
try {
|
||||
const result = await adoptCandidate(candidate, { mode: 'automatisch' });
|
||||
const result = await adoptCandidate(candidate, { mode: 'automatisch', target: 'pool' });
|
||||
if (result.status === 'uebernommen') adoptedContacts += 1;
|
||||
} catch (err) {
|
||||
console.warn(`[Mail] Kontakt-Vorschlag konnte nicht uebernommen werden: ${err.message}`);
|
||||
@@ -299,7 +304,7 @@ export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
if (newMessages || newCandidates || deletedMessages) {
|
||||
console.log(
|
||||
`[Mail] Abgleich (${trigger}): ${newMessages} neu, ${updatedMessages} aktualisiert, ` +
|
||||
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Kontakte angelegt.`
|
||||
`${deletedMessages} entfernt, ${newCandidates} Kontakt-Vorschlaege, ${adoptedContacts} Eintraege in "Aus Mails".`
|
||||
);
|
||||
}
|
||||
if (hasMore || truncated) {
|
||||
|
||||
+267
-1
@@ -59,7 +59,14 @@ function serializeTags(value) {
|
||||
}
|
||||
|
||||
function mapLead(row) {
|
||||
return { ...row, tags: parseTags(row.tags), owner_id: row.owner_id, created_by: row.created_by };
|
||||
return {
|
||||
...row,
|
||||
tags: parseTags(row.tags),
|
||||
owner_id: row.owner_id,
|
||||
created_by: row.created_by,
|
||||
// TINYINT(1) kommt als 0/1 - im Frontend soll es ein Schalter sein.
|
||||
mail_pool: Boolean(row.mail_pool),
|
||||
};
|
||||
}
|
||||
|
||||
const SELECT_LEADS = `
|
||||
@@ -134,6 +141,265 @@ router.post('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vorbereich "Aus Mails" (mail_pool = 1)
|
||||
//
|
||||
// Der Postfachabgleich legt gefundene Personen hier ab, nicht im Kontaktbestand.
|
||||
// Von hier fuehren zwei Wege in die Kontakte: als neuer Kontakt (Kopie) oder als
|
||||
// Ergaenzung eines vorhandenen Kontakts mit ausgewaehlten Feldern.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
* Felder, die beim Uebernehmen verglichen und uebertragen werden koennen.
|
||||
* `source` fehlt absichtlich - die Herkunft 'Mail' wandert unveraendert mit und
|
||||
* ist keine Entscheidung des Benutzers. `notes`, `avatar`, `tags` und `status`
|
||||
* bleiben ebenfalls aussen vor: sie sind gepflegte Angaben des Kontakts und
|
||||
* haben in einem Mailfund kein Gegenstueck.
|
||||
*/
|
||||
const POOL_MERGE_FIELDS = [
|
||||
'salutation',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'company',
|
||||
'position',
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'linkedin',
|
||||
'website',
|
||||
'street',
|
||||
'zip',
|
||||
'city',
|
||||
'country',
|
||||
];
|
||||
|
||||
async function loadPoolLead(id) {
|
||||
const [rows] = await pool.query('SELECT * FROM crm_leads WHERE id = ? AND mail_pool = 1 LIMIT 1', [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailbezuege des Vorbereichseintrags auf den Zielkontakt uebertragen: die
|
||||
* Mails, aus denen die Person stammt, haengen danach am richtigen Kontakt.
|
||||
* Die Vorschlaege werden umgehaengt statt kopiert - beim Entfernen des
|
||||
* Vorbereichseintrags wuerde ihr Verweis sonst auf NULL fallen.
|
||||
*/
|
||||
async function moveMailRelations(fromLeadId, toLeadId) {
|
||||
await pool.query(
|
||||
`INSERT IGNORE INTO crm_mail_links (message_id, lead_id, origin, note, created_by)
|
||||
SELECT message_id, ?, origin, note, created_by FROM crm_mail_links WHERE lead_id = ?`,
|
||||
[toLeadId, fromLeadId]
|
||||
);
|
||||
await pool.query('UPDATE crm_mail_contact_candidates SET lead_id = ? WHERE lead_id = ?', [
|
||||
toLeadId,
|
||||
fromLeadId,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Vorbereichseintrag als neuen Kontakt anlegen; gibt die neue ID zurueck. */
|
||||
async function copyPoolLeadToContacts(poolLead, { userId, ownerId }) {
|
||||
const columns = {};
|
||||
for (const field of TEXT_FIELDS) columns[field] = poolLead[field] ?? null;
|
||||
columns.status = poolLead.status;
|
||||
columns.tags = poolLead.tags ?? '[]';
|
||||
columns.mail_pool = 0;
|
||||
columns.owner_id = ownerId;
|
||||
columns.created_by = userId;
|
||||
|
||||
const keys = Object.keys(columns);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO crm_leads (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
||||
keys.map((key) => columns[key])
|
||||
);
|
||||
|
||||
await moveMailRelations(poolLead.id, result.insertId);
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
// GET /api/leads/mail-pool/sources - woher stammt welcher Eintrag
|
||||
router.get('/mail-pool/sources', async (req, res, next) => {
|
||||
try {
|
||||
/*
|
||||
* Betreff der juengsten Mail ohne Unterabfrage je Zeile: CONCAT stellt das
|
||||
* Datum voran (als Text immer 19 Zeichen), MAX waehlt damit die juengste
|
||||
* Zeile aus, SUBSTRING schneidet das Datum wieder ab.
|
||||
*/
|
||||
const [messages] = await pool.query(
|
||||
`SELECT ml.lead_id,
|
||||
COUNT(DISTINCT ml.message_id) AS message_count,
|
||||
MAX(m.received_at) AS last_received_at,
|
||||
SUBSTRING(MAX(CONCAT(m.received_at, COALESCE(m.subject, ''))), 20) AS last_subject
|
||||
FROM crm_mail_links ml
|
||||
JOIN crm_mail_messages m ON m.id = ml.message_id
|
||||
JOIN crm_leads l ON l.id = ml.lead_id
|
||||
WHERE l.mail_pool = 1 AND ml.lead_id IS NOT NULL
|
||||
GROUP BY ml.lead_id`
|
||||
);
|
||||
|
||||
// Fundstelle und Verlaesslichkeit: derselbe Trick, hier mit der auf drei
|
||||
// Stellen aufgefuellten Trefferguete als Sortierpraefix.
|
||||
const [candidates] = await pool.query(
|
||||
`SELECT c.lead_id,
|
||||
MAX(c.confidence) AS confidence,
|
||||
SUBSTRING(MAX(CONCAT(LPAD(c.confidence, 3, '0'), c.origin)), 4) AS origin
|
||||
FROM crm_mail_contact_candidates c
|
||||
JOIN crm_leads l ON l.id = c.lead_id
|
||||
WHERE l.mail_pool = 1
|
||||
GROUP BY c.lead_id`
|
||||
);
|
||||
|
||||
const sources = {};
|
||||
for (const row of messages) {
|
||||
sources[row.lead_id] = {
|
||||
message_count: Number(row.message_count),
|
||||
last_received_at: row.last_received_at,
|
||||
last_subject: row.last_subject || null,
|
||||
confidence: null,
|
||||
origin: null,
|
||||
};
|
||||
}
|
||||
for (const row of candidates) {
|
||||
const entry = (sources[row.lead_id] ??= {
|
||||
message_count: 0,
|
||||
last_received_at: null,
|
||||
last_subject: null,
|
||||
confidence: null,
|
||||
origin: null,
|
||||
});
|
||||
entry.confidence = Number(row.confidence);
|
||||
entry.origin = row.origin || null;
|
||||
}
|
||||
|
||||
res.json(sources);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* POST /api/leads/mail-pool/promote - mehrere Eintraege auf einen Schlag als
|
||||
* neue Kontakte anlegen. Muss vor /:id/promote stehen, sonst faengt die
|
||||
* Einzelroute "mail-pool" als ID ab.
|
||||
*/
|
||||
router.post('/mail-pool/promote', async (req, res, next) => {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isInteger) : [];
|
||||
if (ids.length === 0) return res.status(400).json({ error: 'Keine Eintraege uebergeben.' });
|
||||
|
||||
const keepPool = req.body?.keep_pool === true;
|
||||
|
||||
try {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const poolLead = await loadPoolLead(id);
|
||||
if (!poolLead) {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
}
|
||||
const leadId = await copyPoolLeadToContacts(poolLead, {
|
||||
userId: req.user.id,
|
||||
ownerId: poolLead.owner_id ?? req.user.id,
|
||||
});
|
||||
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
|
||||
created.push(leadId);
|
||||
}
|
||||
|
||||
res.json({ ok: true, created, skipped });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* POST /api/leads/:id/promote - einen Eintrag uebernehmen.
|
||||
*
|
||||
* ohne target_lead_id : neuer Kontakt als Kopie
|
||||
* mit target_lead_id : die in `fields` genannten Werte in den vorhandenen
|
||||
* Kontakt schreiben (Dublettenfall)
|
||||
*
|
||||
* `keep_pool` behaelt den Eintrag in "Aus Mails"; ohne das verschwindet er dort,
|
||||
* sobald er in den Kontakten steht.
|
||||
*/
|
||||
router.post('/:id/promote', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Ungueltige Lead-ID.' });
|
||||
|
||||
const keepPool = req.body?.keep_pool === true;
|
||||
const targetLeadId =
|
||||
req.body?.target_lead_id === undefined || req.body.target_lead_id === null
|
||||
? null
|
||||
: Number(req.body.target_lead_id);
|
||||
if (targetLeadId !== null && !Number.isInteger(targetLeadId)) {
|
||||
return res.status(400).json({ error: 'Ungueltige Ziel-Kontakt-ID.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const poolLead = await loadPoolLead(id);
|
||||
if (!poolLead) return res.status(404).json({ error: 'Eintrag in "Aus Mails" nicht gefunden.' });
|
||||
|
||||
// Fall 1: neuer Kontakt.
|
||||
if (targetLeadId === null) {
|
||||
const ownerId =
|
||||
req.body?.owner_id === undefined || req.body.owner_id === null
|
||||
? poolLead.owner_id ?? req.user.id
|
||||
: Number(req.body.owner_id);
|
||||
if (!Number.isInteger(ownerId) || !(await userExists(ownerId))) {
|
||||
return res.status(400).json({ error: 'Zustaendiger Benutzer existiert nicht.' });
|
||||
}
|
||||
|
||||
const leadId = await copyPoolLeadToContacts(poolLead, { userId: req.user.id, ownerId });
|
||||
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
|
||||
|
||||
return res.status(201).json({
|
||||
merged: false,
|
||||
fields: 0,
|
||||
removed_pool: !keepPool,
|
||||
lead: await fetchLead(leadId),
|
||||
});
|
||||
}
|
||||
|
||||
// Fall 2: vorhandenen Kontakt ergaenzen.
|
||||
const [targets] = await pool.query(
|
||||
'SELECT id FROM crm_leads WHERE id = ? AND mail_pool = 0 LIMIT 1',
|
||||
[targetLeadId]
|
||||
);
|
||||
if (!targets[0]) return res.status(400).json({ error: 'Zielkontakt existiert nicht.' });
|
||||
|
||||
const requested = Array.isArray(req.body?.fields) ? req.body.fields : [];
|
||||
const fields = requested.filter((field) => POOL_MERGE_FIELDS.includes(field));
|
||||
|
||||
const updates = {};
|
||||
for (const field of fields) {
|
||||
const value = emptyToNull(poolLead[field]);
|
||||
// Ein leerer Wert aus dem Vorbereich darf einen gepflegten Kontakt nicht
|
||||
// ausraeumen - und Namen duerfen ohnehin nicht leer werden.
|
||||
if (value === null) continue;
|
||||
updates[field] = value;
|
||||
}
|
||||
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE crm_leads SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?`,
|
||||
[...keys.map((key) => updates[key]), targetLeadId]
|
||||
);
|
||||
}
|
||||
|
||||
await moveMailRelations(poolLead.id, targetLeadId);
|
||||
if (!keepPool) await pool.query('DELETE FROM crm_leads WHERE id = ?', [poolLead.id]);
|
||||
|
||||
res.json({
|
||||
merged: true,
|
||||
fields: keys.length,
|
||||
removed_pool: !keepPool,
|
||||
lead: await fetchLead(targetLeadId),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/leads/:id
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
|
||||
@@ -74,6 +74,7 @@ router.get('/status', async (req, res, next) => {
|
||||
(SELECT COUNT(*) FROM crm_mail_messages WHERE is_deleted = 0) AS messages,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'offen') AS open_candidates,
|
||||
(SELECT COUNT(*) FROM crm_mail_contact_candidates WHERE status = 'uebernommen') AS adopted_candidates,
|
||||
(SELECT COUNT(*) FROM crm_leads WHERE mail_pool = 1) AS pool_leads,
|
||||
(SELECT COUNT(*) FROM crm_mail_links) AS links`
|
||||
);
|
||||
|
||||
@@ -99,6 +100,7 @@ router.get('/status', async (req, res, next) => {
|
||||
messages: Number(counts.messages),
|
||||
open_candidates: Number(counts.open_candidates),
|
||||
adopted_candidates: Number(counts.adopted_candidates),
|
||||
pool_leads: Number(counts.pool_leads),
|
||||
links: Number(counts.links),
|
||||
},
|
||||
last_result: lastMailSyncResult(),
|
||||
@@ -283,8 +285,12 @@ router.get('/messages/:id', async (req, res, next) => {
|
||||
const [candidates] = await pool.query(
|
||||
`SELECT c.*,
|
||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||
/* Nur richtige Kontakte zaehlen (mail_pool = 0). Ein Eintrag im
|
||||
Vorbereich "Aus Mails" ist keine Dublette - das Uebernehmen von
|
||||
Hand legt trotzdem einen Kontakt an. */
|
||||
(SELECT lx.id FROM crm_leads lx
|
||||
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
|
||||
WHERE c.email IS NOT NULL AND lx.mail_pool = 0
|
||||
AND LOWER(lx.email) = LOWER(c.email)
|
||||
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
||||
FROM crm_mail_contact_candidates c
|
||||
LEFT JOIN crm_leads lead ON lead.id = c.lead_id
|
||||
@@ -423,9 +429,11 @@ router.get('/candidates', async (req, res, next) => {
|
||||
CONCAT_WS(' ', lead.first_name, lead.last_name) AS lead_name,
|
||||
u.name AS decided_by_name,
|
||||
/* Gibt es diese Adresse schon als Kontakt? Dann ist es keine
|
||||
Neuanlage, sondern eine Ergaenzung. */
|
||||
Neuanlage, sondern eine Ergaenzung. Der Vorbereich "Aus Mails"
|
||||
(mail_pool = 1) zaehlt hier nicht mit. */
|
||||
(SELECT lx.id FROM crm_leads lx
|
||||
WHERE c.email IS NOT NULL AND LOWER(lx.email) = LOWER(c.email)
|
||||
WHERE c.email IS NOT NULL AND lx.mail_pool = 0
|
||||
AND LOWER(lx.email) = LOWER(c.email)
|
||||
ORDER BY lx.id LIMIT 1) AS existing_lead_id
|
||||
FROM crm_mail_contact_candidates c
|
||||
JOIN crm_mail_messages m ON m.id = c.message_id
|
||||
@@ -473,10 +481,19 @@ router.post('/candidates/:id/adopt', async (req, res, next) => {
|
||||
: Number(req.body.owner_id);
|
||||
if (!Number.isInteger(ownerId)) return res.status(400).json({ error: 'Ungueltige Benutzer-ID.' });
|
||||
|
||||
/*
|
||||
* Von Hand uebernommen heisst: richtiger Kontakt. Der Vorbereich "Aus Mails"
|
||||
* ist fuer den Hintergrundlauf gedacht - wer hier klickt, hat den Vorschlag
|
||||
* schon gesehen und entschieden. Mit target: 'pool' laesst sich das im Bedarf
|
||||
* umstellen, etwa aus einer Sammelaktion.
|
||||
*/
|
||||
const target = req.body?.target === 'pool' ? 'pool' : 'contacts';
|
||||
|
||||
const result = await adoptCandidate(candidate, {
|
||||
userId: req.user.id,
|
||||
ownerId,
|
||||
mode: 'manuell',
|
||||
target,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
|
||||
@@ -60,6 +60,20 @@ CREATE TABLE IF NOT EXISTS crm_leads (
|
||||
CONSTRAINT fk_crm_leads_creator FOREIGN KEY (created_by) REFERENCES crm_users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
/*
|
||||
* Kontakte aus dem Postfach landen nicht direkt im Kontaktbestand, sondern in
|
||||
* einem Vorbereich: mail_pool = 1. Die Kontaktansicht zeigt nur mail_pool = 0,
|
||||
* der Vorbereich steht als eigener Menuepunkt "Aus Mails" daneben. Erst die
|
||||
* Uebernahme dort erzeugt einen richtigen Kontakt (mail_pool = 0).
|
||||
*
|
||||
* Bewusst eine Spalte statt einer zweiten Tabelle: Mailverknuepfungen
|
||||
* (crm_mail_links) und Vorschlaege (crm_mail_contact_candidates) zeigen per
|
||||
* Fremdschluessel auf crm_leads und bleiben damit auch fuer den Vorbereich
|
||||
* gueltig. Die Einmal-Umstellung bestehender Zeilen macht db.js.
|
||||
*/
|
||||
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS mail_pool TINYINT(1) NOT NULL DEFAULT 0 AFTER source;
|
||||
ALTER TABLE crm_leads ADD INDEX IF NOT EXISTS idx_crm_leads_mail_pool (mail_pool);
|
||||
|
||||
-- Wer steht mit wem in Kontakt. Ungerichtet gedacht; Duplikate werden ueber
|
||||
-- den Unique-Key auf dem sortierten Paar verhindert (siehe Backend).
|
||||
CREATE TABLE IF NOT EXISTS crm_lead_connections (
|
||||
|
||||
Reference in New Issue
Block a user