feat: add lead qualification workflow

This commit is contained in:
2026-06-04 16:09:47 +02:00
parent 15d8bfeb66
commit 59824b7336
19 changed files with 2833 additions and 78 deletions

View File

@@ -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;