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

@@ -4,6 +4,7 @@ import test from "node:test";
import {
RUN_STATUSES,
SCREENSHOT_VIEWPORTS,
LEAD_PRIORITIES,
filterSafeSettingsRows,
isSafeSettingsKey,
normalizeListLimit,
@@ -49,6 +50,10 @@ test("run statuses expose observable job lifecycle states", () => {
]);
});
test("lead priorities include manual blocking option", () => {
assert.deepEqual(LEAD_PRIORITIES, ["high", "medium", "low", "defer", "blocked"]);
});
test("list limits are clamped to a positive integer range", () => {
assert.equal(normalizeListLimit(undefined), 50);
assert.equal(normalizeListLimit(-10), 1);

View File

@@ -2,6 +2,14 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
getLeadBlacklistStatusLabel,
getLeadContactStatusLabel,
getLeadDuplicateStatusLabel,
getLeadPriorityLabel,
leadBlacklistStatusOptions,
leadContactStatusOptions,
leadDuplicateStatusOptions,
leadPriorityOptions,
dashboardKpis,
dashboardNavigation,
groupLeadFunnelCards,
@@ -138,6 +146,49 @@ test("groupLeadFunnelCards derives review, follow-up, and deferred columns witho
);
});
test("toLeadFunnelCard maps blocked priority to deferred stage with blocker label", () => {
const card = toLeadFunnelCard({
id: "lead-blocked",
companyName: "Sperr Beispiel",
city: "Freiburg",
priority: "blocked",
contactStatus: "new",
blacklistStatus: "blocked",
});
assert.equal(card.stageId, "deferred");
assert.equal(card.priorityLabel, "Gesperrt");
assert.equal(card.nextAction, "Zurückstellung prüfen");
});
test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
assert.deepEqual(leadPriorityOptions, [
"high",
"medium",
"low",
"defer",
"blocked",
]);
assert.equal(getLeadPriorityLabel("high"), "Hoch");
assert.equal(getLeadContactStatusLabel("missing_contact"), "Kontakt fehlt");
assert.equal(getLeadBlacklistStatusLabel("blocked"), "Gesperrt");
});
test("dashboard-model exposes duplicate status options and labels", () => {
assert.deepEqual(leadDuplicateStatusOptions, [
"unchecked",
"unique",
"possible_duplicate",
"duplicate",
]);
assert.equal(getLeadDuplicateStatusLabel("duplicate"), "Duplikat");
});
test("dashboard-model exposes contact status options for lead review controls", () => {
assert.equal(leadContactStatusOptions[1], "missing_contact");
assert.equal(leadBlacklistStatusOptions.length, 2);
});
test("dashboardKpis and reviewQueue expose the above-the-fold dashboard summary", () => {
assert.equal(dashboardKpis.length, 4);
assert.equal(reviewQueue.length, 3);

View File

@@ -4,10 +4,14 @@ import test from "node:test";
import {
GOOGLE_PLACES_FIELD_MASK,
buildGeocodingUrl,
getUsableContactEmail,
getUsableContactEmailFromEntries,
getBlacklistMatches,
getBlacklistLookupValues,
getPlacesSearchSpec,
isProbableDuplicateCandidate,
isDuplicateCandidate,
normalizeEmailAddress,
normalizePlacesResponse,
parseGeocodingResponse,
} from "../lib/lead-discovery-google";
@@ -205,8 +209,12 @@ test("places normalization maps source metadata and normalizes website domain",
test("duplicate detection uses placeId and websiteDomain", () => {
const existingLeads = [
{ googlePlaceId: "dup-1", websiteDomain: "other.de" },
{ googlePlaceId: "other-2", websiteDomain: "example.de" },
{
googlePlaceId: "dup-1",
websiteDomain: "other.de",
email: "blocked@example.de",
},
{ googlePlaceId: "other-2", websiteDomain: "example.de", email: "blocked@example.de" },
];
assert.equal(
@@ -277,6 +285,158 @@ test("duplicate detection uses placeId and websiteDomain", () => {
),
false,
);
assert.equal(
isDuplicateCandidate(
{
placeId: "none",
businessName: "Test",
address: "A",
websiteUrl: "https://www.example.de",
websiteDomain: "new.de",
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
contactEmails: [{ email: "Owner@Example.De", isBusinessContactAddress: false }],
},
existingLeads,
),
false,
);
assert.equal(
isDuplicateCandidate(
{
placeId: "none",
businessName: "Test",
address: "A",
websiteUrl: "https://www.new.de",
websiteDomain: "new.de",
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
contactEmails: [{ email: "newlead@new.de" }],
},
existingLeads,
),
false,
);
assert.equal(
isDuplicateCandidate(
{
placeId: "none",
businessName: "Test",
address: "A",
websiteUrl: "https://www.example.de",
websiteDomain: "new.de",
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
email: "Blocked@Example.De",
},
existingLeads,
),
true,
);
});
test("probable duplicates are detected by normalized company+address or normalized phone", () => {
const existingLeads = [
{
googlePlaceId: "dup-1",
companyName: "Muster GmbH",
address: "Hauptstraße 1, 60311 Frankfurt am Main",
phone: "+49 30 123456",
},
];
assert.equal(
isProbableDuplicateCandidate(
{
placeId: "none-1",
businessName: "Muster GmbH",
address: "Hauptstraße 1, 60311 Frankfurt am Main",
websiteUrl: null,
websiteDomain: null,
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
},
existingLeads,
),
true,
);
assert.equal(
isProbableDuplicateCandidate(
{
placeId: "none-2",
businessName: "Other GmbH",
address: "Nebenstraße 9",
websiteUrl: null,
websiteDomain: null,
phone: "0049 30 123456",
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
},
existingLeads,
),
true,
);
assert.equal(
isProbableDuplicateCandidate(
{
placeId: "none-3",
businessName: "Different GmbH",
address: "Musterallee 5",
websiteUrl: null,
websiteDomain: null,
phone: "+49 89 999999",
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
},
existingLeads,
),
false,
);
});
test("blacklist matches include google_place_id, domain, company and phone", () => {
@@ -287,6 +447,8 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
websiteUrl: "https://www.Blocked.de",
websiteDomain: "blocked.de",
phone: "+49 30 555 123",
email: "Info@Blocked.De",
contactEmails: [{ email: "Hello@blocked.de", isBusinessContactAddress: false }],
rating: null,
userRatingCount: null,
businessStatus: null,
@@ -303,6 +465,8 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
{ type: "company", normalizedValue: "muster gmbh" },
{ type: "phone", normalizedValue: "4930555123" },
{ type: "phone", normalizedValue: "+49 30 555 123" },
{ type: "email", normalizedValue: "info@blocked.de" },
{ type: "email", normalizedValue: "hello@blocked.de" },
]);
const matches = getBlacklistMatches(
@@ -323,12 +487,213 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
},
{ type: "email", value: "x@example.de", normalizedValue: "x@example.de" },
{ type: "phone", value: "+49 30 999 999", normalizedValue: "4930999999" },
{
type: "email",
value: "Info@Blocked.De",
normalizedValue: "info@blocked.de",
},
],
);
const matchTypes = matches.map((match) => match.type).sort();
assert.deepEqual(
matchTypes,
["company", "domain", "google_place_id", "phone", "phone"].sort(),
["company", "domain", "google_place_id", "phone", "phone", "email"].sort(),
);
});
test("company normalization for blacklist lookup uses text normalization", () => {
const candidate = {
placeId: "place-company-spaces",
businessName: "Muster GmbH",
address: "A",
websiteUrl: null,
websiteDomain: null,
phone: "+49 30 555 123",
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places" as const,
sourceFetchedAt: 0,
};
assert.deepEqual(getBlacklistLookupValues(candidate), [
{ type: "google_place_id", normalizedValue: "place-company-spaces" },
{ type: "company", normalizedValue: "muster gmbh" },
{ type: "phone", normalizedValue: "4930555123" },
{ type: "phone", normalizedValue: "+49 30 555 123" },
]);
});
test("company blacklist matching supports whitespace-normalized names", () => {
const candidate = {
placeId: "place-company-spaces-2",
businessName: "Muster GmbH",
address: "A",
websiteUrl: null,
websiteDomain: null,
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places" as const,
sourceFetchedAt: 0,
};
const matches = getBlacklistMatches(candidate, [
{ type: "company", value: "Muster GmbH", normalizedValue: "muster gmbh" },
]);
assert.equal(matches.length, 1);
assert.equal(matches[0]!.normalizedValue, "muster gmbh");
});
test("email normalization strips whitespace, lowercases, and rejects malformed addresses", () => {
assert.equal(normalizeEmailAddress(" INFO@Example.DE "), "info@example.de");
assert.equal(normalizeEmailAddress("hello@domain"), null);
assert.equal(normalizeEmailAddress("no-at-symbol"), null);
assert.equal(normalizeEmailAddress("@missing-local.com"), null);
assert.equal(normalizeEmailAddress("name@"), null);
assert.equal(normalizeEmailAddress(""), null);
assert.equal(normalizeEmailAddress("näm@beispiel.de"), null);
});
test("usable email helper prefers generic business aliases and requires explicit metadata for named contacts", () => {
const genericPreferred = getUsableContactEmail({
placeId: "place-1",
businessName: "Bäckerei",
address: "Musterweg 1",
websiteUrl: null,
websiteDomain: null,
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
contactEmails: [
{
email: "müller@bäckerei.de",
isBusinessContactAddress: false,
},
{
email: "Hello@Bäckerei.De",
isBusinessContactAddress: false,
},
{
email: "owner@Bäckerei.De",
isBusinessContactAddress: true,
},
],
});
assert.deepEqual(genericPreferred, {
email: "hello@bäckerei.de",
emailSource: null,
contactPerson: null,
});
const namedWithoutMetadata = getUsableContactEmail({
placeId: "place-2",
businessName: "Bäckerei",
address: "Musterweg 2",
websiteUrl: null,
websiteDomain: null,
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
contactEmails: [
{
email: "owner@Bäckerei.De",
isBusinessContactAddress: false,
},
],
});
assert.equal(namedWithoutMetadata, null);
const namedWithMetadata = getUsableContactEmail({
placeId: "place-3",
businessName: "Bäckerei",
address: "Musterweg 3",
websiteUrl: null,
websiteDomain: null,
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
contactEmails: [
{
email: "owner@Bäckerei.De",
isBusinessContactAddress: true,
},
],
});
assert.deepEqual(namedWithMetadata, {
email: "owner@bäckerei.de",
emailSource: null,
contactPerson: null,
});
});
test("standalone contact-email rule helper rejects invalid entries and prefers generic aliases", () => {
const validGeneric = getUsableContactEmailFromEntries([
{
email: "owner@firma.de",
isBusinessContactAddress: false,
},
{
email: "support@firma.de",
isBusinessContactAddress: false,
},
{
email: "hello@firma.de",
isBusinessContactAddress: false,
},
]);
assert.deepEqual(validGeneric, {
email: "hello@firma.de",
emailSource: null,
contactPerson: null,
});
const rejectedNamed = getUsableContactEmailFromEntries([
{
email: "owner@firma.de",
isBusinessContactAddress: false,
},
]);
assert.equal(rejectedNamed, null);
const invalid = getUsableContactEmailFromEntries([
{
email: "no-at-symbol",
isBusinessContactAddress: true,
},
]);
assert.equal(invalid, null);
});

