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

@@ -1,4 +1,10 @@
import type { GooglePlaceCandidate } from "./lead-discovery-google";
import {
normalizePhone,
normalizeText,
getUsableContactEmail,
type GooglePlaceCandidate,
} from "./lead-discovery-google";
import type { Id } from "../convex/_generated/dataModel";
type AgentRunLike = {
status: string;
@@ -12,8 +18,16 @@ type LeadDiscoveryCounterInput = {
};
type LeadDiscoveryContactInput = {
websiteDomain?: string | null;
phone?: string | null;
usableEmail?: string | null;
};
export type LeadDiscoveryPriority = "high" | "medium" | "low" | "defer" | "blocked";
type LeadDiscoveryPriorityInput = {
isBlacklisted?: boolean;
isDuplicate?: boolean;
hasWebsite?: boolean;
hasWebsiteSignal?: boolean;
};
type LeadDiscoveryLeadRecordInput<TCampaignId extends string, TRunId extends string> = {
@@ -70,7 +84,7 @@ export function buildLeadDiscoveryCounters(input: LeadDiscoveryCounterInput) {
export function getLeadDiscoveryContactStatus(
input: LeadDiscoveryContactInput,
) {
if (input.websiteDomain || input.phone) {
if (input.usableEmail) {
return "new";
}
@@ -81,6 +95,14 @@ export function buildLeadDiscoveryLeadRecord<
TCampaignId extends string,
TRunId extends string,
>(input: LeadDiscoveryLeadRecordInput<TCampaignId, TRunId>) {
type LeadDiscoveryDuplicateStatus =
| "unchecked"
| "unique"
| "possible_duplicate"
| "duplicate";
const usableEmail = getUsableContactEmail(input.candidate);
const lead: {
campaignId: TCampaignId;
discoveryRunId: TRunId;
@@ -100,9 +122,21 @@ export function buildLeadDiscoveryLeadRecord<
websiteUrl?: string;
websiteDomain?: string;
phone?: string;
priority: "medium";
normalizedGooglePlaceId?: string;
normalizedEmail?: string;
normalizedPhone?: string;
normalizedCompanyName?: string;
normalizedAddress?: string;
email?: string;
emailSource?: string;
contactPerson?: string;
priorityReason?: string;
duplicateReason?: string;
duplicateOfLeadId?: Id<"leads">;
blacklistReason?: string;
priority: LeadDiscoveryPriority;
contactStatus: "new" | "missing_contact";
duplicateStatus: "unique";
duplicateStatus: LeadDiscoveryDuplicateStatus;
blacklistStatus: "clear";
createdAt: number;
updatedAt: number;
@@ -119,8 +153,7 @@ export function buildLeadDiscoveryLeadRecord<
sourceFetchedAt: input.candidate.sourceFetchedAt,
priority: "medium",
contactStatus: getLeadDiscoveryContactStatus({
websiteDomain: input.candidate.websiteDomain,
phone: input.candidate.phone,
usableEmail: usableEmail?.email,
}),
duplicateStatus: "unique",
blacklistStatus: "clear",
@@ -136,6 +169,21 @@ export function buildLeadDiscoveryLeadRecord<
const websiteUrl = optionalString(input.candidate.websiteUrl);
const websiteDomain = optionalString(input.candidate.websiteDomain);
const phone = optionalString(input.candidate.phone);
const normalizedPhone = normalizePhone(phone);
const normalizedCompanyName = normalizeText(input.candidate.businessName);
const normalizedAddress = normalizeText(input.candidate.address);
if (normalizedCompanyName !== "") {
lead.normalizedCompanyName = normalizedCompanyName;
}
if (normalizedAddress !== "") {
lead.normalizedAddress = normalizedAddress;
}
if (normalizedPhone !== "") {
lead.normalizedPhone = normalizedPhone;
}
if (googleMapsUrl !== undefined) {
lead.googleMapsUrl = googleMapsUrl;
@@ -161,6 +209,55 @@ export function buildLeadDiscoveryLeadRecord<
if (phone !== undefined) {
lead.phone = phone;
}
if (usableEmail) {
lead.normalizedEmail = usableEmail.email;
lead.email = usableEmail.email;
if (usableEmail.emailSource !== null) {
lead.emailSource = usableEmail.emailSource;
}
if (usableEmail.contactPerson !== null) {
lead.contactPerson = usableEmail.contactPerson;
}
} else {
lead.contactStatus = "missing_contact";
}
return lead;
}
export function getLeadDiscoveryPriority(
input: LeadDiscoveryPriorityInput,
): { priority: LeadDiscoveryPriority; reason: string } {
if (input.isBlacklisted) {
return {
priority: "blocked",
reason: "Lead ist auf der Sperrliste.",
};
}
if (input.isDuplicate) {
return {
priority: "defer",
reason: "Dublettenprüfung oder Reviewpause.",
};
}
if (!input.hasWebsite) {
return {
priority: "high",
reason: "Kein Website-Indikator vorhanden.",
};
}
if (input.hasWebsiteSignal) {
return {
priority: "low",
reason: "Website vorhanden: geringer Kontaktaufwand.",
};
}
return {
priority: "medium",
reason: "Standardpriorität.",
};
}