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

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