Files
pitchfast/lib/lead-discovery-run.ts

295 lines
7.8 KiB
TypeScript

import {
normalizePhone,
normalizeText,
getUsableContactEmail,
type GooglePlaceCandidate,
} from "./lead-discovery-google";
import type { Id } from "../convex/_generated/dataModel";
type AgentRunLike = {
status: string;
updatedAt?: number;
};
type LeadDiscoveryCounterInput = {
leadsFound: number;
leadsCreated: number;
errors: number;
};
type LeadDiscoveryContactInput = {
usableEmail?: string | null;
};
export type LeadDiscoveryContactStatus =
| "new"
| "missing_contact"
| "audit_ready"
| "outreach_ready"
| "contacted"
| "replied"
| "do_not_contact";
type WebsiteEnrichmentScheduleInput = {
websiteUrl?: string | null;
websiteDomain?: string | null;
contactStatus: LeadDiscoveryContactStatus;
};
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> = {
campaignId: TCampaignId;
runId: TRunId;
niche: string;
postalCode: string;
candidate: GooglePlaceCandidate;
now: number;
};
function optionalString(value: string | null | undefined) {
return value && value.trim().length > 0 ? value : undefined;
}
function optionalNumber(value: number | null) {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
export const PENDING_AGENT_RUN_GRACE_MS = 10 * 60 * 1000;
export function isStalePendingAgentRun(run: AgentRunLike, now: number) {
const updatedAt = typeof run.updatedAt === "number" ? run.updatedAt : 0;
return (
run.status === "pending" &&
updatedAt > 0 &&
now - updatedAt > PENDING_AGENT_RUN_GRACE_MS
);
}
export function canStartAgentRun(runs: AgentRunLike[], now = Date.now()) {
return !runs.some((run) => {
if (run.status === "running") {
return true;
}
return run.status === "pending" && !isStalePendingAgentRun(run, now);
});
}
export function buildLeadDiscoveryCounters(input: LeadDiscoveryCounterInput) {
return {
leadsFound: input.leadsFound,
leadsCreated: input.leadsCreated,
auditsCreated: 0,
outreachPrepared: 0,
errors: input.errors,
};
}
export function getLeadDiscoveryContactStatus(
input: LeadDiscoveryContactInput,
) {
if (input.usableEmail) {
return "new";
}
return "missing_contact";
}
export function shouldScheduleWebsiteEnrichment(
input: WebsiteEnrichmentScheduleInput,
) {
const hasWebsiteData =
optionalString(input.websiteUrl) !== undefined ||
optionalString(input.websiteDomain) !== undefined;
return input.contactStatus === "missing_contact" && hasWebsiteData;
}
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;
companyName: string;
niche: string;
address: string;
postalCode: string;
googlePlaceId: string;
googleMapsUrl?: string;
googlePrimaryType?: string;
googleTypes: string[];
googleRating?: number;
googleUserRatingCount?: number;
googleBusinessStatus?: string;
sourceProvider: "google_places" | "local_business_data";
sourceBusinessId?: string;
sourceFetchedAt: number;
websiteUrl?: string;
websiteDomain?: string;
phone?: string;
normalizedGooglePlaceId?: string;
normalizedSourceBusinessId?: 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: LeadDiscoveryDuplicateStatus;
blacklistStatus: "clear";
createdAt: number;
updatedAt: number;
} = {
campaignId: input.campaignId,
discoveryRunId: input.runId,
companyName: input.candidate.businessName,
niche: input.niche,
address: input.candidate.address,
postalCode: input.postalCode,
googlePlaceId: input.candidate.placeId,
googleTypes: input.candidate.googleTypes,
sourceProvider: input.candidate.sourceProvider,
sourceFetchedAt: input.candidate.sourceFetchedAt,
priority: "medium",
contactStatus: getLeadDiscoveryContactStatus({
usableEmail: usableEmail?.email,
}),
duplicateStatus: "unique",
blacklistStatus: "clear",
createdAt: input.now,
updatedAt: input.now,
};
const googleMapsUrl = optionalString(input.candidate.googleMapsUrl);
const googlePrimaryType = optionalString(input.candidate.googlePrimaryType);
const googleRating = optionalNumber(input.candidate.rating);
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
const sourceBusinessId = optionalString(input.candidate.sourceBusinessId);
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;
}
if (googlePrimaryType !== undefined) {
lead.googlePrimaryType = googlePrimaryType;
}
if (googleRating !== undefined) {
lead.googleRating = googleRating;
}
if (googleUserRatingCount !== undefined) {
lead.googleUserRatingCount = googleUserRatingCount;
}
if (googleBusinessStatus !== undefined) {
lead.googleBusinessStatus = googleBusinessStatus;
}
if (sourceBusinessId !== undefined) {
lead.sourceBusinessId = sourceBusinessId;
}
if (websiteUrl !== undefined) {
lead.websiteUrl = websiteUrl;
}
if (websiteDomain !== undefined) {
lead.websiteDomain = websiteDomain;
}
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.",
};
}