- 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>
183 lines
5.9 KiB
TypeScript
183 lines
5.9 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/db";
|
|
import { getApiKey } from "@/lib/utils/apiKey";
|
|
import { sinkLeadsToVault } from "@/lib/services/leadVault";
|
|
import {
|
|
submitBulkPersonSearch,
|
|
getBulkSearchStatus,
|
|
downloadBulkResults,
|
|
searchDecisionMakerByDomain,
|
|
type DecisionMakerCategory,
|
|
} from "@/lib/services/anymailfinder";
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = await req.json() as {
|
|
jobId: string;
|
|
resultIds: string[];
|
|
categories: DecisionMakerCategory[];
|
|
};
|
|
const { jobId, resultIds, categories } = body;
|
|
|
|
const apiKey = await getApiKey("anymailfinder");
|
|
if (!apiKey) return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
|
|
|
const results = await prisma.leadResult.findMany({
|
|
where: { id: { in: resultIds }, jobId, domain: { not: null } },
|
|
});
|
|
|
|
const enrichJob = await prisma.job.create({
|
|
data: {
|
|
type: "linkedin-enrich",
|
|
status: "running",
|
|
config: JSON.stringify({ parentJobId: jobId, categories }),
|
|
totalLeads: results.length,
|
|
},
|
|
});
|
|
|
|
runLinkedInEnrich(enrichJob.id, jobId, results, categories, apiKey).catch(console.error);
|
|
|
|
return NextResponse.json({ jobId: enrichJob.id });
|
|
} catch (err) {
|
|
console.error("POST /api/jobs/linkedin-enrich error:", err);
|
|
return NextResponse.json({ error: "Failed to start enrichment" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
async function runLinkedInEnrich(
|
|
enrichJobId: string,
|
|
parentJobId: string,
|
|
results: Array<{
|
|
id: string; domain: string | null; contactName: string | null;
|
|
companyName: string | null; contactTitle: string | null; linkedinUrl: string | null;
|
|
}>,
|
|
categories: DecisionMakerCategory[],
|
|
apiKey: string
|
|
) {
|
|
let emailsFound = 0;
|
|
|
|
try {
|
|
// Separate results into those with names (person search) and those without (decision maker search)
|
|
const withNames: typeof results = [];
|
|
const withoutNames: typeof results = [];
|
|
|
|
for (const r of results) {
|
|
if (r.contactName && r.domain) {
|
|
withNames.push(r);
|
|
} else if (r.domain) {
|
|
withoutNames.push(r);
|
|
}
|
|
}
|
|
|
|
// Map to look up results by domain
|
|
const resultByDomain = new Map(results.map(r => [r.domain!, r]));
|
|
|
|
// 1. Bulk person name search for leads with names
|
|
if (withNames.length > 0) {
|
|
const leads = withNames.map(r => {
|
|
const nameParts = (r.contactName || "").trim().split(/\s+/);
|
|
return {
|
|
domain: r.domain!,
|
|
firstName: nameParts[0] || "",
|
|
lastName: nameParts.slice(1).join(" ") || "",
|
|
};
|
|
});
|
|
|
|
try {
|
|
const searchId = await submitBulkPersonSearch(leads, apiKey, `linkedin-enrich-${enrichJobId}`);
|
|
|
|
// Poll for completion
|
|
let status;
|
|
do {
|
|
await sleep(5000);
|
|
status = await getBulkSearchStatus(searchId, apiKey);
|
|
} while (status.status !== "completed" && status.status !== "failed");
|
|
|
|
if (status.status === "completed") {
|
|
const rows = await downloadBulkResults(searchId, apiKey);
|
|
|
|
for (const row of rows) {
|
|
const domain = row["domain"] || row["Domain"] || "";
|
|
const result = resultByDomain.get(domain);
|
|
if (!result) continue;
|
|
|
|
const email = row["email"] || row["Email"] || null;
|
|
const emailStatus = (row["email_status"] || row["Email Status"] || "not_found").toLowerCase();
|
|
const isValid = emailStatus === "valid";
|
|
if (isValid) emailsFound++;
|
|
|
|
await prisma.leadResult.update({
|
|
where: { id: result.id },
|
|
data: {
|
|
email: email || null,
|
|
confidence: isValid ? 1.0 : emailStatus === "risky" ? 0.5 : 0,
|
|
contactName: row["person_full_name"] || row["Full Name"] || result.contactName || null,
|
|
contactTitle: row["person_job_title"] || row["Job Title"] || result.contactTitle || null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Bulk person search error:", err);
|
|
// Fall through — will attempt decision-maker search below
|
|
}
|
|
}
|
|
|
|
// 2. Decision-maker search for leads without names
|
|
for (const r of withoutNames) {
|
|
if (!r.domain) continue;
|
|
try {
|
|
const found = await searchDecisionMakerByDomain(r.domain, categories, apiKey);
|
|
const isValid = !!found.valid_email;
|
|
if (isValid) emailsFound++;
|
|
|
|
await prisma.leadResult.update({
|
|
where: { id: r.id },
|
|
data: {
|
|
email: found.email || null,
|
|
confidence: isValid ? 1.0 : found.email_status === "risky" ? 0.5 : 0,
|
|
contactName: found.person_full_name || r.contactName || null,
|
|
contactTitle: found.person_job_title || r.contactTitle || null,
|
|
},
|
|
});
|
|
|
|
await prisma.job.update({ where: { id: enrichJobId }, data: { emailsFound } });
|
|
} catch (err) {
|
|
console.error(`Decision-maker search error for domain ${r.domain}:`, err);
|
|
}
|
|
}
|
|
|
|
await prisma.job.update({
|
|
where: { id: enrichJobId },
|
|
data: { status: "complete", emailsFound },
|
|
});
|
|
|
|
// Sync final state to LeadVault
|
|
const finalResults = await prisma.leadResult.findMany({ where: { jobId: enrichJobId } });
|
|
await sinkLeadsToVault(
|
|
finalResults.map(r => ({
|
|
domain: r.domain,
|
|
companyName: r.companyName,
|
|
contactName: r.contactName,
|
|
contactTitle: r.contactTitle,
|
|
email: r.email,
|
|
linkedinUrl: r.linkedinUrl,
|
|
emailConfidence: r.confidence,
|
|
})),
|
|
"linkedin",
|
|
undefined,
|
|
enrichJobId,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await prisma.job.update({
|
|
where: { id: enrichJobId },
|
|
data: { status: "failed", error: message },
|
|
});
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise(r => setTimeout(r, ms));
|
|
}
|