feat: LeadVault - zentrale Lead-Datenbank mit CRM-Funktionen
- Prisma-Schema: Lead + LeadEvent Modelle (Migration 20260320) - lib/services/leadVault.ts: sinkLeadsToVault mit Deduplizierung - Auto-Sync: alle 4 Pipelines schreiben Leads in LeadVault - GET /api/leads: Filter, Sortierung, Pagination (Server-side) - PATCH/DELETE /api/leads/[id]: Status, Priorität, Tags, Notizen - POST /api/leads/bulk: Bulk-Aktionen für mehrere Leads - GET /api/leads/stats: Statistiken + 7-Tage-Sparkline - POST /api/leads/quick-serp: SERP-Capture ohne Enrichment - GET /api/leads/export: CSV-Export mit allen Feldern - app/leadvault/page.tsx: vollständige UI mit Stats, Quick SERP, Filter-Leiste, sortierbare Tabelle, Bulk-Aktionen, Side Panel - Sidebar: LeadVault-Eintrag mit Live-Badge (neue Leads) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
159
lib/services/leadVault.ts
Normal file
159
lib/services/leadVault.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export interface VaultLead {
|
||||
domain?: string | null;
|
||||
companyName?: string | null;
|
||||
contactName?: string | null;
|
||||
contactTitle?: string | null;
|
||||
email?: string | null;
|
||||
linkedinUrl?: string | null;
|
||||
phone?: string | null;
|
||||
emailConfidence?: number | null;
|
||||
serpTitle?: string | null;
|
||||
serpSnippet?: string | null;
|
||||
serpRank?: number | null;
|
||||
serpUrl?: string | null;
|
||||
country?: string | null;
|
||||
headcount?: string | null;
|
||||
industry?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplication logic:
|
||||
* - Same domain + same email → skip (already exists)
|
||||
* - Same domain + no email yet + new version has email → update
|
||||
* - Otherwise → create new lead
|
||||
*/
|
||||
export async function sinkLeadToVault(
|
||||
lead: VaultLead,
|
||||
sourceTab: string,
|
||||
sourceTerm?: string,
|
||||
sourceJobId?: string,
|
||||
): Promise<string> {
|
||||
const domain = lead.domain || null;
|
||||
const email = lead.email || null;
|
||||
|
||||
if (domain) {
|
||||
// Check for exact duplicate (same domain + same email)
|
||||
if (email) {
|
||||
const exact = await prisma.lead.findFirst({
|
||||
where: { domain, email },
|
||||
});
|
||||
if (exact) return exact.id; // already exists, skip
|
||||
}
|
||||
|
||||
// Check if same domain exists without email and we now have one
|
||||
const existing = await prisma.lead.findFirst({
|
||||
where: { domain, email: null },
|
||||
});
|
||||
|
||||
if (existing && email) {
|
||||
// Upgrade: fill in email + other missing fields
|
||||
await prisma.lead.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.lead.create({
|
||||
data: {
|
||||
domain,
|
||||
companyName: lead.companyName || null,
|
||||
contactName: lead.contactName || null,
|
||||
contactTitle: lead.contactTitle || null,
|
||||
email,
|
||||
linkedinUrl: lead.linkedinUrl || null,
|
||||
phone: lead.phone || null,
|
||||
emailConfidence: lead.emailConfidence ?? null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpSnippet: lead.serpSnippet || null,
|
||||
serpRank: lead.serpRank ?? null,
|
||||
serpUrl: lead.serpUrl || null,
|
||||
country: lead.country || null,
|
||||
headcount: lead.headcount || null,
|
||||
industry: lead.industry || null,
|
||||
sourceTab,
|
||||
sourceTerm: sourceTerm || null,
|
||||
sourceJobId: sourceJobId || null,
|
||||
status: "new",
|
||||
priority: "normal",
|
||||
},
|
||||
});
|
||||
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function sinkLeadsToVault(
|
||||
leads: VaultLead[],
|
||||
sourceTab: string,
|
||||
sourceTerm?: string,
|
||||
sourceJobId?: string,
|
||||
): Promise<{ added: number; updated: number; skipped: number }> {
|
||||
let added = 0, updated = 0, skipped = 0;
|
||||
|
||||
for (const lead of leads) {
|
||||
const domain = lead.domain || null;
|
||||
const email = lead.email || null;
|
||||
|
||||
if (domain) {
|
||||
if (email) {
|
||||
const exact = await prisma.lead.findFirst({ where: { domain, email } });
|
||||
if (exact) { skipped++; continue; }
|
||||
}
|
||||
|
||||
const existing = await prisma.lead.findFirst({ where: { domain, email: null } });
|
||||
if (existing && email) {
|
||||
await prisma.lead.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
email,
|
||||
emailConfidence: lead.emailConfidence ?? existing.emailConfidence,
|
||||
contactName: lead.contactName || existing.contactName,
|
||||
contactTitle: lead.contactTitle || existing.contactTitle,
|
||||
linkedinUrl: lead.linkedinUrl || existing.linkedinUrl,
|
||||
phone: lead.phone || existing.phone,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.lead.create({
|
||||
data: {
|
||||
domain,
|
||||
companyName: lead.companyName || null,
|
||||
contactName: lead.contactName || null,
|
||||
contactTitle: lead.contactTitle || null,
|
||||
email,
|
||||
linkedinUrl: lead.linkedinUrl || null,
|
||||
phone: lead.phone || null,
|
||||
emailConfidence: lead.emailConfidence ?? null,
|
||||
serpTitle: lead.serpTitle || null,
|
||||
serpSnippet: lead.serpSnippet || null,
|
||||
serpRank: lead.serpRank ?? null,
|
||||
serpUrl: lead.serpUrl || null,
|
||||
country: lead.country || null,
|
||||
sourceTab,
|
||||
sourceTerm: sourceTerm || null,
|
||||
sourceJobId: sourceJobId || null,
|
||||
status: "new",
|
||||
priority: "normal",
|
||||
},
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
return { added, updated, skipped };
|
||||
}
|
||||
Reference in New Issue
Block a user