264 lines
6.9 KiB
TypeScript
264 lines
6.9 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 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) {
|
|
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 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";
|
|
sourceFetchedAt: number;
|
|
websiteUrl?: string;
|
|
websiteDomain?: string;
|
|
phone?: string;
|
|
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: 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 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 (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.",
|
|
};
|
|
}
|