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

@@ -1,10 +1,5 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { BlacklistManager } from "@/components/blacklist/blacklist-manager";
export default function BlacklistPage() { export default function BlacklistPage() {
return ( return <BlacklistManager />;
<DashboardPlaceholderPage
description="Sperrlisten für Domains, E-Mails, Telefonnummern, Firmennamen und Place IDs folgen nach den Datenmodellen."
title="Sperrliste"
/>
);
} }

View File

@@ -1,10 +1,5 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { LeadsReviewTable } from "@/components/leads/leads-review-table";
export default function LeadsPage() { export default function LeadsPage() {
return ( return <LeadsReviewTable />;
<DashboardPlaceholderPage
description="Lead-Qualifikation, Dubletten und fehlende Kontaktdaten folgen in TASK-7."
title="Leads"
/>
);
} }

View File

@@ -0,0 +1,48 @@
---
id: TASK-20
title: Implement TASK-7 slice 3 dashboard UI
status: In Progress
assignee: []
created_date: '2026-06-04 13:54'
updated_date: '2026-06-04 13:58'
labels: []
dependencies: []
priority: high
ordinal: 22000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Build dashboard leads review page and blacklist management UI for lead qualification and blacklist controls.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Replace dashboard leads placeholder with inline lead review and review mutations
- [x] #2 Replace dashboard blacklist placeholder with blacklist create/edit/list/delete UI
- [ ] #3 Use shadcn-style dashboard components and keep TypeScript compile clean
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Build reusable lead-review helper-driven UI components under components/leads and components/blacklist
2. Replace dashboard placeholder pages for leads and blacklist
3. Extend dashboard-model label helpers where needed
4. Add/adjust dashboard-model tests for new helper mappings
5. Run lint/tests and report results
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
1) Built lead-review model helpers and added dashboard-model tests
2) Replaced dashboard/leads and dashboard/blacklist placeholders with component-backed UI
3) Added lead review table controls for priority/contact, notes, duplicate/blacklist handling, and review email fields
4) Added blacklist manager with create/list/edit/delete and backend blocking note in UI
Validation completed: pnpm -s exec tsc -p tsconfig.json --noEmit + pnpm -s test pass; targeted eslint on changed files pass; full `pnpm -s lint` currently fails on pre-existing blacklist.ts any-typed fields from prior task work
<!-- SECTION:NOTES:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-7 id: TASK-7
title: 'Add lead qualification, deduplication, and blacklist handling' title: 'Add lead qualification, deduplication, and blacklist handling'
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:13' created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 14:09'
labels: labels:
- mvp - mvp
- leads - leads
@@ -24,19 +25,57 @@ Implement the rules that turn raw business discoveries into usable lead states.
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Leads with no usable email are placed in Kontakt fehlt while preserving phone and source data - [x] #1 Leads with no usable email are placed in Kontakt fehlt while preserving phone and source data
- [ ] #2 Generic business emails are preferred and named emails are accepted only when explicitly found as business contact addresses - [x] #2 Generic business emails are preferred and named emails are accepted only when explicitly found as business contact addresses
- [ ] #3 Hard duplicates are detected by domain, Google Place ID, or email; probable duplicates are flagged by name plus address or phone - [x] #3 Hard duplicates are detected by domain, Google Place ID, or email; probable duplicates are flagged by name plus address or phone
- [ ] #4 Manual blacklist entries for domain, email, phone, company name, and Place ID are enforced during discovery and review - [x] #4 Manual blacklist entries for domain, email, phone, company name, and Place ID are enforced during discovery and review
- [ ] #5 Priority values Hoch, Mittel, Niedrig, Zurückstellen, and Gesperrt are assigned or editable with clear reasons - [x] #5 Priority values Hoch, Mittel, Niedrig, Zurückstellen, and Gesperrt are assigned or editable with clear reasons
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Add blacklist CRUD in Convex and dashboard UI. Subagent-driven TDD execution plan
2. Implement email/contact extraction result fields and Kontakt fehlt transitions.
3. Add hard and probable duplicate matching rules. Orchestrator responsibilities:
4. Add priority assignment rules based on website/contact signals. 1. Coordinate TASK-7 implementation end to end.
5. Surface reasons and source data in lead detail and run logs. 2. Use gpt-5.3-codex-spark subagents for implementation and review slices.
3. Enforce TDD: write failing tests first, verify red, implement minimal production code, verify green, then refactor.
4. Keep Backlog notes current and do not mark Done until user confirms manual testing.
Implementation slices:
1. Rules/backend qualification: add tests and implementation for email usability, generic vs named email handling, hard duplicates by domain/place/email, probable duplicates by company+address or company+phone, blacklist normalization, and priority/status reason derivation.
2. Convex integration: extend schema/types/indexes and lead/blacklist APIs for qualification, editable priority/status/reasons, blacklist CRUD, and discovery/review enforcement.
3. Dashboard UI: replace Leads and Sperrliste placeholders with scan-friendly review tools that expose source data, duplicate/blacklist reasons, and editable priority/status controls.
4. Funnel/model polish: map blocked priority to Gesperrt and keep deferred/review funnel behavior coherent.
5. Verification: run targeted tests during each TDD slice, then pnpm test and pnpm lint at the end.
Acceptance criteria mapping:
- AC1: contact qualification stores leads without usable email as Kontakt fehlt while preserving phone/source metadata.
- AC2: email rules prefer generic business addresses and only allow named emails when explicitly sourced as business contact addresses.
- AC3: duplicate rules distinguish hard duplicates and probable duplicates.
- AC4: blacklist entries for domain/email/phone/company/place ID apply during discovery and review.
- AC5: Hoch, Mittel, Niedrig, Zurückstellen, and Gesperrt are assignable/editable with clear reasons.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Execution started with subagent-driven TDD orchestration using gpt-5.3-codex-spark as requested.
Aufgabe 7: implementiere Google-Places-Email-Review-Regeln, Sperrlisten-Enforcement für bestehende Leads, und korrigiere Firmen-Normalisierung in Blacklist-Matching. Beginne mit neuen TDD-Tests in lib/lead-discovery-google + Convex-Review-Pfad.
TASK-7 implemented: added review-based email contact patch in convex/leads.ts, bounded blacklist enforcement on create/update in convex/blacklist.ts, company normalization fix in getBlacklistLookupValues/getBlacklistMatches, and schema support for new lead matching fields/reasons/blocked priority. Tests: pnpm -s test ✅ and pnpm -s tsc ✅.
Progress: implementing code-quality fixes in convex/blacklist.ts, convex/leads.ts, convex/leadDiscovery.ts; running requested test/type/lint commands after changes. Plan: tighten mutation patch typing, bound blacklist propagation, split website signal, and avoid empty normalized writes.
Executed requested TASK-7 backend quality fixes in scoped files and validated with pnpm -s test, pnpm -s tsc, and targeted eslint. Outstanding follow-up: keep an eye on very large blacklist match sets; enforcement currently remains batch-at-a-time by design.
TASK-7 implementation verified by orchestrator. Added lead qualification helpers and Convex integration for usable email handling, hard/probable duplicate detection, blacklist enforcement with scheduled backfill/apply batches, blocked priority/reason fields, and dashboard Leads/Sperrliste review UI. Verified: pnpm -s test (67 pass), pnpm -s tsc (exit 0), pnpm -s lint (0 errors, 2 generated Better Auth warnings). Browser plugin could not open localhost due ERR_BLOCKED_BY_CLIENT; route HEAD checks redirect to /login as expected for protected dashboard pages.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented lead qualification, duplicate handling, blacklist enforcement, blocked priority/reason support, and dashboard review surfaces. Verified acceptance criteria #1-#5 with tests/typecheck/lint; user confirmed TASK-7 is done.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -4,6 +4,7 @@ title: Implement Playwright website crawling and screenshot capture
status: To Do status: To Do
assignee: [] assignee: []
created_date: '2026-06-03 19:13' created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 14:08'
labels: labels:
- mvp - mvp
- audit - audit
@@ -19,24 +20,37 @@ ordinal: 8000
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Build the website inspection layer using Playwright. For qualified leads, the system should load the company website, inspect the homepage and a small set of relevant subpages, capture desktop/mobile screenshots, extract visible text and contact signals, and store all raw evidence in Convex. Build the website inspection and contact-enrichment layer using Playwright. For qualified leads, the system should load the company website, inspect the homepage and a small set of relevant subpages, capture desktop/mobile screenshots, extract visible text and contact signals, store all raw evidence in Convex, and feed found email candidates back into the TASK-7 qualification rules before a lead remains in Kontakt fehlt. Google Places does not provide business email fields, so website crawl evidence is the primary MVP source for usable business email addresses.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Playwright captures desktop and mobile screenshots for the homepage and stores them in Convex File Storage - [ ] #1 Playwright captures desktop and mobile screenshots for the homepage and stores them in Convex File Storage
- [ ] #2 Crawler visits a bounded set of relevant subpages: Kontakt, Impressum, Leistungen/Angebot, Über uns/Team when discoverable - [ ] #2 Crawler visits a bounded set of relevant subpages: Kontakt, Impressum, Leistungen/Angebot, Über uns/Team when discoverable
- [ ] #3 Crawler extracts visible text, page title, meta description, headings, links, phone numbers, email candidates, and CTA/contact-form signals - [ ] #3 Crawler extracts visible text, page title, meta description, headings, links, phone numbers, email candidates, email source URLs, contact-person context, and CTA/contact-form signals
- [ ] #4 Simple technical checks include HTTPS/final URL, missing title/meta description, visible contact path, and obvious broken internal links within the crawl limit - [ ] #4 Extracted email candidates are classified through the TASK-7 rules: generic business emails are preferred; named emails are accepted only when explicitly published as business contact addresses; no guessed addresses are generated
- [ ] #5 Crawler failures produce useful dashboard-visible errors without blocking unrelated leads - [ ] #5 Leads discovered by Google Places with a website are automatically scheduled for contact enrichment before they remain in Kontakt fehlt; found usable email updates the lead contact fields and status while preserving phone and source data
- [ ] #6 Simple technical checks include HTTPS/final URL, missing title/meta description, visible contact path, and obvious broken internal links within the crawl limit
- [ ] #7 Crawler failures produce useful dashboard-visible errors without blocking unrelated leads
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Add Playwright runtime setup compatible with local development and Coolify container deployment. 1. Add Playwright runtime setup compatible with local development and Coolify container deployment.
2. Define crawl limits, viewports, timeout behavior, and allowed same-domain URL rules. 2. Define crawl limits, viewports, timeout behavior, and allowed same-domain URL rules.
3. Capture homepage desktop/mobile screenshots and upload to Convex storage. 3. Capture homepage desktop/mobile screenshots and upload them to Convex storage.
4. Discover and inspect relevant subpages with bounded depth. 4. Discover and inspect relevant subpages with bounded depth.
5. Persist extracted text, metadata, contact candidates, technical checks, screenshots, and errors. 5. Extract visible text, metadata, links, phone numbers, email candidates, contact-person context, CTA/contact-form signals, and source URLs.
6. Normalize and score email candidates, then call the existing TASK-7 lead review/contact qualification path so usable emails update lead contact fields and unqualified named emails do not.
7. Add contact-enrichment run state and dashboard-visible run events/errors for leads that still need manual contact research.
8. Persist extracted raw evidence, technical checks, screenshots, and crawler errors in Convex.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Expanded TASK-8 to cover website-based contact enrichment because Google Places does not provide business email fields. This keeps email handling evidence-based and reuses TASK-7 qualification rules instead of guessing addresses.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,376 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Id } from "@/convex/_generated/dataModel";
import { api } from "@/convex/_generated/api";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type BlacklistResult = FunctionReturnType<typeof api.blacklist.list>;
type BlacklistEntry = NonNullable<BlacklistResult>[number];
type BlacklistType =
| "domain"
| "email"
| "phone"
| "company"
| "google_place_id";
const blacklistTypeOptions: BlacklistType[] = [
"domain",
"email",
"phone",
"company",
"google_place_id",
];
function labelForType(type: BlacklistType): string {
if (type === "google_place_id") {
return "Google Place ID";
}
return type.charAt(0).toUpperCase() + type.slice(1);
}
function formatDate(value: number): string {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(value));
}
export function BlacklistManager() {
const entries = useQuery(api.blacklist.list, { limit: 150 }) as
| BlacklistResult
| undefined;
const createEntry = useMutation(api.blacklist.create);
const updateEntry = useMutation(api.blacklist.update);
const removeEntry = useMutation(api.blacklist.remove);
const [type, setType] = useState<BlacklistType>("domain");
const [value, setValue] = useState("");
const [note, setNote] = useState("");
const [rowBusyId, setRowBusyId] = useState<Id<"blacklistEntries"> | null>(null);
const [formBusy, setFormBusy] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const entriesSorted = useMemo(() => {
if (!entries) {
return [];
}
return [...entries].sort((a, b) => b.createdAt - a.createdAt);
}, [entries]);
const submitNew = async () => {
if (!value.trim()) {
setStatusError("Bitte ein Sperrwert eintragen.");
return;
}
setFormBusy(true);
setStatusError(null);
setStatusMessage(null);
try {
await createEntry({
type,
value: value.trim(),
note: note.trim().length > 0 ? note.trim() : undefined,
});
setValue("");
setNote("");
setStatusMessage("Eintrag hinzugefügt.");
} catch {
setStatusError("Eintrag konnte nicht erstellt werden.");
} finally {
setFormBusy(false);
}
};
const remove = async (id: Id<"blacklistEntries">) => {
setRowBusyId(id);
setStatusError(null);
setStatusMessage(null);
try {
await removeEntry({ id });
setStatusMessage("Eintrag gelöscht.");
} catch {
setStatusError("Eintrag konnte nicht entfernt werden.");
} finally {
setRowBusyId(null);
}
};
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
<p className="text-sm text-muted-foreground">Blacklist-Verwaltung</p>
<h1 className="text-2xl font-semibold tracking-normal">Sperrliste</h1>
</div>
<div className="mx-auto w-full max-w-7xl">
<Card className="p-4 space-y-4">
<h2 className="text-sm font-medium">Neuen Eintrag anlegen</h2>
<p className="text-sm text-muted-foreground">
Neue Einträge wirken sofort: bestehende und neue Leads mit passendem
Typ werden automatisch blockiert.
</p>
<div className="grid gap-3 sm:grid-cols-[150px_1fr_1fr_auto]">
<Select
value={type}
onValueChange={(nextType) => setType(nextType as BlacklistType)}
>
<SelectTrigger>
<SelectValue placeholder="Typ" />
</SelectTrigger>
<SelectContent>
{blacklistTypeOptions.map((item) => (
<SelectItem value={item} key={item}>
{labelForType(item)}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Wert"
/>
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="Notiz (optional)"
/>
<Button
onClick={submitNew}
disabled={formBusy || !value.trim()}
className="justify-start sm:w-auto"
>
Eintrag speichern
</Button>
</div>
{statusError ? (
<p className="text-sm text-destructive" role="status">
{statusError}
</p>
) : null}
{statusMessage ? (
<p className="text-sm text-muted-foreground" role="status">
{statusMessage}
</p>
) : null}
</Card>
</div>
<div className="mx-auto w-full max-w-7xl">
<Card>
<div className="overflow-x-auto">
<div className="min-w-[880px]">
<table className="w-full border-separate border-spacing-0 text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="p-3 font-normal">Typ</th>
<th className="p-3 font-normal">Wert</th>
<th className="p-3 font-normal">Notiz</th>
<th className="p-3 font-normal">Normalisiert</th>
<th className="p-3 font-normal">Erstellt</th>
<th className="p-3 font-normal">Aktion</th>
</tr>
</thead>
{entries === undefined ? (
<tbody>
<tr>
<td className="p-3" colSpan={6}>
<p className="rounded-md bg-muted p-4 text-sm">
Sperrliste wird geladen
</p>
</td>
</tr>
</tbody>
) : entriesSorted.length === 0 ? (
<tbody>
<tr>
<td className="p-3" colSpan={6}>
<p className="rounded-md border p-4 text-sm text-muted-foreground">
Noch keine Sperreinträge.
</p>
</td>
</tr>
</tbody>
) : (
<tbody>
{entriesSorted.map((entry) => (
<BlacklistEntryRow
key={entry._id}
entry={entry}
onDelete={remove}
onUpdate={async (nextEntry) => {
setRowBusyId(nextEntry.id);
setStatusError(null);
setStatusMessage(null);
try {
await updateEntry(nextEntry);
setStatusMessage("Eintrag aktualisiert.");
} catch {
setStatusError("Eintrag konnte nicht gespeichert werden.");
} finally {
setRowBusyId(null);
}
}}
isBusy={rowBusyId === entry._id}
/>
))}
</tbody>
)}
</table>
</div>
</div>
</Card>
</div>
</section>
);
}
function BlacklistEntryRow({
entry,
onDelete,
onUpdate,
isBusy,
}: {
entry: BlacklistEntry;
onDelete: (id: Id<"blacklistEntries">) => Promise<void>;
onUpdate: (next: {
id: Id<"blacklistEntries">;
type?: BlacklistType;
value?: string;
note?: string;
}) => Promise<void>;
isBusy: boolean;
}) {
const [isEditing, setIsEditing] = useState(false);
const [type, setType] = useState<BlacklistType>(entry.type);
const [value, setValue] = useState(entry.value);
const [note, setNote] = useState(entry.note ?? "");
const [rowMessage, setRowMessage] = useState<string | null>(null);
const submitUpdate = async () => {
if (!value.trim()) {
setRowMessage("Wert darf nicht leer sein.");
return;
}
setRowMessage(null);
await onUpdate({
id: entry._id,
type,
value: value.trim(),
note: note.trim().length > 0 ? note.trim() : undefined,
});
setIsEditing(false);
setRowMessage("Gespeichert");
};
return (
<tr className="border-t">
<td className="p-3 align-top">
{isEditing ? (
<Select value={type} onValueChange={(nextType) => setType(nextType as BlacklistType)}>
<SelectTrigger className="max-w-[168px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{blacklistTypeOptions.map((item) => (
<SelectItem value={item} key={item}>
{labelForType(item)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Badge variant="secondary">{labelForType(entry.type)}</Badge>
)}
</td>
<td className="max-w-[260px] p-3 align-top">
{isEditing ? (
<Input value={value} onChange={(event) => setValue(event.target.value)} />
) : (
<p className="truncate">{entry.value}</p>
)}
</td>
<td className="max-w-[300px] p-3 align-top">
{isEditing ? (
<Input value={note} onChange={(event) => setNote(event.target.value)} />
) : (
<p className="truncate text-muted-foreground">
{entry.note ?? "—"}
</p>
)}
</td>
<td className="p-3 align-top">
<p className="truncate">{entry.normalizedValue}</p>
</td>
<td className="p-3 align-top">
<p className="text-muted-foreground">{formatDate(entry.createdAt)}</p>
</td>
<td className="p-3 align-top">
<div className="grid gap-2 sm:grid-cols-2">
{isEditing ? (
<>
<Button size="sm" onClick={submitUpdate} disabled={isBusy}>
Speichern
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(false)}
disabled={isBusy}
>
Abbrechen
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => setIsEditing(true)}
disabled={isBusy}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(entry._id)}
disabled={isBusy}
>
Löschen
</Button>
</>
)}
</div>
{rowMessage ? (
<p className="mt-2 text-xs text-muted-foreground">{rowMessage}</p>
) : null}
</td>
</tr>
);
}

