Initial commit: LeadFlow lead generation platform
Full-stack Next.js 16 app with three scraping pipelines: - AirScale CSV → Anymailfinder Bulk Decision Maker search - LinkedIn Sales Navigator → Vayne → Anymailfinder email enrichment - Apify Google SERP → domain extraction → Anymailfinder bulk enrichment Includes Docker multi-stage build + docker-compose for Coolify deployment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
167
app/api/jobs/linkedin-enrich/route.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/utils/encryption";
|
||||
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 cred = await prisma.apiCredential.findUnique({ where: { service: "anymailfinder" } });
|
||||
if (!cred?.value) {
|
||||
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
||||
}
|
||||
const apiKey = decrypt(cred.value);
|
||||
|
||||
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 },
|
||||
});
|
||||
} 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));
|
||||
}
|
||||
Reference in New Issue
Block a user