Integrate local business workflow and SaaS redesign

This commit is contained in:
2026-06-12 21:08:35 +02:00
parent f00c5a3193
commit 21c7e4c9a4
88 changed files with 2683 additions and 849 deletions

View File

@@ -1,23 +1,23 @@
import { v } from "convex/values";
import {
GOOGLE_PLACES_FIELD_MASK,
buildGeocodingUrl,
getBlacklistLookupValues,
getBlacklistMatches,
getCandidateEmailValues,
getPlacesSearchSpec,
getUsableContactEmail,
normalizeDomain,
normalizePhone,
normalizeText,
normalizePlacesResponse,
parseGeocodingResponse,
} from "../lib/lead-discovery-google";
import {
LOCAL_BUSINESS_DATA_HOST,
getLocalBusinessSearchSpec,
normalizeLocalBusinessSearchResponse,
} from "../lib/lead-discovery-local-business";
import {
buildLeadDiscoveryLeadRecord,
buildLeadDiscoveryCounters,
getLeadDiscoveryPriority,
shouldScheduleWebsiteEnrichment,
} from "../lib/lead-discovery-run";
import { calculateNextRunAt } from "../lib/campaign-scheduling";
@@ -26,12 +26,21 @@ import { Doc, Id } from "./_generated/dataModel";
import { internalAction, internalMutation } from "./_generated/server";
type CampaignDoc = Doc<"campaigns">;
type DuplicateEmailBackfillPatch = Partial<
Pick<
Doc<"leads">,
"normalizedEmail" | "email" | "emailSource" | "contactPerson" | "contactStatus" | "contactStatusReason"
>
> & {
updatedAt: number;
};
const nullableString = v.union(v.string(), v.null());
const nullableNumber = v.union(v.number(), v.null());
const candidateValidator = v.object({
placeId: v.string(),
sourceBusinessId: v.optional(nullableString),
businessName: v.string(),
address: v.string(),
websiteUrl: nullableString,
@@ -57,7 +66,10 @@ const candidateValidator = v.object({
}),
),
),
sourceProvider: v.literal("google_places"),
sourceProvider: v.union(
v.literal("google_places"),
v.literal("local_business_data"),
),
sourceFetchedAt: v.number(),
});
@@ -98,7 +110,7 @@ async function fetchJson(url: string, init?: RequestInit) {
if (!response.ok) {
const body = await response.text();
throw new Error(
`Google API request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
`Local Business Data request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
);
}
@@ -122,89 +134,54 @@ export const processCampaignRun = internalAction({
}
try {
const geocodingApiKey = getRequiredEnv("GOOGLE_GEOCODING_API_KEY");
const placesApiKey = getRequiredEnv("GOOGLE_PLACES_API_KEY");
const localBusinessDataApiKey = getRequiredEnv("LOCAL_BUSINESS_DATA_API_KEY");
const campaign = started.campaign;
const fetchedAt = Date.now();
let latitude = campaign.latitude;
let longitude = campaign.longitude;
if (typeof latitude !== "number" || typeof longitude !== "number") {
const geocodingUrl = buildGeocodingUrl({
postalCode: campaign.postalCode,
apiKey: geocodingApiKey,
});
const geocodingJson = await fetchJson(geocodingUrl);
const geocoding = parseGeocodingResponse(geocodingJson, fetchedAt);
latitude = geocoding.latitude;
longitude = geocoding.longitude;
await ctx.runMutation(internal.leadDiscovery.cacheCampaignGeocode, {
campaignId: campaign._id,
latitude,
longitude,
geocodedAt: geocoding.fetchedAt,
geocodingPlaceId: geocoding.placeId,
geocodingFormattedAddress: geocoding.formattedAddress,
});
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "info",
message: "PLZ geocodiert.",
details: [
{ label: "PLZ", value: campaign.postalCode, source: "google_geocoding" },
{
label: "Koordinaten",
value: `${latitude}, ${longitude}`,
source: "google_geocoding",
},
],
});
} else {
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "info",
message: "Geocoding-Cache der Kampagne verwendet.",
details: [
{ label: "PLZ", value: campaign.postalCode },
{ label: "Koordinaten", value: `${latitude}, ${longitude}` },
],
});
}
const searchSpec = getPlacesSearchSpec({
const searchSpec = getLocalBusinessSearchSpec({
categoryMode: campaign.categoryMode,
category: campaign.category,
customSearchTerm: campaign.customSearchTerm,
postalCode: campaign.postalCode,
radiusKm: campaign.radiusKm,
latitude,
longitude,
maxNewLeads: campaign.maxNewLeadsPerRun,
});
const placesJson = await fetchJson(
`https://places.googleapis.com/v1/places:${searchSpec.endpoint}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": placesApiKey,
"X-Goog-FieldMask": GOOGLE_PLACES_FIELD_MASK,
},
body: JSON.stringify(searchSpec.body),
const localBusinessJson = await fetchJson(searchSpec.url, {
method: "GET",
headers: {
"X-RapidAPI-Key": localBusinessDataApiKey,
"X-RapidAPI-Host": LOCAL_BUSINESS_DATA_HOST,
},
});
const candidates = normalizeLocalBusinessSearchResponse(
localBusinessJson,
Date.now(),
);
const candidates = normalizePlacesResponse(placesJson, Date.now());
await ctx.runMutation(internal.usageEvents.recordUsageEvent, {
provider: "local_business_data",
operation: "lead_lookup",
runId: args.runId,
estimatedCostUsd: 0,
callCounts: {
requests: 1,
lookups: candidates.length,
},
});
if (candidates.length === 0) {
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "warning",
message: "Google Places lieferte keine Ergebnisse.",
message: "Local Business Data lieferte keine Ergebnisse.",
details: [
{ label: "Suchtyp", value: searchSpec.searchType, source: "google_places" },
{ label: "Kategorie", value: getCampaignNiche(campaign), source: "google_places" },
{
label: "Suchquery",
value: searchSpec.query,
source: "local_business_data",
},
{
label: "Kategorie",
value: getCampaignNiche(campaign),
source: "local_business_data",
},
],
});
}
@@ -215,11 +192,6 @@ export const processCampaignRun = internalAction({
skippedDuplicates: number;
skippedBlacklisted: number;
errors: number;
websiteEnrichmentQueue: Array<{
leadId: Id<"leads">;
companyName: string;
website: string;
}>;
} = await ctx.runMutation(internal.leadDiscovery.persistDiscoveredLeads, {
runId: args.runId,
campaignId: campaign._id,
@@ -229,31 +201,6 @@ export const processCampaignRun = internalAction({
candidates,
});
for (const enrichment of result.websiteEnrichmentQueue) {
await ctx.runMutation(internal.websiteEnrichment.queueLeadEnrichment, {
leadId: enrichment.leadId,
parentRunId: args.runId,
});
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "info",
message: "Website-Kontaktanreicherung geplant.",
details: [
{
label: "Unternehmen",
value: enrichment.companyName,
source: "google_places",
},
{
label: "Website",
value: enrichment.website,
source: "google_places",
},
],
});
}
await ctx.runMutation(internal.leadDiscovery.finishCampaignRun, {
runId: args.runId,
status: "succeeded",
@@ -423,11 +370,6 @@ export const persistDiscoveredLeads = internalMutation({
let skippedDuplicates = 0;
let skippedBlacklisted = 0;
let errors = 0;
const websiteEnrichmentQueue: Array<{
leadId: Id<"leads">;
companyName: string;
website: string;
}> = [];
for (const candidate of args.candidates) {
if (leadsCreated >= args.maxNewLeads) {
@@ -446,7 +388,7 @@ export const persistDiscoveredLeads = internalMutation({
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "warning",
message: "Google-Places-Ergebnis ohne Unternehmensname übersprungen.",
message: "Lead-Recherche-Ergebnis ohne Unternehmensname übersprungen.",
details: [{ label: "Place ID", value: candidate.placeId }],
createdAt: Date.now(),
});
@@ -454,6 +396,9 @@ export const persistDiscoveredLeads = internalMutation({
}
const normalizedPlaceId = normalizeDomain(candidate.placeId);
const normalizedSourceBusinessId = normalizeDomain(
candidate.sourceBusinessId ?? candidate.placeId,
);
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
const normalizedEmails = getCandidateEmailValues(candidate);
const normalizedPhone = normalizePhone(candidate.phone);
@@ -476,6 +421,15 @@ export const persistDiscoveredLeads = internalMutation({
.take(1)
: [];
const duplicateBySourceBusinessId = normalizedSourceBusinessId
? await ctx.db
.query("leads")
.withIndex("by_normalizedSourceBusinessId", (q) =>
q.eq("normalizedSourceBusinessId", normalizedSourceBusinessId),
)
.take(1)
: [];
const duplicateByEmailRows = [];
for (const email of normalizedEmails) {
const rows = await ctx.db
@@ -487,17 +441,84 @@ export const persistDiscoveredLeads = internalMutation({
if (
duplicateByPlaceId.length > 0 ||
duplicateBySourceBusinessId.length > 0 ||
duplicateByDomain.length > 0 ||
duplicateByEmailRows.length > 0
) {
skippedDuplicates += 1;
const duplicateLeadForEmailBackfill =
duplicateBySourceBusinessId[0] ??
duplicateByPlaceId[0] ??
duplicateByDomain[0] ??
null;
const usableEmail = getUsableContactEmail(candidate);
if (
duplicateLeadForEmailBackfill &&
usableEmail &&
!duplicateLeadForEmailBackfill.email &&
duplicateLeadForEmailBackfill.contactStatus !== "do_not_contact" &&
duplicateLeadForEmailBackfill.blacklistStatus !== "blocked" &&
duplicateLeadForEmailBackfill.priority !== "blocked" &&
duplicateByEmailRows.length === 0
) {
const emailBackfillPatch: DuplicateEmailBackfillPatch = {
normalizedEmail: usableEmail.email,
email: usableEmail.email,
updatedAt: now,
};
if (usableEmail.emailSource !== null) {
emailBackfillPatch.emailSource = usableEmail.emailSource;
}
if (usableEmail.contactPerson !== null) {
emailBackfillPatch.contactPerson = usableEmail.contactPerson;
}
if (duplicateLeadForEmailBackfill.contactStatus === "missing_contact") {
emailBackfillPatch.contactStatus = "new";
emailBackfillPatch.contactStatusReason =
"E-Mail bei erneutem Local-Business-Data-Lauf ergänzt.";
}
await ctx.db.patch(
duplicateLeadForEmailBackfill._id,
emailBackfillPatch,
);
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "E-Mail für bestehenden Lead ergänzt.",
details: [
{
label: "Unternehmen",
value: duplicateLeadForEmailBackfill.companyName,
source: candidate.sourceProvider,
},
{
label: "E-Mail-Quelle",
value: usableEmail.emailSource ?? "local_business_data",
source: candidate.sourceProvider,
},
],
createdAt: Date.now(),
});
}
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Doppelter Lead übersprungen.",
details: [
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
{
label: "Unternehmen",
value: candidate.businessName,
source: candidate.sourceProvider,
},
{
label: "Source ID",
value: candidate.sourceBusinessId ?? candidate.placeId,
source: candidate.sourceProvider,
},
],
createdAt: Date.now(),
});
@@ -569,13 +590,16 @@ export const persistDiscoveredLeads = internalMutation({
const priorityResult = getLeadDiscoveryPriority({
isDuplicate: !!probableDuplicateLead,
hasWebsite,
hasWebsiteSignal: false, // plain Google-Places website hint maps to medium priority.
hasWebsiteSignal: false,
});
const isDuplicateCandidate = !!probableDuplicateLead;
if (normalizedPlaceId) {
lead.normalizedGooglePlaceId = normalizedPlaceId;
}
if (normalizedSourceBusinessId) {
lead.normalizedSourceBusinessId = normalizedSourceBusinessId;
}
if (normalizedPhone !== "") {
lead.normalizedPhone = normalizedPhone;
}
@@ -596,20 +620,21 @@ export const persistDiscoveredLeads = internalMutation({
const leadId = await ctx.db.insert("leads", lead);
leadsCreated += 1;
if (shouldScheduleWebsiteEnrichment(lead)) {
websiteEnrichmentQueue.push({
leadId,
companyName: lead.companyName,
website: lead.websiteDomain ?? lead.websiteUrl ?? "unbekannt",
});
}
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Lead aus Google Places gespeichert.",
message: "Lead aus Local Business Data gespeichert.",
details: [
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
{
label: "Unternehmen",
value: candidate.businessName,
source: candidate.sourceProvider,
},
{
label: "Source ID",
value: candidate.sourceBusinessId ?? candidate.placeId,
source: candidate.sourceProvider,
},
],
createdAt: Date.now(),
});
@@ -634,7 +659,6 @@ export const persistDiscoveredLeads = internalMutation({
skippedDuplicates,
skippedBlacklisted,
errors,
websiteEnrichmentQueue,
};
},
});