View File

@@ -0,0 +1,576 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Building2, Mail, MapPin, Phone, ShieldAlert } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
import {
getLeadBlacklistStatusLabel,
getLeadContactStatusLabel,
getLeadDuplicateStatusLabel,
getLeadPriorityLabel,
leadBlacklistStatusOptions,
leadContactStatusOptions,
leadDuplicateStatusOptions,
leadPriorityOptions,
type LeadContactStatus,
type LeadDuplicateStatus,
type LeadPriority,
type LeadBlacklistStatus,
} from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
type LeadRow = NonNullable<LeadsListResult>[number];
type LeadReviewDraft = {
priority: LeadPriority;
contactStatus: LeadContactStatus;
priorityReason: string;
contactStatusReason: string;
notes: string;
reviewEmail: string;
reviewEmailSource: string;
reviewContactPerson: string;
reviewIsBusinessContactAddress: boolean;
duplicateStatus: LeadDuplicateStatus;
blacklistStatus: LeadBlacklistStatus;
};
type LeadReviewPayload = {
id: Id<"leads">;
priority?: LeadPriority;
priorityReason?: string;
contactStatus?: LeadContactStatus;
contactStatusReason?: string;
notes?: string;
duplicateStatus?: LeadDuplicateStatus;
duplicateReason?: string;
blacklistStatus?: LeadBlacklistStatus;
blacklistReason?: string;
duplicateOfLeadId?: Id<"leads">;
applyBlacklist?: boolean;
reviewEmail?: string;
reviewEmailSource?: string;
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
function normalizeTextInput(value: string): string | undefined {
const next = value.trim();
return next.length > 0 ? next : undefined;
}
function contactSourceLabel(lead: LeadRow): string {
if (lead.sourceProvider) {
return lead.sourceProvider;
}
if (lead.emailSource) {
return lead.emailSource;
}
return "Unbekannt";
}
function formatLocation(lead: LeadRow): string {
if (lead.postalCode && lead.city) {
return `${lead.postalCode} ${lead.city}`;
}
if (lead.city || lead.address) {
return lead.city ?? lead.address ?? "";
}
return lead.address ?? "Ort offen";
}
function priorityBadgeClass(priority: LeadPriority): string {
switch (priority) {
case "high":
return "text-destructive border-destructive/30 bg-destructive/15";
case "medium":
return "text-muted-foreground border-muted-foreground/30 bg-muted/20";
case "low":
return "text-muted-foreground border-muted/40 bg-muted/35";
case "defer":
return "text-muted-foreground border-secondary/50 bg-secondary/30";
case "blocked":
return "text-destructive border-destructive/40 bg-destructive/15";
default:
return "text-muted-foreground border-muted bg-muted/20";
}
}
function duplicateBadgeVariant(
duplicateStatus: LeadDuplicateStatus,
): "secondary" | "default" | "outline" | "destructive" {
if (duplicateStatus === "duplicate") {
return "destructive";
}
if (duplicateStatus === "possible_duplicate") {
return "outline";
}
if (duplicateStatus === "unique") {
return "secondary";
}
return "outline";
}
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
const sortedLeads = useMemo(() => {
if (!leads) {
return [];
}
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
<p className="text-sm text-muted-foreground">Leads Review</p>
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div>
<div className="mx-auto w-full max-w-7xl">
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<div className="min-w-[1150px]">
<table className="w-full border-separate border-spacing-0 text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="p-3 font-normal">Firma / Ort</th>
<th className="p-3 font-normal">Kontakt + Quelle</th>
<th className="p-3 font-normal">Priorität</th>
<th className="p-3 font-normal">Kontaktstatus</th>
<th className="p-3 font-normal">Qualität</th>
<th className="p-3 font-normal">Review-Felder</th>
<th className="p-3 font-normal">Aktionen</th>
</tr>
</thead>
{leads === undefined ? (
<tbody>
<tr>
<td className="p-3" colSpan={7}>
<p className="rounded-md bg-muted p-4 text-sm">
Leads werden geladen
</p>
</td>
</tr>
</tbody>
) : sortedLeads.length === 0 ? (
<tbody>
<tr>
<td className="p-3" colSpan={7}>
<p className="rounded-md border p-4 text-sm text-muted-foreground">
Keine Leads vorhanden. Bitte zuerst eine Kampagne starten
oder importieren.
</p>
</td>
</tr>
</tbody>
) : (
<tbody>
{sortedLeads.map((lead) => (
<LeadReviewRow
key={lead._id}
lead={lead}
onActionMessage={setActionMessage}
/>
))}
</tbody>
)}
</table>
</div>
</div>
</Card>
</div>
{actionMessage ? (
<p className="mx-auto max-w-7xl text-sm text-muted-foreground">
{actionMessage}
</p>
) : null}
</section>
);
}
function LeadReviewRow({
lead,
onActionMessage,
}: {
lead: LeadRow;
onActionMessage: (value: string) => void;
}) {
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority,
contactStatus: lead.contactStatus,
priorityReason: lead.priorityReason ?? "",
contactStatusReason: lead.contactStatusReason ?? "",
notes: lead.notes ?? "",
reviewEmail: lead.email ?? "",
reviewEmailSource: lead.emailSource ?? "",
reviewContactPerson: lead.contactPerson ?? "",
reviewIsBusinessContactAddress: false,
duplicateStatus: (lead.duplicateStatus as LeadDuplicateStatus) ?? "unchecked",
blacklistStatus: lead.blacklistStatus,
}));
const [isSaving, setIsSaving] = useState(false);
const [isBlocking, setIsBlocking] = useState(false);
const [rowMessage, setRowMessage] = useState<string | null>(null);
const reviewUpdate = useMutation(api.leads.reviewUpdate);
const location = formatLocation(lead);
const reasonParts = [
lead.priorityReason,
lead.contactStatusReason,
lead.duplicateReason,
lead.blacklistReason,
].filter((item): item is string => Boolean(item));
const update = async (
payload?: Omit<LeadReviewPayload, "id">,
) => {
setIsSaving(true);
setRowMessage(null);
onActionMessage("");
try {
await reviewUpdate({ id: lead._id, ...payload } as LeadReviewPayload);
setRowMessage("Gespeichert");
onActionMessage("Aktualisierung übernommen");
} catch {
setRowMessage("Speichern fehlgeschlagen");
} finally {
setIsSaving(false);
setTimeout(() => setRowMessage(null), 1400);
}
};
const saveRow = async () => {
const reviewEmail = normalizeTextInput(draft.reviewEmail);
const reviewEmailSource = normalizeTextInput(draft.reviewEmailSource);
const reviewContactPerson = draft.reviewContactPerson.trim();
const shouldUpdateEmailReview =
reviewEmail !== normalizeTextInput(lead.email ?? "") ||
reviewEmailSource !== normalizeTextInput(lead.emailSource ?? "") ||
reviewContactPerson !== normalizeTextInput(lead.contactPerson ?? "");
if (shouldUpdateEmailReview && !reviewEmail && !lead.email) {
setRowMessage("Review-E-Mail setzen, um Kontaktinfos zu ändern.");
return;
}
const payload = {
id: lead._id,
priority: draft.priority,
priorityReason: draft.priorityReason,
contactStatus: draft.contactStatus,
contactStatusReason: draft.contactStatusReason,
notes: draft.notes,
duplicateStatus: draft.duplicateStatus,
duplicateReason: lead.duplicateReason,
blacklistStatus: draft.blacklistStatus,
blacklistReason: lead.blacklistReason,
reviewIsBusinessContactAddress: draft.reviewIsBusinessContactAddress,
...(shouldUpdateEmailReview ? {
reviewEmail: reviewEmail ?? lead.email,
reviewEmailSource: reviewEmailSource ?? lead.emailSource,
reviewContactPerson,
} : {}),
};
await update(payload);
};
const blockLead = async () => {
setIsBlocking(true);
await update({ applyBlacklist: true });
setIsBlocking(false);
};
const updateDraft = <T extends keyof LeadReviewDraft>(
field: T,
value: LeadReviewDraft[T],
) => {
setDraft((current) => ({ ...current, [field]: value }));
};
return (
<tr className="border-t">
<td className="max-w-[260px] p-3 align-top">
<p className="font-medium">{lead.companyName}</p>
<p className="mt-1 inline-flex items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
<span className="truncate">{lead.niche ?? "Nische offen"}</span>
</p>
<p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span>{location}</span>
</p>
{lead.address ? (
<p className="mt-1 max-w-full truncate text-xs text-muted-foreground">
{lead.address}
</p>
) : null}
</td>
<td className="max-w-[260px] p-3 align-top">
<p className="inline-flex w-full items-start gap-1 text-sm">
<Mail className="mt-0.5 size-3 shrink-0" />
<span className="min-w-0 break-all">
{lead.email || "Keine E-Mail"}
</span>
</p>
{lead.phone ? (
<p className="mt-2 inline-flex w-full items-start gap-1 text-xs text-muted-foreground">
<Phone className="size-3 shrink-0" />
<span className="break-all">{lead.phone}</span>
</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground">
Quelle: {contactSourceLabel(lead)}
</p>
{lead.websiteDomain ? (
<p className="mt-1 text-xs text-muted-foreground">
Domain: {lead.websiteDomain}
</p>
) : null}
</td>
<td className="p-3 align-top">
<p
className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
draft.priority,
)}`}
>
{getLeadPriorityLabel(draft.priority)}
</p>
<div className="mt-2 max-w-[160px]">
<Select
value={draft.priority}
onValueChange={(nextPriority) =>
updateDraft("priority", nextPriority as LeadPriority)
}
>
<SelectTrigger>
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
{leadPriorityOptions.map((value) => (
<SelectItem value={value} key={value}>
{getLeadPriorityLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</td>
<td className="p-3 align-top">
<Badge variant="outline">
{getLeadContactStatusLabel(draft.contactStatus)}
</Badge>
<div className="mt-2 max-w-[180px]">
<Select
value={draft.contactStatus}
onValueChange={(nextStatus) =>
updateDraft("contactStatus", nextStatus as LeadContactStatus)
}
>
<SelectTrigger>
<SelectValue placeholder="Kontaktstatus" />
</SelectTrigger>
<SelectContent>
{leadContactStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadContactStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</td>
<td className="max-w-[220px] p-3 align-top">
<div className="grid gap-2">
<p className="text-xs text-muted-foreground">Prioritätsgrund</p>
<Input
value={draft.priorityReason}
onChange={(event) => {
updateDraft("priorityReason", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Kontaktstatus-Notiz</p>
<Input
value={draft.contactStatusReason}
onChange={(event) => {
updateDraft("contactStatusReason", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Notiz</p>
<Input
value={draft.notes}
onChange={(event) => {
updateDraft("notes", event.target.value);
}}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge
variant={duplicateBadgeVariant(draft.duplicateStatus)}
title={lead.duplicateReason ?? undefined}
>
{getLeadDuplicateStatusLabel(draft.duplicateStatus)}
</Badge>
<Badge
variant={lead.blacklistStatus === "blocked" ? "destructive" : "secondary"}
>
{getLeadBlacklistStatusLabel(lead.blacklistStatus)}
</Badge>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{reasonParts.length === 0 ? (
<p>Keine Zusatzhinweise</p>
) : (
reasonParts.map((reason) => <p key={reason}> {reason}</p>)
)}
</div>
</td>
<td className="min-w-[260px] p-3 align-top">
<div className="grid gap-2">
<p className="text-xs text-muted-foreground">Review-E-Mail</p>
<Input
value={draft.reviewEmail}
onChange={(event) => {
updateDraft("reviewEmail", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Review-Quelle</p>
<Input
value={draft.reviewEmailSource}
onChange={(event) => {
updateDraft("reviewEmailSource", event.target.value);
}}
/>
</div>
<div className="mt-2 grid gap-2">
<p className="text-xs text-muted-foreground">Ansprechperson</p>
<Input
value={draft.reviewContactPerson}
onChange={(event) => {
updateDraft("reviewContactPerson", event.target.value);
}}
/>
</div>
<label className="mt-3 inline-flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => {
updateDraft("reviewIsBusinessContactAddress", checked);
}}
/>
Genannte E-Mail als Business-Kontakt
</label>
<div className="mt-3 grid gap-2">
<p className="text-xs text-muted-foreground">Duplikatstatus</p>
<Select
value={draft.duplicateStatus}
onValueChange={(nextStatus) =>
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
}
>
<SelectTrigger>
<SelectValue placeholder="Duplikatstatus" />
</SelectTrigger>
<SelectContent>
{leadDuplicateStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadDuplicateStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-2">
<label className="text-xs text-muted-foreground">Sperrstatus</label>
<Select
value={draft.blacklistStatus}
onValueChange={(nextStatus) =>
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
}
>
<SelectTrigger>
<SelectValue placeholder="Sperrstatus" />
</SelectTrigger>
<SelectContent>
{leadBlacklistStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadBlacklistStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</td>
<td className="max-w-[170px] p-3 align-top">
<div className="grid gap-2">
<Button
onClick={saveRow}
disabled={isSaving || isBlocking}
size="sm"
>
<span>Speichern</span>
</Button>
<Button
variant="destructive"
onClick={blockLead}
disabled={isSaving || isBlocking}
size="sm"
>
<ShieldAlert className="size-4" />
Sperren
</Button>
</div>
{rowMessage ? (
<p className="mt-2 text-xs text-muted-foreground">{rowMessage}</p>
) : null}
</td>
</tr>
);
}

View File

@@ -1,7 +1,15 @@
import { v } from "convex/values"; import { v } from "convex/values";
import {
normalizeDomain,
normalizeEmailAddress,
normalizePhone,
normalizeText,
} from "../lib/lead-discovery-google";
import { normalizeListLimit } from "./domain"; import { normalizeListLimit } from "./domain";
import { mutation, query } from "./_generated/server"; import { internal } from "./_generated/api";
import type { Doc } from "./_generated/dataModel";
import { internalMutation, mutation, query, type MutationCtx } from "./_generated/server";
const blacklistType = v.union( const blacklistType = v.union(
v.literal("domain"), v.literal("domain"),
@@ -11,8 +19,193 @@ const blacklistType = v.union(
v.literal("google_place_id"), v.literal("google_place_id"),
); );
function normalizeBlacklistValue(value: string) { type BlacklistType =
return value.trim().toLowerCase(); | "domain"
| "email"
| "phone"
| "company"
| "google_place_id";
const BLACKLIST_APPLY_BATCH_SIZE = 100;
const BLACKLIST_REVIEW_NOTE_PREFIX =
"Lead automatisch durch Sperrlisteneintrag blockiert.";
type BlacklistReason = {
type: BlacklistType;
normalizedValue: string;
reason: string;
};
type LeadIdAndBlacklistPatch = Pick<
Doc<"leads">,
"blacklistStatus" | "priority" | "contactStatus" | "blacklistReason" | "priorityReason" | "contactStatusReason"
> & {
updatedAt: number;
};
type LeadMatchingFieldsPatch = Partial<
Pick<
Doc<"leads">,
| "normalizedEmail"
| "normalizedPhone"
| "normalizedCompanyName"
| "normalizedAddress"
| "normalizedGooglePlaceId"
>
> & {
updatedAt: number;
};
type LeadIdRow = Pick<Doc<"leads">, "_id">;
type LeadMatchQuery = {
order: (direction: "asc" | "desc") => {
paginate: (args: {
numItems: number;
cursor: string | null;
}) => Promise<{
page: LeadIdRow[];
isDone: boolean;
continueCursor: string | null;
}>;
};
};
function buildBlacklistReason(entry: { type: BlacklistType; value: string; note?: string }) {
const normalizedNote = entry.note?.trim();
return normalizedNote
? `${BLACKLIST_REVIEW_NOTE_PREFIX} ${entry.type}: ${entry.value}. ${normalizedNote}`
: `${BLACKLIST_REVIEW_NOTE_PREFIX} ${entry.type}: ${entry.value}.`;
}
function buildReasonPatch(reason: string) {
const patch: LeadIdAndBlacklistPatch = {
blacklistStatus: "blocked" as const,
priority: "blocked" as const,
contactStatus: "do_not_contact" as const,
blacklistReason: reason,
priorityReason: reason,
contactStatusReason: reason,
updatedAt: Date.now(),
};
return patch;
}
function getLeadMatchQuery(
ctx: MutationCtx,
type: BlacklistType,
normalizedValue: string,
): (() => LeadMatchQuery) | null {
if (!normalizedValue) {
return null;
}
switch (type) {
case "domain":
return () =>
ctx.db
.query("leads")
.withIndex("by_websiteDomain", (q) =>
q.eq("websiteDomain", normalizedValue),
);
case "email":
return () =>
ctx.db
.query("leads")
.withIndex("by_normalizedEmail", (q) =>
q.eq("normalizedEmail", normalizedValue),
);
case "phone":
return () =>
ctx.db
.query("leads")
.withIndex("by_normalizedPhone", (q) =>
q.eq("normalizedPhone", normalizedValue),
);
case "company":
return () =>
ctx.db
.query("leads")
.withIndex("by_normalizedCompanyName", (q) =>
q.eq("normalizedCompanyName", normalizedValue),
);
case "google_place_id":
return () =>
ctx.db
.query("leads")
.withIndex("by_normalizedGooglePlaceId", (q) =>
q.eq("normalizedGooglePlaceId", normalizedValue),
);
default:
return null;
}
}
function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
const patch: LeadMatchingFieldsPatch = {
updatedAt: Date.now(),
};
const normalizedEmail = normalizeEmailAddress(lead.email);
const normalizedPhone = normalizePhone(lead.phone);
const normalizedCompanyName = normalizeText(lead.companyName);
const normalizedAddress = normalizeText(lead.address);
const normalizedGooglePlaceId = normalizeDomain(lead.googlePlaceId);
if (!lead.normalizedEmail && normalizedEmail) {
patch.normalizedEmail = normalizedEmail;
}
if (!lead.normalizedPhone && normalizedPhone) {
patch.normalizedPhone = normalizedPhone;
}
if (!lead.normalizedCompanyName && normalizedCompanyName) {
patch.normalizedCompanyName = normalizedCompanyName;
}
if (!lead.normalizedAddress && normalizedAddress) {
patch.normalizedAddress = normalizedAddress;
}
if (!lead.normalizedGooglePlaceId && normalizedGooglePlaceId) {
patch.normalizedGooglePlaceId = normalizedGooglePlaceId;
}
return Object.keys(patch).length > 1 ? patch : null;
}
async function scheduleBackfillThenBlacklistApply(
ctx: MutationCtx,
reason: BlacklistReason,
) {
await ctx.scheduler.runAfter(
0,
internal.blacklist.backfillLeadMatchingFieldsForBlacklist,
{
...reason,
cursor: null,
},
);
}
function normalizeBlacklistValue(type: BlacklistType, value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
switch (type) {
case "email":
return normalizeEmailAddress(trimmed);
case "phone":
return normalizePhone(trimmed);
case "domain":
case "google_place_id":
return normalizeDomain(trimmed);
case "company":
return normalizeText(trimmed);
default:
return null;
}
} }
export const create = mutation({ export const create = mutation({
@@ -22,11 +215,238 @@ export const create = mutation({
note: v.optional(v.string()), note: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
return await ctx.db.insert("blacklistEntries", { const type = args.type as BlacklistType;
...args, const normalizedValue = normalizeBlacklistValue(type, args.value);
normalizedValue: normalizeBlacklistValue(args.value),
if (!normalizedValue) {
throw new Error("Blacklist-Wert ist ungültig.");
}
const existing = await ctx.db
.query("blacklistEntries")
.withIndex("by_type_and_normalizedValue", (q) =>
q.eq("type", type).eq("normalizedValue", normalizedValue),
)
.take(1);
if (existing[0]) {
await scheduleBackfillThenBlacklistApply(ctx, {
type,
normalizedValue,
reason: buildBlacklistReason({
type,
value: existing[0].value,
note: existing[0].note,
}),
});
return existing[0]._id;
}
const created = await ctx.db.insert("blacklistEntries", {
type,
value: args.value.trim(),
normalizedValue,
note: args.note,
createdAt: Date.now(), createdAt: Date.now(),
}); });
await scheduleBackfillThenBlacklistApply(ctx, {
type,
normalizedValue,
reason: buildBlacklistReason({
type,
value: args.value.trim(),
note: args.note,
}),
});
return created;
},
});
export const update = mutation({
args: {
id: v.id("blacklistEntries"),
type: v.optional(blacklistType),
value: v.optional(v.string()),
note: v.optional(v.string()),
},
handler: async (ctx, args) => {
const current = await ctx.db.get(args.id);
if (!current) {
throw new Error("Blacklist-Eintrag nicht gefunden.");
}
const nextType = (args.type ?? current.type) as BlacklistType;
const patch: {
type: BlacklistType;
value?: string;
normalizedValue?: string;
note?: string;
} = {
type: nextType,
};
const nextNormalizedValueFromCurrent = normalizeBlacklistValue(
nextType,
current.value,
);
if (!nextNormalizedValueFromCurrent) {
throw new Error("Blacklist-Wert ist ungültig.");
}
let nextValue = current.value;
let nextNormalizedValue = nextNormalizedValueFromCurrent;
if (args.value !== undefined) {
const value = args.value.trim();
const normalizedValue = normalizeBlacklistValue(nextType, value);
if (!normalizedValue) {
throw new Error("Blacklist-Wert ist ungültig.");
}
const existing = await ctx.db
.query("blacklistEntries")
.withIndex("by_type_and_normalizedValue", (q) =>
q.eq("type", nextType).eq("normalizedValue", normalizedValue),
)
.take(1);
if (existing[0] && existing[0]._id !== args.id) {
return existing[0]._id;
}
patch.value = value;
patch.normalizedValue = normalizedValue;
nextValue = value;
nextNormalizedValue = normalizedValue;
}
if (args.note !== undefined) {
patch.note = args.note;
}
await ctx.db.patch(args.id, patch);
await scheduleBackfillThenBlacklistApply(ctx, {
type: nextType,
normalizedValue: nextNormalizedValue,
reason: buildBlacklistReason({
type: nextType,
value: nextValue,
note: patch.note ?? args.note ?? current.note,
}),
});
return args.id;
},
});
export const backfillLeadMatchingFieldsForBlacklist = internalMutation({
args: {
type: blacklistType,
normalizedValue: v.string(),
reason: v.string(),
cursor: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
const page = await ctx.db
.query("leads")
.order("asc")
.paginate({
numItems: BLACKLIST_APPLY_BATCH_SIZE,
cursor: args.cursor,
});
for (const lead of page.page) {
const patch = buildLeadMatchingFieldsPatch(lead);
if (patch) {
await ctx.db.patch(lead._id, patch);
}
}
if (!page.isDone) {
await ctx.scheduler.runAfter(
0,
internal.blacklist.backfillLeadMatchingFieldsForBlacklist,
{
type: args.type,
normalizedValue: args.normalizedValue,
reason: args.reason,
cursor: page.continueCursor,
},
);
return null;
}
await ctx.scheduler.runAfter(
0,
internal.blacklist.applyBlacklistToMatchingLeadsBatch,
{
type: args.type,
normalizedValue: args.normalizedValue,
reason: args.reason,
cursor: null,
},
);
return null;
},
});
export const applyBlacklistToMatchingLeadsBatch = internalMutation({
args: {
type: blacklistType,
normalizedValue: v.string(),
reason: v.string(),
cursor: v.union(v.string(), v.null()),
},
handler: async (ctx, args) => {
const queryBuilder = getLeadMatchQuery(
ctx,
args.type as BlacklistType,
args.normalizedValue,
);
if (!queryBuilder) {
return null;
}
const page = await queryBuilder()
.order("asc")
.paginate({
numItems: BLACKLIST_APPLY_BATCH_SIZE,
cursor: args.cursor,
});
const patch = buildReasonPatch(args.reason);
for (const lead of page.page) {
await ctx.db.patch(lead._id, patch);
}
if (!page.isDone) {
await ctx.scheduler.runAfter(
0,
internal.blacklist.applyBlacklistToMatchingLeadsBatch,
{
type: args.type,
normalizedValue: args.normalizedValue,
reason: args.reason,
cursor: page.continueCursor,
},
);
}
return null;
},
});
export const remove = mutation({
args: { id: v.id("blacklistEntries") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return args.id;
}, },
}); });

View File

@@ -12,7 +12,7 @@ const SECRET_KEY_PATTERNS = [
]; ];
export const CAMPAIGN_STATUSES = ["active", "paused"] as const; export const CAMPAIGN_STATUSES = ["active", "paused"] as const;
export const LEAD_PRIORITIES = ["high", "medium", "low", "defer"] as const; export const LEAD_PRIORITIES = ["high", "medium", "low", "defer", "blocked"] as const;
export const LEAD_CONTACT_STATUSES = [ export const LEAD_CONTACT_STATUSES = [
"new", "new",
"missing_contact", "missing_contact",

View File

@@ -5,13 +5,18 @@ import {
buildGeocodingUrl, buildGeocodingUrl,
getBlacklistLookupValues, getBlacklistLookupValues,
getBlacklistMatches, getBlacklistMatches,
getCandidateEmailValues,
getPlacesSearchSpec, getPlacesSearchSpec,
normalizeDomain,
normalizePhone,
normalizeText,
normalizePlacesResponse, normalizePlacesResponse,
parseGeocodingResponse, parseGeocodingResponse,
} from "../lib/lead-discovery-google"; } from "../lib/lead-discovery-google";
import { import {
buildLeadDiscoveryLeadRecord, buildLeadDiscoveryLeadRecord,
buildLeadDiscoveryCounters, buildLeadDiscoveryCounters,
getLeadDiscoveryPriority,
} from "../lib/lead-discovery-run"; } from "../lib/lead-discovery-run";
import { calculateNextRunAt } from "../lib/campaign-scheduling"; import { calculateNextRunAt } from "../lib/campaign-scheduling";
@@ -37,6 +42,20 @@ const candidateValidator = v.object({
googleTypes: v.array(v.string()), googleTypes: v.array(v.string()),
googlePrimaryType: nullableString, googlePrimaryType: nullableString,
googleMapsUrl: nullableString, googleMapsUrl: nullableString,
email: v.optional(nullableString),
emailSource: v.optional(nullableString),
contactPerson: v.optional(nullableString),
isBusinessContactAddress: v.optional(v.boolean()),
contactEmails: v.optional(
v.array(
v.object({
email: v.string(),
emailSource: v.optional(nullableString),
contactPerson: v.optional(nullableString),
isBusinessContactAddress: v.optional(v.boolean()),
}),
),
),
sourceProvider: v.literal("google_places"), sourceProvider: v.literal("google_places"),
sourceFetchedAt: v.number(), sourceFetchedAt: v.number(),
}); });
@@ -396,23 +415,43 @@ export const persistDiscoveredLeads = internalMutation({
continue; continue;
} }
const existingByPlaceId = await ctx.db const normalizedPlaceId = normalizeDomain(candidate.placeId);
.query("leads") const normalizedDomain = normalizeDomain(candidate.websiteDomain);
.withIndex("by_googlePlaceId", (q) => const normalizedEmails = getCandidateEmailValues(candidate);
q.eq("googlePlaceId", candidate.placeId), const normalizedPhone = normalizePhone(candidate.phone);
) const normalizedCompanyName = normalizeText(candidate.businessName);
.take(1); const normalizedAddress = normalizeText(candidate.address);
const candidateDomain = candidate.websiteDomain;
const existingByDomain = candidateDomain const duplicateByPlaceId = normalizedPlaceId
? await ctx.db ? await ctx.db
.query("leads") .query("leads")
.withIndex("by_websiteDomain", (q) => .withIndex("by_normalizedGooglePlaceId", (q) =>
q.eq("websiteDomain", candidateDomain), q.eq("normalizedGooglePlaceId", normalizedPlaceId),
) )
.take(1) .take(1)
: []; : [];
if (existingByPlaceId.length > 0 || existingByDomain.length > 0) { const duplicateByDomain = normalizedDomain
? await ctx.db
.query("leads")
.withIndex("by_websiteDomain", (q) => q.eq("websiteDomain", normalizedDomain))
.take(1)
: [];
const duplicateByEmailRows = [];
for (const email of normalizedEmails) {
const rows = await ctx.db
.query("leads")
.withIndex("by_normalizedEmail", (q) => q.eq("normalizedEmail", email))
.take(1);
duplicateByEmailRows.push(...rows);
}
if (
duplicateByPlaceId.length > 0 ||
duplicateByDomain.length > 0 ||
duplicateByEmailRows.length > 0
) {
skippedDuplicates += 1; skippedDuplicates += 1;
await ctx.db.insert("agentRunEvents", { await ctx.db.insert("agentRunEvents", {
runId: args.runId, runId: args.runId,
@@ -427,6 +466,29 @@ export const persistDiscoveredLeads = internalMutation({
continue; continue;
} }
const probableDuplicateByPhone = normalizedPhone
? await ctx.db
.query("leads")
.withIndex("by_normalizedPhone", (q) =>
q.eq("normalizedPhone", normalizedPhone),
)
.take(1)
: [];
const probableDuplicateByAddress = normalizedCompanyName && normalizedAddress
? await ctx.db
.query("leads")
.withIndex("by_normalizedCompanyName_and_normalizedAddress", (q) =>
q
.eq("normalizedCompanyName", normalizedCompanyName)
.eq("normalizedAddress", normalizedAddress),
)
.take(1)
: [];
const probableDuplicateLead =
probableDuplicateByPhone[0] ?? probableDuplicateByAddress[0] ?? null;
const blacklistRows = []; const blacklistRows = [];
for (const lookup of getBlacklistLookupValues(candidate)) { for (const lookup of getBlacklistLookupValues(candidate)) {
const rows = await ctx.db const rows = await ctx.db
@@ -465,6 +527,34 @@ export const persistDiscoveredLeads = internalMutation({
candidate, candidate,
now, now,
}); });
const hasWebsite = Boolean(candidate.websiteUrl ?? candidate.websiteDomain);
const priorityResult = getLeadDiscoveryPriority({
isDuplicate: !!probableDuplicateLead,
hasWebsite,
hasWebsiteSignal: false, // plain Google-Places website hint maps to medium priority.
});
const isDuplicateCandidate = !!probableDuplicateLead;
if (normalizedPlaceId) {
lead.normalizedGooglePlaceId = normalizedPlaceId;
}
if (normalizedPhone !== "") {
lead.normalizedPhone = normalizedPhone;
}
if (normalizedCompanyName !== "") {
lead.normalizedCompanyName = normalizedCompanyName;
}
if (normalizedAddress !== "") {
lead.normalizedAddress = normalizedAddress;
}
lead.priority = priorityResult.priority;
lead.priorityReason = priorityResult.reason;
if (isDuplicateCandidate) {
lead.duplicateStatus = "possible_duplicate";
lead.duplicateReason = `Möglicher Dublettenkandidat zu Lead ${probableDuplicateLead._id}`;
lead.duplicateOfLeadId = probableDuplicateLead._id;
}
await ctx.db.insert("leads", lead); await ctx.db.insert("leads", lead);
leadsCreated += 1; leadsCreated += 1;

View File

@@ -1,8 +1,93 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
import { normalizeListLimit } from "./domain"; import { normalizeListLimit } from "./domain";
import type { Doc, Id } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
type LeadDoc = Doc<"leads">;
type LeadReviewContactPatch = {
email: string;
normalizedEmail: string;
emailSource?: string;
contactPerson?: string;
};
type BuildReviewContactPatchResult = {
patch?: LeadReviewContactPatch;
setContactStatus?: LeadDoc["contactStatus"];
};
type LeadReviewPatch = {
updatedAt: number;
priority?: LeadDoc["priority"];
priorityReason?: string;
contactStatus?: LeadDoc["contactStatus"];
contactStatusReason?: string;
notes?: string;
duplicateStatus?: LeadDoc["duplicateStatus"];
duplicateReason?: string;
duplicateOfLeadId?: Id<"leads">;
blacklistStatus?: LeadDoc["blacklistStatus"];
blacklistReason?: string;
email?: string;
normalizedEmail?: string;
emailSource?: string;
contactPerson?: string;
};
function buildReviewContactPatch(args: {
email?: string;
emailSource?: string;
contactPerson?: string;
isBusinessContactAddress?: boolean;
explicitContactStatus?: boolean;
currentContactStatus?: "new" | "missing_contact" | "audit_ready" | "outreach_ready" | "contacted" | "replied" | "do_not_contact";
}): BuildReviewContactPatchResult | null {
if (args.email === undefined) {
return null;
}
const usable = getUsableContactEmailFromEntries([
{
email: args.email,
emailSource: args.emailSource,
contactPerson: args.contactPerson,
isBusinessContactAddress: args.isBusinessContactAddress,
},
]);
if (!usable) {
return {
setContactStatus: "missing_contact",
};
}
const patch: LeadReviewContactPatch = {
email: usable.email,
normalizedEmail: usable.email,
};
if (usable.emailSource !== null) {
patch.emailSource = usable.emailSource;
}
if (usable.contactPerson !== null) {
patch.contactPerson = usable.contactPerson;
}
const setContactStatus =
!args.explicitContactStatus && args.currentContactStatus === "missing_contact"
? "new"
: undefined;
return ({
patch,
setContactStatus,
});
}
export const create = mutation({ export const create = mutation({
args: { args: {
campaignId: v.optional(v.id("campaigns")), campaignId: v.optional(v.id("campaigns")),
@@ -24,6 +109,10 @@ export const create = mutation({
websiteUrl: v.optional(v.string()), websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()), websiteDomain: v.optional(v.string()),
phone: v.optional(v.string()), phone: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
normalizedCompanyName: v.optional(v.string()),
normalizedAddress: v.optional(v.string()),
email: v.optional(v.string()), email: v.optional(v.string()),
emailSource: v.optional(v.string()), emailSource: v.optional(v.string()),
contactPerson: v.optional(v.string()), contactPerson: v.optional(v.string()),
@@ -33,8 +122,10 @@ export const create = mutation({
v.literal("medium"), v.literal("medium"),
v.literal("low"), v.literal("low"),
v.literal("defer"), v.literal("defer"),
v.literal("blocked"),
), ),
), ),
priorityReason: v.optional(v.string()),
contactStatus: v.optional( contactStatus: v.optional(
v.union( v.union(
v.literal("new"), v.literal("new"),
@@ -46,6 +137,20 @@ export const create = mutation({
v.literal("do_not_contact"), v.literal("do_not_contact"),
), ),
), ),
contactStatusReason: v.optional(v.string()),
duplicateStatus: v.optional(
v.union(
v.literal("unchecked"),
v.literal("unique"),
v.literal("possible_duplicate"),
v.literal("duplicate"),
),
),
duplicateReason: v.optional(v.string()),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
normalizedGooglePlaceId: v.optional(v.string()),
notes: v.optional(v.string()), notes: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -53,16 +158,151 @@ export const create = mutation({
return await ctx.db.insert("leads", { return await ctx.db.insert("leads", {
...args, ...args,
normalizedEmail: args.normalizedEmail,
normalizedPhone: args.normalizedPhone,
normalizedCompanyName: args.normalizedCompanyName,
normalizedAddress: args.normalizedAddress,
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
priority: args.priority ?? "medium", priority: args.priority ?? "medium",
contactStatus: args.contactStatus ?? "new", contactStatus: args.contactStatus ?? "new",
duplicateStatus: "unchecked", duplicateStatus: args.duplicateStatus ?? "unchecked",
blacklistStatus: "clear", blacklistStatus: args.blacklistStatus ?? "clear",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
}, },
}); });
export const reviewUpdate = mutation({
args: {
id: v.id("leads"),
priority: v.optional(
v.union(
v.literal("high"),
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
),
),
priorityReason: v.optional(v.string()),
contactStatus: v.optional(
v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
),
contactStatusReason: v.optional(v.string()),
notes: v.optional(v.string()),
duplicateStatus: v.optional(
v.union(
v.literal("unchecked"),
v.literal("unique"),
v.literal("possible_duplicate"),
v.literal("duplicate"),
),
),
duplicateReason: v.optional(v.string()),
blacklistStatus: v.optional(v.union(v.literal("clear"), v.literal("blocked"))),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
applyBlacklist: v.optional(v.boolean()),
reviewEmail: v.optional(v.string()),
reviewEmailSource: v.optional(v.string()),
reviewContactPerson: v.optional(v.string()),
reviewIsBusinessContactAddress: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const lead = await ctx.db.get(args.id);
if (!lead) {
return null;
}
const now = Date.now();
const patch: LeadReviewPatch = {
updatedAt: now,
};
if (args.priority !== undefined) {
patch.priority = args.priority;
}
if (args.priorityReason !== undefined) {
patch.priorityReason = args.priorityReason;
}
if (args.contactStatus !== undefined) {
patch.contactStatus = args.contactStatus;
}
if (args.contactStatusReason !== undefined) {
patch.contactStatusReason = args.contactStatusReason;
}
if (args.notes !== undefined) {
patch.notes = args.notes;
}
if (args.duplicateStatus !== undefined) {
patch.duplicateStatus = args.duplicateStatus;
}
if (args.duplicateReason !== undefined) {
patch.duplicateReason = args.duplicateReason;
}
if (args.duplicateOfLeadId !== undefined) {
patch.duplicateOfLeadId = args.duplicateOfLeadId;
}
if (args.applyBlacklist) {
patch.blacklistStatus = "blocked";
if (args.blacklistReason !== undefined) {
patch.blacklistReason = args.blacklistReason;
} else if (lead.blacklistReason === undefined) {
patch.blacklistReason = "Manuell in der Review als Sperrgrund gesetzt.";
}
if (args.priority === undefined || args.priority !== "blocked") {
patch.priority = "blocked";
}
} else if (args.applyBlacklist === false && args.blacklistStatus !== undefined) {
patch.blacklistStatus = args.blacklistStatus;
patch.blacklistReason = args.blacklistReason;
} else if (args.blacklistStatus !== undefined) {
patch.blacklistStatus = args.blacklistStatus;
patch.blacklistReason = args.blacklistReason;
}
const reviewContactPatch = buildReviewContactPatch({
email: args.reviewEmail,
emailSource: args.reviewEmailSource,
contactPerson: args.reviewContactPerson,
isBusinessContactAddress: args.reviewIsBusinessContactAddress,
explicitContactStatus: args.contactStatus !== undefined,
currentContactStatus: lead.contactStatus,
});
if (reviewContactPatch?.patch) {
Object.assign(patch, reviewContactPatch.patch);
}
if (
reviewContactPatch !== null &&
reviewContactPatch.setContactStatus !== undefined &&
args.contactStatus === undefined
) {
patch.contactStatus = reviewContactPatch.setContactStatus;
}
if (args.blacklistReason !== undefined && patch.blacklistStatus === undefined) {
patch.blacklistStatus = "blocked";
patch.blacklistReason = args.blacklistReason;
}
await ctx.db.patch(args.id, patch);
return args.id;
},
});
export const get = query({ export const get = query({
args: { id: v.id("leads") }, args: { id: v.id("leads") },
handler: async (ctx, args) => { handler: async (ctx, args) => {

View File

@@ -8,6 +8,7 @@ const leadPriority = v.union(
v.literal("medium"), v.literal("medium"),
v.literal("low"), v.literal("low"),
v.literal("defer"), v.literal("defer"),
v.literal("blocked"),
); );
const leadContactStatus = v.union( const leadContactStatus = v.union(
v.literal("new"), v.literal("new"),
@@ -158,6 +159,7 @@ export default defineSchema({
city: v.optional(v.string()), city: v.optional(v.string()),
postalCode: v.optional(v.string()), postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()), googlePlaceId: v.optional(v.string()),
normalizedGooglePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()), googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()), googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())), googleTypes: v.optional(v.array(v.string())),
@@ -169,9 +171,18 @@ export default defineSchema({
websiteUrl: v.optional(v.string()), websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()), websiteDomain: v.optional(v.string()),
phone: v.optional(v.string()), phone: v.optional(v.string()),
normalizedEmail: v.optional(v.string()),
normalizedPhone: v.optional(v.string()),
normalizedCompanyName: v.optional(v.string()),
normalizedAddress: v.optional(v.string()),
email: v.optional(v.string()), email: v.optional(v.string()),
emailSource: v.optional(v.string()), emailSource: v.optional(v.string()),
contactPerson: v.optional(v.string()), contactPerson: v.optional(v.string()),
priorityReason: v.optional(v.string()),
contactStatusReason: v.optional(v.string()),
duplicateReason: v.optional(v.string()),
blacklistReason: v.optional(v.string()),
duplicateOfLeadId: v.optional(v.id("leads")),
priority: leadPriority, priority: leadPriority,
contactStatus: leadContactStatus, contactStatus: leadContactStatus,
duplicateStatus: leadDuplicateStatus, duplicateStatus: leadDuplicateStatus,
@@ -183,8 +194,16 @@ export default defineSchema({
.index("by_campaignId", ["campaignId"]) .index("by_campaignId", ["campaignId"])
.index("by_discoveryRunId", ["discoveryRunId"]) .index("by_discoveryRunId", ["discoveryRunId"])
.index("by_contactStatus", ["contactStatus"]) .index("by_contactStatus", ["contactStatus"])
.index("by_normalizedEmail", ["normalizedEmail"])
.index("by_normalizedPhone", ["normalizedPhone"])
.index("by_normalizedCompanyName_and_normalizedAddress", [
"normalizedCompanyName",
"normalizedAddress",
])
.index("by_normalizedGooglePlaceId", ["normalizedGooglePlaceId"])
.index("by_googlePlaceId", ["googlePlaceId"]) .index("by_googlePlaceId", ["googlePlaceId"])
.index("by_websiteDomain", ["websiteDomain"]) .index("by_websiteDomain", ["websiteDomain"])
.index("by_normalizedCompanyName", ["normalizedCompanyName"])
.index("by_priority_and_contactStatus", ["priority", "contactStatus"]), .index("by_priority_and_contactStatus", ["priority", "contactStatus"]),
audits: defineTable({ audits: defineTable({

View File

@@ -29,7 +29,7 @@ export type ReviewQueueItem = {
detail: string; detail: string;
}; };
export type LeadPriority = "high" | "medium" | "low" | "defer"; export type LeadPriority = "high" | "medium" | "low" | "defer" | "blocked";
export type LeadContactStatus = export type LeadContactStatus =
| "new" | "new"
@@ -41,6 +41,11 @@ export type LeadContactStatus =
| "do_not_contact"; | "do_not_contact";
export type LeadBlacklistStatus = "clear" | "blocked"; export type LeadBlacklistStatus = "clear" | "blocked";
export type LeadDuplicateStatus =
| "unchecked"
| "unique"
| "possible_duplicate"
| "duplicate";
export type OutreachApprovalStatus = "draft" | "approved" | "rejected"; export type OutreachApprovalStatus = "draft" | "approved" | "rejected";
export type OutreachSendStatus = "not_sent" | "queued" | "sent" | "failed"; export type OutreachSendStatus = "not_sent" | "queued" | "sent" | "failed";
@@ -151,14 +156,15 @@ export const leadFunnelStages: LeadFunnelStage[] = [
}, },
]; ];
const priorityLabels: Record<LeadPriority, string> = { export const leadPriorityLabels: Record<LeadPriority, string> = {
high: "Hoch", high: "Hoch",
medium: "Mittel", medium: "Mittel",
low: "Niedrig", low: "Niedrig",
defer: "Zurückstellen", defer: "Zurückstellen",
blocked: "Gesperrt",
}; };
const contactStatusLabels: Record<LeadContactStatus, string> = { export const leadContactStatusLabels: Record<LeadContactStatus, string> = {
new: "Neu", new: "Neu",
missing_contact: "Kontakt fehlt", missing_contact: "Kontakt fehlt",
audit_ready: "Audit bereit", audit_ready: "Audit bereit",
@@ -168,6 +174,61 @@ const contactStatusLabels: Record<LeadContactStatus, string> = {
do_not_contact: "Nicht kontaktieren", do_not_contact: "Nicht kontaktieren",
}; };
export const leadDuplicateStatusLabels: Record<LeadDuplicateStatus, string> = {
unchecked: "Noch nicht geprüft",
unique: "Einzigartig",
possible_duplicate: "Möglicher Doppelter",
duplicate: "Duplikat",
};
export const leadBlacklistStatusLabels: Record<LeadBlacklistStatus, string> = {
clear: "Offen",
blocked: "Gesperrt",
};
export const leadPriorityOptions: LeadPriority[] = [
"high",
"medium",
"low",
"defer",
"blocked",
];
export const leadContactStatusOptions: LeadContactStatus[] = [
"new",
"missing_contact",
"audit_ready",
"outreach_ready",
"contacted",
"replied",
"do_not_contact",
];
export const leadDuplicateStatusOptions: LeadDuplicateStatus[] = [
"unchecked",
"unique",
"possible_duplicate",
"duplicate",
];
export const leadBlacklistStatusOptions: LeadBlacklistStatus[] = ["clear", "blocked"];
export function getLeadPriorityLabel(priority: LeadPriority): string {
return leadPriorityLabels[priority];
}
export function getLeadContactStatusLabel(status: LeadContactStatus): string {
return leadContactStatusLabels[status];
}
export function getLeadDuplicateStatusLabel(status: LeadDuplicateStatus): string {
return leadDuplicateStatusLabels[status];
}
export function getLeadBlacklistStatusLabel(status: LeadBlacklistStatus): string {
return leadBlacklistStatusLabels[status];
}
export function toLeadFunnelCard(lead: LeadFunnelInput): LeadFunnelCard { export function toLeadFunnelCard(lead: LeadFunnelInput): LeadFunnelCard {
return { return {
id: lead.id, id: lead.id,
@@ -175,8 +236,8 @@ export function toLeadFunnelCard(lead: LeadFunnelInput): LeadFunnelCard {
company: lead.companyName, company: lead.companyName,
niche: lead.niche ?? "Nische offen", niche: lead.niche ?? "Nische offen",
location: formatLeadLocation(lead), location: formatLeadLocation(lead),
priorityLabel: priorityLabels[lead.priority], priorityLabel: getLeadPriorityLabel(lead.priority),
contactStatusLabel: contactStatusLabels[lead.contactStatus], contactStatusLabel: getLeadContactStatusLabel(lead.contactStatus),
nextAction: getLeadNextAction(lead), nextAction: getLeadNextAction(lead),
websiteDomain: lead.websiteDomain, websiteDomain: lead.websiteDomain,
contactDetail: formatContactDetail(lead), contactDetail: formatContactDetail(lead),
@@ -198,6 +259,7 @@ function getLeadFunnelStageId(lead: LeadFunnelInput): LeadFunnelStageId {
if ( if (
lead.blacklistStatus === "blocked" || lead.blacklistStatus === "blocked" ||
lead.priority === "defer" || lead.priority === "defer" ||
lead.priority === "blocked" ||
lead.contactStatus === "do_not_contact" lead.contactStatus === "do_not_contact"
) { ) {
return "deferred"; return "deferred";

View File

@@ -228,6 +228,13 @@ type GooglePlaceDisplayName =
text?: string; text?: string;
}; };
type GooglePlaceContactEmailSource = {
email: string;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
};
type GooglePlaceApiPlace = { type GooglePlaceApiPlace = {
id?: string; id?: string;
displayName?: GooglePlaceDisplayName; displayName?: GooglePlaceDisplayName;
@@ -254,6 +261,11 @@ export type GooglePlaceCandidate = {
websiteUrl: string | null; websiteUrl: string | null;
websiteDomain: string | null; websiteDomain: string | null;
phone: string | null; phone: string | null;
email?: string | null;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
contactEmails?: GooglePlaceContactEmailSource[];
rating: number | null; rating: number | null;
userRatingCount: number | null; userRatingCount: number | null;
businessStatus: string | 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( export function normalizePlacesResponse(
response: GooglePlacesApiResponse, response: GooglePlacesApiResponse,
fetchedAt: number, fetchedAt: number,
@@ -333,6 +502,10 @@ export function normalizePlacesResponse(
export type ExistingLeadLike = { export type ExistingLeadLike = {
googlePlaceId?: string | null; googlePlaceId?: string | null;
websiteDomain?: string | null; websiteDomain?: string | null;
email?: string | null;
companyName?: string | null;
address?: string | null;
phone?: string | null;
}; };
export type BlacklistRow = { export type BlacklistRow = {
@@ -342,20 +515,25 @@ export type BlacklistRow = {
}; };
export type BlacklistLookupValue = { export type BlacklistLookupValue = {
type: "domain" | "phone" | "company" | "google_place_id"; type: "domain" | "email" | "phone" | "company" | "google_place_id";
normalizedValue: string; normalizedValue: string;
}; };
function normalizeDomain(value?: string | null) { export function normalizeDomain(value?: string | null) {
return value?.trim().toLowerCase().replace(/^www\./, "") ?? ""; return value?.trim().toLowerCase().replace(/^www\./, "") ?? "";
} }
function normalizePhone(value?: string | null) { export function normalizePhone(value?: string | null) {
if (!value) { if (!value) {
return ""; 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[]) { function uniqueLookupValues(values: BlacklistLookupValue[]) {
@@ -375,6 +553,8 @@ function uniqueLookupValues(values: BlacklistLookupValue[]) {
export function getBlacklistLookupValues( export function getBlacklistLookupValues(
candidate: GooglePlaceCandidate, candidate: GooglePlaceCandidate,
): BlacklistLookupValue[] { ): BlacklistLookupValue[] {
const emailAddresses = getCandidateEmailValues(candidate);
return uniqueLookupValues([ return uniqueLookupValues([
{ {
type: "google_place_id", type: "google_place_id",
@@ -386,7 +566,7 @@ export function getBlacklistLookupValues(
}, },
{ {
type: "company", type: "company",
normalizedValue: normalizeDomain(candidate.businessName), normalizedValue: normalizeText(candidate.businessName),
}, },
{ {
type: "phone", type: "phone",
@@ -396,6 +576,10 @@ export function getBlacklistLookupValues(
type: "phone", type: "phone",
normalizedValue: normalizeDomain(candidate.phone), normalizedValue: normalizeDomain(candidate.phone),
}, },
...emailAddresses.map((email) => ({
type: "email" as const,
normalizedValue: email ?? "",
})),
]); ]);
} }
@@ -405,25 +589,57 @@ export function isDuplicateCandidate(
): boolean { ): boolean {
const candidatePlaceId = normalizeDomain(candidate.placeId); const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain); const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateEmails = getCandidateEmailValues(candidate);
return existing.some((entry) => { return existing.some((entry) => {
const entryPlaceId = normalizeDomain(entry.googlePlaceId); const entryPlaceId = normalizeDomain(entry.googlePlaceId);
const entryDomain = normalizeDomain(entry.websiteDomain); const entryDomain = normalizeDomain(entry.websiteDomain);
const entryEmail = normalizeEmailAddress(entry.email);
return ( return (
(candidatePlaceId && entryPlaceId === candidatePlaceId) || (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( export function getBlacklistMatches(
candidate: GooglePlaceCandidate, candidate: GooglePlaceCandidate,
blacklistRows: BlacklistRow[], blacklistRows: BlacklistRow[],
) { ) {
const candidatePlaceId = normalizeDomain(candidate.placeId); const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain); const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateCompany = normalizeDomain(candidate.businessName); const candidateCompany = normalizeText(candidate.businessName);
const candidatePhone = normalizePhone(candidate.phone); const candidatePhone = normalizePhone(candidate.phone);
return blacklistRows.filter((row) => { return blacklistRows.filter((row) => {
@@ -446,6 +662,10 @@ export function getBlacklistMatches(
(row.normalizedValue === candidatePhone || (row.normalizedValue === candidatePhone ||
normalizePhone(row.value) === candidatePhone) normalizePhone(row.value) === candidatePhone)
); );
case "email":
return getCandidateEmailValues(candidate).some(
(candidateEmail) => candidateEmail === row.normalizedValue,
);
default: default:
return false; return false;
} }

View File

@@ -1,4 +1,10 @@
import type { GooglePlaceCandidate } from "./lead-discovery-google"; import {
normalizePhone,
normalizeText,
getUsableContactEmail,
type GooglePlaceCandidate,
} from "./lead-discovery-google";
import type { Id } from "../convex/_generated/dataModel";
type AgentRunLike = { type AgentRunLike = {
status: string; status: string;
@@ -12,8 +18,16 @@ type LeadDiscoveryCounterInput = {
}; };
type LeadDiscoveryContactInput = { type LeadDiscoveryContactInput = {
websiteDomain?: string | null; usableEmail?: string | null;
phone?: 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> = { type LeadDiscoveryLeadRecordInput<TCampaignId extends string, TRunId extends string> = {
@@ -70,7 +84,7 @@ export function buildLeadDiscoveryCounters(input: LeadDiscoveryCounterInput) {
export function getLeadDiscoveryContactStatus( export function getLeadDiscoveryContactStatus(
input: LeadDiscoveryContactInput, input: LeadDiscoveryContactInput,
) { ) {
if (input.websiteDomain || input.phone) { if (input.usableEmail) {
return "new"; return "new";
} }
@@ -81,6 +95,14 @@ export function buildLeadDiscoveryLeadRecord<
TCampaignId extends string, TCampaignId extends string,
TRunId extends string, TRunId extends string,
>(input: LeadDiscoveryLeadRecordInput<TCampaignId, TRunId>) { >(input: LeadDiscoveryLeadRecordInput<TCampaignId, TRunId>) {
type LeadDiscoveryDuplicateStatus =
| "unchecked"
| "unique"
| "possible_duplicate"
| "duplicate";
const usableEmail = getUsableContactEmail(input.candidate);
const lead: { const lead: {
campaignId: TCampaignId; campaignId: TCampaignId;
discoveryRunId: TRunId; discoveryRunId: TRunId;
@@ -100,9 +122,21 @@ export function buildLeadDiscoveryLeadRecord<
websiteUrl?: string; websiteUrl?: string;
websiteDomain?: string; websiteDomain?: string;
phone?: string; phone?: string;
priority: "medium"; 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"; contactStatus: "new" | "missing_contact";
duplicateStatus: "unique"; duplicateStatus: LeadDiscoveryDuplicateStatus;
blacklistStatus: "clear"; blacklistStatus: "clear";
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
@@ -119,8 +153,7 @@ export function buildLeadDiscoveryLeadRecord<
sourceFetchedAt: input.candidate.sourceFetchedAt, sourceFetchedAt: input.candidate.sourceFetchedAt,
priority: "medium", priority: "medium",
contactStatus: getLeadDiscoveryContactStatus({ contactStatus: getLeadDiscoveryContactStatus({
websiteDomain: input.candidate.websiteDomain, usableEmail: usableEmail?.email,
phone: input.candidate.phone,
}), }),
duplicateStatus: "unique", duplicateStatus: "unique",
blacklistStatus: "clear", blacklistStatus: "clear",
@@ -136,6 +169,21 @@ export function buildLeadDiscoveryLeadRecord<
const websiteUrl = optionalString(input.candidate.websiteUrl); const websiteUrl = optionalString(input.candidate.websiteUrl);
const websiteDomain = optionalString(input.candidate.websiteDomain); const websiteDomain = optionalString(input.candidate.websiteDomain);
const phone = optionalString(input.candidate.phone); 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) { if (googleMapsUrl !== undefined) {
lead.googleMapsUrl = googleMapsUrl; lead.googleMapsUrl = googleMapsUrl;
@@ -161,6 +209,55 @@ export function buildLeadDiscoveryLeadRecord<
if (phone !== undefined) { if (phone !== undefined) {
lead.phone = phone; 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; 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.",
};
}

View File

@@ -4,6 +4,7 @@ import test from "node:test";
import { import {
RUN_STATUSES, RUN_STATUSES,
SCREENSHOT_VIEWPORTS, SCREENSHOT_VIEWPORTS,
LEAD_PRIORITIES,
filterSafeSettingsRows, filterSafeSettingsRows,
isSafeSettingsKey, isSafeSettingsKey,
normalizeListLimit, 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", () => { test("list limits are clamped to a positive integer range", () => {
assert.equal(normalizeListLimit(undefined), 50); assert.equal(normalizeListLimit(undefined), 50);
assert.equal(normalizeListLimit(-10), 1); assert.equal(normalizeListLimit(-10), 1);

View File

@@ -2,6 +2,14 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
getLeadBlacklistStatusLabel,
getLeadContactStatusLabel,
getLeadDuplicateStatusLabel,
getLeadPriorityLabel,
leadBlacklistStatusOptions,
leadContactStatusOptions,
leadDuplicateStatusOptions,
leadPriorityOptions,
dashboardKpis, dashboardKpis,
dashboardNavigation, dashboardNavigation,
groupLeadFunnelCards, 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", () => { test("dashboardKpis and reviewQueue expose the above-the-fold dashboard summary", () => {
assert.equal(dashboardKpis.length, 4); assert.equal(dashboardKpis.length, 4);
assert.equal(reviewQueue.length, 3); assert.equal(reviewQueue.length, 3);

View File

@@ -4,10 +4,14 @@ import test from "node:test";
import { import {
GOOGLE_PLACES_FIELD_MASK, GOOGLE_PLACES_FIELD_MASK,
buildGeocodingUrl, buildGeocodingUrl,
getUsableContactEmail,
getUsableContactEmailFromEntries,
getBlacklistMatches, getBlacklistMatches,
getBlacklistLookupValues, getBlacklistLookupValues,
getPlacesSearchSpec, getPlacesSearchSpec,
isProbableDuplicateCandidate,
isDuplicateCandidate, isDuplicateCandidate,
normalizeEmailAddress,
normalizePlacesResponse, normalizePlacesResponse,
parseGeocodingResponse, parseGeocodingResponse,
} from "../lib/lead-discovery-google"; } 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", () => { test("duplicate detection uses placeId and websiteDomain", () => {
const existingLeads = [ 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( assert.equal(
@@ -277,6 +285,158 @@ test("duplicate detection uses placeId and websiteDomain", () => {
), ),
false, 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", () => { 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", websiteUrl: "https://www.Blocked.de",
websiteDomain: "blocked.de", websiteDomain: "blocked.de",
phone: "+49 30 555 123", phone: "+49 30 555 123",
email: "Info@Blocked.De",
contactEmails: [{ email: "Hello@blocked.de", isBusinessContactAddress: false }],
rating: null, rating: null,
userRatingCount: null, userRatingCount: null,
businessStatus: null, businessStatus: null,
@@ -303,6 +465,8 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
{ type: "company", normalizedValue: "muster gmbh" }, { type: "company", normalizedValue: "muster gmbh" },
{ type: "phone", normalizedValue: "4930555123" }, { type: "phone", normalizedValue: "4930555123" },
{ type: "phone", normalizedValue: "+49 30 555 123" }, { type: "phone", normalizedValue: "+49 30 555 123" },
{ type: "email", normalizedValue: "info@blocked.de" },
{ type: "email", normalizedValue: "hello@blocked.de" },
]); ]);
const matches = getBlacklistMatches( 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: "email", value: "x@example.de", normalizedValue: "x@example.de" },
{ type: "phone", value: "+49 30 999 999", normalizedValue: "4930999999" }, { 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(); const matchTypes = matches.map((match) => match.type).sort();
assert.deepEqual( assert.deepEqual(
matchTypes, 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, canStartAgentRun,
isStalePendingAgentRun, isStalePendingAgentRun,
getLeadDiscoveryContactStatus, getLeadDiscoveryContactStatus,
getLeadDiscoveryPriority,
} from "../lib/lead-discovery-run"; } from "../lib/lead-discovery-run";
test("agent run guard ignores stale pending runs but blocks active runs", () => { 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", () => { test("lead discovery contact status separates leads without any contact route", () => {
assert.equal( assert.equal(
getLeadDiscoveryContactStatus({ websiteDomain: null, phone: null }), getLeadDiscoveryContactStatus({ usableEmail: null }),
"missing_contact", "missing_contact",
); );
assert.equal( assert.equal(
getLeadDiscoveryContactStatus({ websiteDomain: "example.de", phone: null }), getLeadDiscoveryContactStatus({ usableEmail: "info@example.de" }),
"new", "new",
); );
assert.equal( assert.equal(
getLeadDiscoveryContactStatus({ websiteDomain: null, phone: "030 123" }), getLeadDiscoveryContactStatus({ usableEmail: null }),
"new", "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", () => { test("lead discovery lead record keeps raw website url and normalized domain", () => {
const record = buildLeadDiscoveryLeadRecord({ const record = buildLeadDiscoveryLeadRecord({
campaignId: "campaign-1", 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.googleUserRatingCount, 12);
assert.equal(record.sourceFetchedAt, 1717480001000); 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.",
});
});