- 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>
177 lines
5.5 KiB
TypeScript
177 lines
5.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { prisma } from "@/lib/db";
|
|
import { getApiKey } from "@/lib/utils/apiKey";
|
|
import { searchPlacesMultiQuery } from "@/lib/services/googlemaps";
|
|
import { bulkSearchDomains, type DecisionMakerCategory } from "@/lib/services/anymailfinder";
|
|
import { sinkLeadsToVault } from "@/lib/services/leadVault";
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = await req.json() as {
|
|
queries: string[];
|
|
maxResultsPerQuery: number;
|
|
languageCode: string;
|
|
categories: DecisionMakerCategory[];
|
|
enrichEmails: boolean;
|
|
};
|
|
|
|
const { queries, maxResultsPerQuery, languageCode, categories, enrichEmails } = body;
|
|
|
|
if (!queries?.length) {
|
|
return NextResponse.json({ error: "No search queries provided" }, { status: 400 });
|
|
}
|
|
|
|
const mapsApiKey = await getApiKey("googlemaps");
|
|
if (!mapsApiKey) return NextResponse.json({ error: "Google Maps API key not configured" }, { status: 400 });
|
|
|
|
if (enrichEmails && !(await getApiKey("anymailfinder"))) {
|
|
return NextResponse.json({ error: "Anymailfinder API key not configured" }, { status: 400 });
|
|
}
|
|
|
|
const job = await prisma.job.create({
|
|
data: {
|
|
type: "maps",
|
|
status: "running",
|
|
config: JSON.stringify({ queries, maxResultsPerQuery, languageCode, categories, enrichEmails }),
|
|
totalLeads: 0,
|
|
},
|
|
});
|
|
|
|
runMapsEnrich(job.id, body, mapsApiKey).catch(console.error);
|
|
|
|
return NextResponse.json({ jobId: job.id });
|
|
} catch (err) {
|
|
console.error("POST /api/jobs/maps-enrich error:", err);
|
|
return NextResponse.json({ error: "Failed to start job" }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
async function runMapsEnrich(
|
|
jobId: string,
|
|
params: {
|
|
queries: string[];
|
|
maxResultsPerQuery: number;
|
|
languageCode: string;
|
|
categories: DecisionMakerCategory[];
|
|
enrichEmails: boolean;
|
|
},
|
|
mapsApiKey: string
|
|
) {
|
|
try {
|
|
// 1. Search Google Maps
|
|
const places = await searchPlacesMultiQuery(
|
|
params.queries,
|
|
mapsApiKey,
|
|
params.maxResultsPerQuery,
|
|
params.languageCode,
|
|
async (_done, _total) => {
|
|
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: _done } });
|
|
}
|
|
);
|
|
|
|
await prisma.job.update({
|
|
where: { id: jobId },
|
|
data: { totalLeads: places.length },
|
|
});
|
|
|
|
// 2. Store raw Google Maps results immediately
|
|
for (const place of places) {
|
|
await prisma.leadResult.create({
|
|
data: {
|
|
jobId,
|
|
companyName: place.name || null,
|
|
domain: place.domain || null,
|
|
source: JSON.stringify({
|
|
address: place.address,
|
|
phone: place.phone,
|
|
website: place.website,
|
|
placeId: place.placeId,
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
// 3. Optionally enrich with Anymailfinder
|
|
if (params.enrichEmails && places.length > 0) {
|
|
const anymailKey = await getApiKey("anymailfinder");
|
|
if (!anymailKey) throw new Error("Anymailfinder key missing");
|
|
const domains = places.filter(p => p.domain).map(p => p.domain!);
|
|
|
|
// Map domain → placeId for updating results
|
|
const domainToResultId = new Map<string, string>();
|
|
const existingResults = await prisma.leadResult.findMany({
|
|
where: { jobId },
|
|
select: { id: true, domain: true },
|
|
});
|
|
for (const r of existingResults) {
|
|
if (r.domain) domainToResultId.set(r.domain, r.id);
|
|
}
|
|
|
|
let emailsFound = 0;
|
|
const enrichResults = await bulkSearchDomains(
|
|
domains,
|
|
params.categories,
|
|
anymailKey,
|
|
async (_completed, total) => {
|
|
await prisma.job.update({ where: { id: jobId }, data: { totalLeads: total } });
|
|
}
|
|
);
|
|
|
|
for (const result of enrichResults) {
|
|
const hasEmail = !!result.valid_email;
|
|
if (hasEmail) emailsFound++;
|
|
|
|
const resultId = domainToResultId.get(result.domain || "");
|
|
if (!resultId) continue;
|
|
|
|
await prisma.leadResult.update({
|
|
where: { id: resultId },
|
|
data: {
|
|
contactName: result.person_full_name || null,
|
|
contactTitle: result.person_job_title || null,
|
|
email: result.email || null,
|
|
confidence: result.valid_email ? 1.0 : result.email_status === "risky" ? 0.5 : 0,
|
|
linkedinUrl: result.person_linkedin_url || null,
|
|
},
|
|
});
|
|
|
|
await prisma.job.update({ where: { id: jobId }, data: { emailsFound } });
|
|
}
|
|
|
|
await prisma.job.update({
|
|
where: { id: jobId },
|
|
data: { status: "complete", emailsFound, totalLeads: places.length },
|
|
});
|
|
} else {
|
|
await prisma.job.update({
|
|
where: { id: jobId },
|
|
data: { status: "complete", totalLeads: places.length },
|
|
});
|
|
}
|
|
|
|
// Sync to LeadVault
|
|
const finalResults = await prisma.leadResult.findMany({ where: { jobId } });
|
|
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,
|
|
phone: (() => { try { return JSON.parse(r.source || "{}").phone ?? null; } catch { return null; } })(),
|
|
})),
|
|
"maps",
|
|
params.queries.join(", "),
|
|
jobId,
|
|
);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await prisma.job.update({
|
|
where: { id: jobId },
|
|
data: { status: "failed", error: message },
|
|
});
|
|
}
|
|
}
|