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:
Sascha Wolf
2026-08-11 08:59:14 +02:00
co-authored by Claude Opus 5
parent 8090d0aae1
commit ea55acdb5c
22 changed files with 1524 additions and 115 deletions
+42
View File
@@ -128,6 +128,28 @@ export const usersApi = {
export type LeadInput = Partial<Omit<Lead, 'id' | 'created_at' | 'updated_at'>>;
/**
* Herkunft eines Eintrags im Vorbereich "Aus Mails": aus wie vielen Mails er
* stammt, welche die letzte war und wie sicher der Fund ist.
*/
export interface MailPoolSource {
message_count: number;
last_received_at: string | null;
last_subject: string | null;
/** 0-100; null, wenn der Vorschlag inzwischen entfernt wurde. */
confidence: number | null;
origin: 'absender' | 'empfaenger' | 'signatur' | 'text' | null;
}
export interface PromoteResult {
/** true = vorhandener Kontakt ergaenzt, false = neuer Kontakt angelegt. */
merged: boolean;
/** Anzahl der uebertragenen Felder (nur beim Ergaenzen). */
fields: number;
removed_pool: boolean;
lead: Lead;
}
export const leadsApi = {
list: () => request<Lead[]>('/leads'),
create: (input: LeadInput) =>
@@ -135,6 +157,26 @@ export const leadsApi = {
update: (id: number, input: LeadInput) =>
request<Lead>(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),
remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }),
/** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */
mailPoolSources: () => request<Record<string, MailPoolSource>>('/leads/mail-pool/sources'),
/**
* Eintrag aus "Aus Mails" in die Kontakte holen. Ohne `target_lead_id`
* entsteht ein neuer Kontakt; mit ihr werden die genannten Felder in den
* vorhandenen Kontakt geschrieben. `keep_pool` behaelt den Eintrag im
* Vorbereich.
*/
promote: (
id: number,
input: { target_lead_id?: number | null; fields?: string[]; keep_pool?: boolean; owner_id?: number } = {}
) => request<PromoteResult>(`/leads/${id}/promote`, { method: 'POST', body: JSON.stringify(input) }),
promoteMany: (ids: number[], options: { keep_pool?: boolean } = {}) =>
request<{ ok: true; created: number[]; skipped: number[] }>('/leads/mail-pool/promote', {
method: 'POST',
body: JSON.stringify({ ids, ...options }),
}),
};
// ---------- Opportunities ----------