Files
lead-scraper/app/api/jobs/linkedin-enrich/route.ts
Timo Uttenweiler 115cdacd08 UI improvements: Leadspeicher, Maps enrichment, exports
- Rename LeadVault → Leadspeicher throughout (sidebar, topbar, page)
- SidePanel: full lead detail view with contact, source, tags (read-only), Google Maps link for address
- Tags: kontaktiert stored as tag (toggleable), favorit tag toggle
- Remove Status column, StatusBadge dropdown, Priority feature
- Remove Aktualisieren button from Leadspeicher
- Bulk actions: remove status dropdown
- Export: LeadVault Excel-only, clean columns, freeze row + autofilter
- Export dropdown: click-based (fix overflow-hidden clipping)
- ExportButtons: remove CSV, Excel only everywhere
- Maps page: post-search Anymailfinder enrichment button
- ProgressCard: "Suche läuft..." instead of "Warte auf Anymailfinder-Server..."
- Quick SERP renamed to "Schnell neue Suche"
- Results page: Excel export, always-enabled download button
- Anymailfinder: fix bulk field names, array-of-arrays format
- Apify: fix countryCode lowercase
- API: sourceTerm search, contacted/favorite tag filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 18:12:31 +01:00

180 lines
5.7 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,
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,
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,
})),
"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));
}