View File

@@ -7,6 +7,7 @@ import {
canStartAgentRun,
isStalePendingAgentRun,
getLeadDiscoveryContactStatus,
getLeadDiscoveryPriority,
} from "../lib/lead-discovery-run";
test("agent run guard ignores stale pending runs but blocks active runs", () => {
@@ -62,19 +63,51 @@ test("lead discovery counters preserve audit and outreach counters", () => {
test("lead discovery contact status separates leads without any contact route", () => {
assert.equal(
getLeadDiscoveryContactStatus({ websiteDomain: null, phone: null }),
getLeadDiscoveryContactStatus({ usableEmail: null }),
"missing_contact",
);
assert.equal(
getLeadDiscoveryContactStatus({ websiteDomain: "example.de", phone: null }),
getLeadDiscoveryContactStatus({ usableEmail: "info@example.de" }),
"new",
);
assert.equal(
getLeadDiscoveryContactStatus({ websiteDomain: null, phone: "030 123" }),
"new",
getLeadDiscoveryContactStatus({ usableEmail: null }),
"missing_contact",
);
});
test("lead discovery lead record marks contact missing when no usable email exists", () => {
const record = buildLeadDiscoveryLeadRecord({
campaignId: "campaign-1",
runId: "run-1",
niche: "Restaurant",
postalCode: "10115",
now: 1717480000000,
candidate: {
placeId: "place-2",
businessName: "Kontaktlos GmbH",
address: "Hauptstraße 2",
websiteUrl: "https://www.beispiel.de",
websiteDomain: "example.de",
phone: "+49 30 123",
rating: 3.9,
userRatingCount: 9,
businessStatus: "OPERATIONAL",
googleTypes: ["consulting"],
googlePrimaryType: "consulting",
googleMapsUrl: "https://maps.google.com/place-2",
sourceProvider: "google_places",
sourceFetchedAt: 1717480001000,
contactEmails: [{ email: "Herr.Bewerber@Beispiel.de", isBusinessContactAddress: false }],
},
});
assert.equal(record.contactStatus, "missing_contact");
assert.equal(record.phone, "+49 30 123");
assert.equal(record.websiteDomain, "example.de");
assert.equal(record.email, undefined);
});
test("lead discovery lead record keeps raw website url and normalized domain", () => {
const record = buildLeadDiscoveryLeadRecord({
campaignId: "campaign-1",
@@ -106,3 +139,113 @@ test("lead discovery lead record keeps raw website url and normalized domain", (
assert.equal(record.googleUserRatingCount, 12);
assert.equal(record.sourceFetchedAt, 1717480001000);
});
test("lead discovery lead record stores valid email and sets contactStatus to new", () => {
const record = buildLeadDiscoveryLeadRecord({
campaignId: "campaign-1",
runId: "run-1",
niche: "Restaurant",
postalCode: "10115",
now: 1717480000000,
candidate: {
placeId: "place-3",
businessName: "Beispiel GmbH",
address: "Hauptstraße 1",
websiteUrl: "https://www.example.de/path",
websiteDomain: "example.de",
phone: "+49 30 123",
rating: 4.5,
userRatingCount: 12,
businessStatus: "OPERATIONAL",
googleTypes: ["restaurant"],
googlePrimaryType: "restaurant",
googleMapsUrl: "https://maps.google.com/place-3",
sourceProvider: "google_places",
sourceFetchedAt: 1717480001000,
contactEmails: [
{
email: "Herr@Beispiel.de",
isBusinessContactAddress: false,
},
{
email: "info@beispiel.de",
isBusinessContactAddress: false,
},
],
},
});
assert.equal(record.contactStatus, "new");
assert.equal(record.email, "info@beispiel.de");
assert.equal(record.contactPerson, undefined);
});
test("lead discovery lead record stores normalized matching fields", () => {
const record = buildLeadDiscoveryLeadRecord({
campaignId: "campaign-1",
runId: "run-1",
niche: "Restaurant",
postalCode: "10115",
now: 1717480000000,
candidate: {
placeId: "place-4",
businessName: "Muster GmbH",
address: "Hauptstraße 1 60311 Berlin",
websiteUrl: "https://www.example.de/",
websiteDomain: "Example.de",
phone: "+49 30 123 456",
rating: 4.5,
userRatingCount: 12,
businessStatus: "OPERATIONAL",
googleTypes: ["restaurant"],
googlePrimaryType: "restaurant",
googleMapsUrl: "https://maps.google.com/place-4",
sourceProvider: "google_places",
sourceFetchedAt: 1717480001000,
email: "Info@Example.de",
contactEmails: [
{
email: "Info@Example.de",
isBusinessContactAddress: false,
},
],
},
});
assert.equal(record.normalizedEmail, "info@example.de");
assert.equal(record.normalizedPhone, "4930123456");
assert.equal(record.normalizedCompanyName, "muster gmbh");
assert.equal(record.normalizedAddress, "hauptstraße 1 60311 berlin");
});
test("lead discovery priority helper classifies blocked, deferred, and low-potential leads", () => {
assert.deepEqual(getLeadDiscoveryPriority({ isBlacklisted: true }), {
priority: "blocked",
reason: "Lead ist auf der Sperrliste.",
});
assert.deepEqual(getLeadDiscoveryPriority({ isDuplicate: true }), {
priority: "defer",
reason: "Dublettenprüfung oder Reviewpause.",
});
assert.deepEqual(getLeadDiscoveryPriority({ hasWebsite: false }), {
priority: "high",
reason: "Kein Website-Indikator vorhanden.",
});
assert.deepEqual(getLeadDiscoveryPriority({ hasWebsite: true, hasWebsiteSignal: true }), {
priority: "low",
reason: "Website vorhanden: geringer Kontaktaufwand.",
});
assert.deepEqual(getLeadDiscoveryPriority({ hasWebsite: true, hasWebsiteSignal: false }), {
priority: "medium",
reason: "Standardpriorität.",
});
assert.deepEqual(getLeadDiscoveryPriority({ hasWebsite: true }), {
priority: "medium",
reason: "Standardpriorität.",
});
});