Files
lead-scraper/app/api/jobs/maps-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

190 lines
5.8 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 sink to LeadVault
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,
}),
},
});
}
// Sink Google Maps results to vault immediately (before enrichment)
// so they're available even if Anymailfinder fails
await sinkLeadsToVault(
places.map(p => ({
domain: p.domain,
companyName: p.name || null,
phone: p.phone,
address: p.address,
description: p.description,
industry: p.category,
})),
"maps",
params.queries.join(", "),
jobId,
);
// 3. Optionally enrich with Anymailfinder
if (params.enrichEmails && places.length > 0) {
const anymailKey = await getApiKey("anymailfinder");
if (!anymailKey) throw new Error("Anymailfinder API-Key fehlt — bitte in den Einstellungen eintragen");
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.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,
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 },
});
// Update vault entries with enrichment results
await sinkLeadsToVault(
enrichResults
.filter(r => r.email)
.map(r => ({
domain: r.domain,
contactName: r.person_full_name || null,
contactTitle: r.person_job_title || null,
email: r.email || null,
linkedinUrl: r.person_linkedin_url || null,
})),
"maps",
params.queries.join(", "),
jobId,
);
} else {
await prisma.job.update({
where: { id: jobId },
data: { status: "complete", totalLeads: places.length },
});
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await prisma.job.update({
where: { id: jobId },
data: { status: "failed", error: message },
});
}
}