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

@@ -29,7 +29,7 @@ export type ReviewQueueItem = {
detail: string;
};
export type LeadPriority = "high" | "medium" | "low" | "defer";
export type LeadPriority = "high" | "medium" | "low" | "defer" | "blocked";
export type LeadContactStatus =
| "new"
@@ -41,6 +41,11 @@ export type LeadContactStatus =
| "do_not_contact";
export type LeadBlacklistStatus = "clear" | "blocked";
export type LeadDuplicateStatus =
| "unchecked"
| "unique"
| "possible_duplicate"
| "duplicate";
export type OutreachApprovalStatus = "draft" | "approved" | "rejected";
export type OutreachSendStatus = "not_sent" | "queued" | "sent" | "failed";
@@ -151,14 +156,15 @@ export const leadFunnelStages: LeadFunnelStage[] = [
},
];
const priorityLabels: Record<LeadPriority, string> = {
export const leadPriorityLabels: Record<LeadPriority, string> = {
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
defer: "Zurückstellen",
blocked: "Gesperrt",
};
const contactStatusLabels: Record<LeadContactStatus, string> = {
export const leadContactStatusLabels: Record<LeadContactStatus, string> = {
new: "Neu",
missing_contact: "Kontakt fehlt",
audit_ready: "Audit bereit",
@@ -168,6 +174,61 @@ const contactStatusLabels: Record<LeadContactStatus, string> = {
do_not_contact: "Nicht kontaktieren",
};
export const leadDuplicateStatusLabels: Record<LeadDuplicateStatus, string> = {
unchecked: "Noch nicht geprüft",
unique: "Einzigartig",
possible_duplicate: "Möglicher Doppelter",
duplicate: "Duplikat",
};
export const leadBlacklistStatusLabels: Record<LeadBlacklistStatus, string> = {
clear: "Offen",
blocked: "Gesperrt",
};
export const leadPriorityOptions: LeadPriority[] = [
"high",
"medium",
"low",
"defer",
"blocked",
];
export const leadContactStatusOptions: LeadContactStatus[] = [
"new",
"missing_contact",
"audit_ready",
"outreach_ready",
"contacted",
"replied",
"do_not_contact",
];
export const leadDuplicateStatusOptions: LeadDuplicateStatus[] = [
"unchecked",
"unique",
"possible_duplicate",
"duplicate",
];
export const leadBlacklistStatusOptions: LeadBlacklistStatus[] = ["clear", "blocked"];
export function getLeadPriorityLabel(priority: LeadPriority): string {
return leadPriorityLabels[priority];
}
export function getLeadContactStatusLabel(status: LeadContactStatus): string {
return leadContactStatusLabels[status];
}
export function getLeadDuplicateStatusLabel(status: LeadDuplicateStatus): string {
return leadDuplicateStatusLabels[status];
}
export function getLeadBlacklistStatusLabel(status: LeadBlacklistStatus): string {
return leadBlacklistStatusLabels[status];
}
export function toLeadFunnelCard(lead: LeadFunnelInput): LeadFunnelCard {
return {
id: lead.id,
@@ -175,8 +236,8 @@ export function toLeadFunnelCard(lead: LeadFunnelInput): LeadFunnelCard {
company: lead.companyName,
niche: lead.niche ?? "Nische offen",
location: formatLeadLocation(lead),
priorityLabel: priorityLabels[lead.priority],
contactStatusLabel: contactStatusLabels[lead.contactStatus],
priorityLabel: getLeadPriorityLabel(lead.priority),
contactStatusLabel: getLeadContactStatusLabel(lead.contactStatus),
nextAction: getLeadNextAction(lead),
websiteDomain: lead.websiteDomain,
contactDetail: formatContactDetail(lead),
@@ -198,6 +259,7 @@ function getLeadFunnelStageId(lead: LeadFunnelInput): LeadFunnelStageId {
if (
lead.blacklistStatus === "blocked" ||
lead.priority === "defer" ||
lead.priority === "blocked" ||
lead.contactStatus === "do_not_contact"
) {
return "deferred";

View File

@@ -228,6 +228,13 @@ type GooglePlaceDisplayName =
text?: string;
};
type GooglePlaceContactEmailSource = {
email: string;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
};
type GooglePlaceApiPlace = {
id?: string;
displayName?: GooglePlaceDisplayName;
@@ -254,6 +261,11 @@ export type GooglePlaceCandidate = {
websiteUrl: string | null;
websiteDomain: string | null;
phone: string | null;
email?: string | null;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
contactEmails?: GooglePlaceContactEmailSource[];
rating: number | null;
userRatingCount: number | null;
businessStatus: string | null;
@@ -297,6 +309,163 @@ function normalizeWebsiteDomain(input?: string | null) {
}
}
const GENERIC_BUSINESS_EMAIL_LOCAL_PARTS = new Set([
"info",
"kontakt",
"hello",
"hallo",
"office",
"post",
"service",
"team",
"anfrage",
]);
export function normalizeText(value?: string | null) {
return value?.trim().toLowerCase().replace(/\s+/g, " ") ?? "";
}
export function normalizeEmailAddress(value?: string | null) {
const valueTrimmed = value?.trim().toLowerCase();
if (!valueTrimmed) {
return null;
}
const [localPart, domain] = valueTrimmed.split("@");
if (!localPart || !domain) {
return null;
}
if (!/^[a-z0-9._%+-]+$/.test(localPart)) {
return null;
}
if (!/^[^\s@]+\.[^\s@]+$/.test(domain)) {
return null;
}
return valueTrimmed;
}
export type UsableContactEmail = {
email: string;
emailSource: string | null;
contactPerson: string | null;
};
type ParsedContactEmail = {
email: string;
emailSource: string | null;
contactPerson: string | null;
isBusinessContactAddress: boolean;
isGeneric: boolean;
};
type ContactEmailRuleInput = {
email: string;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
};
export function getUsableContactEmailFromEntries(
entries: ContactEmailRuleInput[] | undefined,
) {
if (!Array.isArray(entries) || entries.length === 0) {
return null;
}
const parsedEntries: ParsedContactEmail[] = [];
for (const emailEntry of entries) {
const normalized = normalizeEmailAddress(emailEntry.email);
if (!normalized) {
continue;
}
parsedEntries.push({
email: normalized,
emailSource: emailEntry.emailSource ?? null,
contactPerson: emailEntry.contactPerson ?? null,
isBusinessContactAddress: emailEntry.isBusinessContactAddress === true,
isGeneric: isGenericBusinessEmail(normalized),
});
}
const generic = parsedEntries.find((entry) => entry.isGeneric);
if (generic) {
return {
email: generic.email,
emailSource: generic.emailSource,
contactPerson: generic.contactPerson,
};
}
const named = parsedEntries.find((entry) => entry.isBusinessContactAddress);
if (!named) {
return null;
}
return {
email: named.email,
emailSource: named.emailSource,
contactPerson: named.contactPerson,
};
}
function getCandidateEmailMetadata(candidate: GooglePlaceCandidate) {
const emails: GooglePlaceContactEmailSource[] = [];
if (candidate.email) {
emails.push({
email: candidate.email,
emailSource: candidate.emailSource,
contactPerson: candidate.contactPerson,
isBusinessContactAddress: candidate.isBusinessContactAddress,
});
}
if (Array.isArray(candidate.contactEmails)) {
emails.push(...candidate.contactEmails);
}
return emails;
}
export function getCandidateEmailValues(candidate: GooglePlaceCandidate) {
return getCandidateEmailMetadata(candidate)
.map((entry) => normalizeEmailAddress(entry.email))
.filter((value): value is string => value !== null);
}
function splitEmailLocalPart(email: string) {
const [localPart] = email.split("@");
return localPart?.split("+")[0] ?? "";
}
function isGenericBusinessEmail(email: string) {
const normalizedLocalPart = splitEmailLocalPart(email).toLowerCase();
return GENERIC_BUSINESS_EMAIL_LOCAL_PARTS.has(normalizedLocalPart);
}
export function getUsableContactEmail(
candidate: GooglePlaceCandidate,
): UsableContactEmail | null {
return getUsableContactEmailFromEntries(
getCandidateEmailMetadata(candidate).map((entry) => ({
email: entry.email,
emailSource: entry.emailSource,
contactPerson: entry.contactPerson,
isBusinessContactAddress: entry.isBusinessContactAddress,
})),
);
}
export function normalizePlacesResponse(
response: GooglePlacesApiResponse,
fetchedAt: number,
@@ -333,6 +502,10 @@ export function normalizePlacesResponse(
export type ExistingLeadLike = {
googlePlaceId?: string | null;
websiteDomain?: string | null;
email?: string | null;
companyName?: string | null;
address?: string | null;
phone?: string | null;
};
export type BlacklistRow = {
@@ -342,20 +515,25 @@ export type BlacklistRow = {
};
export type BlacklistLookupValue = {
type: "domain" | "phone" | "company" | "google_place_id";
type: "domain" | "email" | "phone" | "company" | "google_place_id";
normalizedValue: string;
};
function normalizeDomain(value?: string | null) {
export function normalizeDomain(value?: string | null) {
return value?.trim().toLowerCase().replace(/^www\./, "") ?? "";
}
function normalizePhone(value?: string | null) {
export function normalizePhone(value?: string | null) {
if (!value) {
return "";
}
return value.replace(/\D+/g, "");
const digits = value.replace(/\D+/g, "");
if (digits.startsWith("00")) {
return digits.slice(2);
}
return digits;
}
function uniqueLookupValues(values: BlacklistLookupValue[]) {
@@ -375,6 +553,8 @@ function uniqueLookupValues(values: BlacklistLookupValue[]) {
export function getBlacklistLookupValues(
candidate: GooglePlaceCandidate,
): BlacklistLookupValue[] {
const emailAddresses = getCandidateEmailValues(candidate);
return uniqueLookupValues([
{
type: "google_place_id",
@@ -386,7 +566,7 @@ export function getBlacklistLookupValues(
},
{
type: "company",
normalizedValue: normalizeDomain(candidate.businessName),
normalizedValue: normalizeText(candidate.businessName),
},
{
type: "phone",
@@ -396,6 +576,10 @@ export function getBlacklistLookupValues(
type: "phone",
normalizedValue: normalizeDomain(candidate.phone),
},
...emailAddresses.map((email) => ({
type: "email" as const,
normalizedValue: email ?? "",
})),
]);
}
@@ -405,25 +589,57 @@ export function isDuplicateCandidate(
): boolean {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateEmails = getCandidateEmailValues(candidate);
return existing.some((entry) => {
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
const entryDomain = normalizeDomain(entry.websiteDomain);
const entryEmail = normalizeEmailAddress(entry.email);
return (
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
(candidateDomain && entryDomain === candidateDomain)
(candidateDomain && entryDomain === candidateDomain) ||
candidateEmails.some(
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
)
);
});
}
export function isProbableDuplicateCandidate(
candidate: GooglePlaceCandidate,
existing: ExistingLeadLike[],
): boolean {
const candidateCompany = normalizeText(candidate.businessName);
const candidateAddress = normalizeText(candidate.address);
const candidatePhone = normalizePhone(candidate.phone);
return existing.some((entry) => {
const entryCompany = normalizeText(entry.companyName);
const entryAddress = normalizeText(entry.address);
const entryPhone = normalizePhone(entry.phone);
const isSameCompanyAndAddress =
candidateCompany &&
candidateAddress &&
entryCompany &&
entryAddress &&
candidateCompany === entryCompany &&
candidateAddress === entryAddress;
const isSamePhone = candidatePhone && entryPhone && candidatePhone === entryPhone;
return isSameCompanyAndAddress || isSamePhone;
});
}
export function getBlacklistMatches(
candidate: GooglePlaceCandidate,
blacklistRows: BlacklistRow[],
) {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateCompany = normalizeDomain(candidate.businessName);
const candidateCompany = normalizeText(candidate.businessName);
const candidatePhone = normalizePhone(candidate.phone);
return blacklistRows.filter((row) => {
@@ -446,6 +662,10 @@ export function getBlacklistMatches(
(row.normalizedValue === candidatePhone ||
normalizePhone(row.value) === candidatePhone)
);
case "email":
return getCandidateEmailValues(candidate).some(
(candidateEmail) => candidateEmail === row.normalizedValue,
);
default:
return false;
}

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.",
};
}