feat: add lead qualification workflow
This commit is contained in:
@@ -5,13 +5,18 @@ import {
|
||||
buildGeocodingUrl,
|
||||
getBlacklistLookupValues,
|
||||
getBlacklistMatches,
|
||||
getCandidateEmailValues,
|
||||
getPlacesSearchSpec,
|
||||
normalizeDomain,
|
||||
normalizePhone,
|
||||
normalizeText,
|
||||
normalizePlacesResponse,
|
||||
parseGeocodingResponse,
|
||||
} from "../lib/lead-discovery-google";
|
||||
import {
|
||||
buildLeadDiscoveryLeadRecord,
|
||||
buildLeadDiscoveryCounters,
|
||||
getLeadDiscoveryPriority,
|
||||
} from "../lib/lead-discovery-run";
|
||||
import { calculateNextRunAt } from "../lib/campaign-scheduling";
|
||||
|
||||
@@ -37,6 +42,20 @@ const candidateValidator = v.object({
|
||||
googleTypes: v.array(v.string()),
|
||||
googlePrimaryType: nullableString,
|
||||
googleMapsUrl: nullableString,
|
||||
email: v.optional(nullableString),
|
||||
emailSource: v.optional(nullableString),
|
||||
contactPerson: v.optional(nullableString),
|
||||
isBusinessContactAddress: v.optional(v.boolean()),
|
||||
contactEmails: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
email: v.string(),
|
||||
emailSource: v.optional(nullableString),
|
||||
contactPerson: v.optional(nullableString),
|
||||
isBusinessContactAddress: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
),
|
||||
sourceProvider: v.literal("google_places"),
|
||||
sourceFetchedAt: v.number(),
|
||||
});
|
||||
@@ -396,23 +415,43 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingByPlaceId = await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_googlePlaceId", (q) =>
|
||||
q.eq("googlePlaceId", candidate.placeId),
|
||||
)
|
||||
.take(1);
|
||||
const candidateDomain = candidate.websiteDomain;
|
||||
const existingByDomain = candidateDomain
|
||||
const normalizedPlaceId = normalizeDomain(candidate.placeId);
|
||||
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
|
||||
const normalizedEmails = getCandidateEmailValues(candidate);
|
||||
const normalizedPhone = normalizePhone(candidate.phone);
|
||||
const normalizedCompanyName = normalizeText(candidate.businessName);
|
||||
const normalizedAddress = normalizeText(candidate.address);
|
||||
|
||||
const duplicateByPlaceId = normalizedPlaceId
|
||||
? await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_websiteDomain", (q) =>
|
||||
q.eq("websiteDomain", candidateDomain),
|
||||
.withIndex("by_normalizedGooglePlaceId", (q) =>
|
||||
q.eq("normalizedGooglePlaceId", normalizedPlaceId),
|
||||
)
|
||||
.take(1)
|
||||
: [];
|
||||
|
||||
if (existingByPlaceId.length > 0 || existingByDomain.length > 0) {
|
||||
const duplicateByDomain = normalizedDomain
|
||||
? await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_websiteDomain", (q) => q.eq("websiteDomain", normalizedDomain))
|
||||
.take(1)
|
||||
: [];
|
||||
|
||||
const duplicateByEmailRows = [];
|
||||
for (const email of normalizedEmails) {
|
||||
const rows = await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_normalizedEmail", (q) => q.eq("normalizedEmail", email))
|
||||
.take(1);
|
||||
duplicateByEmailRows.push(...rows);
|
||||
}
|
||||
|
||||
if (
|
||||
duplicateByPlaceId.length > 0 ||
|
||||
duplicateByDomain.length > 0 ||
|
||||
duplicateByEmailRows.length > 0
|
||||
) {
|
||||
skippedDuplicates += 1;
|
||||
await ctx.db.insert("agentRunEvents", {
|
||||
runId: args.runId,
|
||||
@@ -427,6 +466,29 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
continue;
|
||||
}
|
||||
|
||||
const probableDuplicateByPhone = normalizedPhone
|
||||
? await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_normalizedPhone", (q) =>
|
||||
q.eq("normalizedPhone", normalizedPhone),
|
||||
)
|
||||
.take(1)
|
||||
: [];
|
||||
|
||||
const probableDuplicateByAddress = normalizedCompanyName && normalizedAddress
|
||||
? await ctx.db
|
||||
.query("leads")
|
||||
.withIndex("by_normalizedCompanyName_and_normalizedAddress", (q) =>
|
||||
q
|
||||
.eq("normalizedCompanyName", normalizedCompanyName)
|
||||
.eq("normalizedAddress", normalizedAddress),
|
||||
)
|
||||
.take(1)
|
||||
: [];
|
||||
|
||||
const probableDuplicateLead =
|
||||
probableDuplicateByPhone[0] ?? probableDuplicateByAddress[0] ?? null;
|
||||
|
||||
const blacklistRows = [];
|
||||
for (const lookup of getBlacklistLookupValues(candidate)) {
|
||||
const rows = await ctx.db
|
||||
@@ -465,6 +527,34 @@ export const persistDiscoveredLeads = internalMutation({
|
||||
candidate,
|
||||
now,
|
||||
});
|
||||
const hasWebsite = Boolean(candidate.websiteUrl ?? candidate.websiteDomain);
|
||||
const priorityResult = getLeadDiscoveryPriority({
|
||||
isDuplicate: !!probableDuplicateLead,
|
||||
hasWebsite,
|
||||
hasWebsiteSignal: false, // plain Google-Places website hint maps to medium priority.
|
||||
});
|
||||
const isDuplicateCandidate = !!probableDuplicateLead;
|
||||
|
||||
if (normalizedPlaceId) {
|
||||
lead.normalizedGooglePlaceId = normalizedPlaceId;
|
||||
}
|
||||
if (normalizedPhone !== "") {
|
||||
lead.normalizedPhone = normalizedPhone;
|
||||
}
|
||||
if (normalizedCompanyName !== "") {
|
||||
lead.normalizedCompanyName = normalizedCompanyName;
|
||||
}
|
||||
if (normalizedAddress !== "") {
|
||||
lead.normalizedAddress = normalizedAddress;
|
||||
}
|
||||
lead.priority = priorityResult.priority;
|
||||
lead.priorityReason = priorityResult.reason;
|
||||
|
||||
if (isDuplicateCandidate) {
|
||||
lead.duplicateStatus = "possible_duplicate";
|
||||
lead.duplicateReason = `Möglicher Dublettenkandidat zu Lead ${probableDuplicateLead._id}`;
|
||||
lead.duplicateOfLeadId = probableDuplicateLead._id;
|
||||
}
|
||||
|
||||
await ctx.db.insert("leads", lead);
|
||||
leadsCreated += 1;
|
||||
|
||||
Reference in New Issue
Block a user