feat: add lead qualification workflow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user