- 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>
180 lines
5.7 KiB
TypeScript
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));
|
|
}
|