Integrate local business workflow and SaaS redesign
This commit is contained in:
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user