Compare commits

..

4 Commits

53 changed files with 8184 additions and 146 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() {
return (
<DashboardPlaceholderPage
description="Sperrlisten fuer Domains, E-Mails, Telefonnummern, Firmennamen und Place IDs folgen nach den Datenmodellen."
title="Blacklist"
/>
);
return <BlacklistManager />;
}

View File

@@ -1,10 +1,11 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page";
import { CampaignsBoard } from "@/components/campaigns/campaigns-board";
export default function CampaignsPage() {
return (
<DashboardPlaceholderPage
description="Kampagnen-Konfiguration, PLZ, Radius, Limits und Laufplanung folgen in TASK-5."
title="Campaigns"
/>
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<CampaignsBoard />
</div>
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { isAuthenticated } from "@/lib/auth-server";
import { DashboardSidebar } from "@/components/dashboard-sidebar";
import { DashboardThemeProvider } from "@/components/dashboard-theme";
import { getDashboardRedirectPath } from "@/lib/route-guards";
export default async function DashboardLayout({
@@ -17,9 +18,9 @@ export default async function DashboardLayout({
}
return (
<div className="min-h-dvh bg-background md:flex">
<DashboardThemeProvider>
<DashboardSidebar />
<div className="min-w-0 flex-1">{children}</div>
</div>
</DashboardThemeProvider>
);
}

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() {
return (
<DashboardPlaceholderPage
description="Lead-Qualifikation, Dubletten und fehlende Kontaktdaten folgen in TASK-7."
title="Leads"
/>
);
return <LeadsReviewTable />;
}

View File

@@ -3,8 +3,8 @@ import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-pag
export default function OutreachPage() {
return (
<DashboardPlaceholderPage
description="E-Mail-Entwuerfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14."
title="Outreach"
description="E-Mail-Entwürfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14."
title="Review"
/>
);
}

View File

@@ -1,9 +1,9 @@
import {
dashboardKpis,
pipelineHealth,
pipelineStages,
reviewQueue,
} from "@/lib/dashboard-model";
import { LeadFunnelBoard } from "@/components/lead-funnel-board";
export default function DashboardPage() {
return (
@@ -15,16 +15,14 @@ export default function DashboardPage() {
Interner Arbeitsbereich
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
Pipeline-Uebersicht
Pipeline-Übersicht
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Recherche, Audit-Freigabe und Outreach bleiben eng gekoppelt:
wenige gute Leads, manuelle Pruefung, kein automatischer Versand.
wenige gute Leads, manuelle Prüfung, kein automatischer Versand.
</p>
</div>
<p className="text-sm font-medium text-muted-foreground">
Mock-Session aktiv
</p>
<p className="text-sm font-medium text-muted-foreground">MVP intern</p>
</header>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
@@ -44,36 +42,13 @@ export default function DashboardPage() {
))}
</section>
<section className="grid gap-3 xl:grid-cols-4">
{pipelineStages.map((stage) => {
const Icon = stage.icon;
return (
<article
className="rounded-lg border bg-card p-4 text-card-foreground"
key={stage.title}
>
<div className="flex items-center justify-between gap-4">
<Icon className="size-5 text-muted-foreground" />
<span className="text-2xl font-semibold">{stage.count}</span>
</div>
<h2 className="mt-4 text-sm font-medium">{stage.title}</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{stage.description}
</p>
<p className="mt-4 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
{stage.meta}
</p>
</article>
);
})}
</section>
<LeadFunnelBoard />
<section className="grid gap-3 lg:grid-cols-[1.45fr_0.55fr]">
<div className="rounded-lg border bg-card text-card-foreground">
<div className="border-b p-4">
<h2 className="text-base font-semibold tracking-normal">
Naechste Review-Schritte
Nächste Review-Schritte
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Alles bleibt an manuelle Freigabe gekoppelt.

View File

@@ -4,7 +4,7 @@ export default function SettingsPage() {
return (
<DashboardPlaceholderPage
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
title="Settings"
title="Einstellungen"
/>
);
}

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-4
title: Build the dashboard shell and lead funnel
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:12'
updated_date: '2026-06-04 10:35'
labels:
- mvp
- ui
@@ -13,7 +14,7 @@ dependencies:
references:
- PRD.md
priority: high
ordinal: 4000
ordinal: 20000
---
## Description
@@ -24,11 +25,11 @@ Create the internal German-language dashboard shell for the MVP. It should provi
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Dashboard shell has German navigation for campaigns, leads, audits, analytics, blacklist, and settings
- [ ] #2 Light/Dark theme toggle works only in the internal dashboard
- [ ] #3 Kanban/Funnel columns represent the agreed lead states, including Kontakt fehlt, Audit bereit, Freigabe offen, Kontaktiert, Follow-up, and Zurückgestellt
- [ ] #4 Lead cards show the key scan data: company, niche, location, priority, contact status, and next action
- [ ] #5 Dashboard remains keyboard accessible and responsive on practical desktop/tablet widths
- [x] #1 Dashboard shell has German navigation for campaigns, leads, audits, analytics, blacklist, and settings
- [x] #2 Light/Dark theme toggle works only in the internal dashboard
- [x] #3 Kanban/Funnel columns represent the agreed lead states, including Kontakt fehlt, Audit bereit, Freigabe offen, Kontaktiert, Follow-up, and Zurückgestellt
- [x] #4 Lead cards show the key scan data: company, niche, location, priority, contact status, and next action
- [x] #5 Dashboard remains keyboard accessible and responsive on practical desktop/tablet widths
<!-- AC:END -->
## Implementation Plan
@@ -40,3 +41,19 @@ Create the internal German-language dashboard shell for the MVP. It should provi
4. Build the Kanban/Funnel view using Convex lead data.
5. Add empty states, loading states, and basic accessibility checks.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started subagent-driven, test-driven implementation for TASK-4. Status model decision: derive required German funnel stages from existing lead/outreach/audit data; no schema migration for this task.
Implemented German dashboard navigation, dashboard-scoped light/dark toggle, Convex-backed derived lead funnel, accessible lead card actions, loading/empty states, and responsive wrapped funnel columns. Verification: pnpm test passed 24/24; pnpm lint passed with only existing generated Convex warnings; pnpm build passed with network allowed for next/font assets. Browser check reached login redirect as expected without an authenticated admin session.
Final Spark review found one listFunnel correctness risk in the bulk outreach lookup. Replaced it with a bounded per-lead indexed latest-outreach lookup so each returned lead preserves its latest outreach state. Re-ran pnpm test, pnpm lint, and pnpm build successfully after the fix.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped the German internal dashboard shell with dashboard-scoped light/dark mode, Convex-backed derived lead funnel, accessible responsive lead cards, localized dashboard navigation/placeholders, and verified TASK-4 acceptance criteria. Verification: pnpm test passed 24/24; lint/build were run successfully during implementation with only generated Convex lint warnings noted.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-5
title: Implement campaign configuration and scheduling controls
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:12'
updated_date: '2026-06-04 12:44'
labels:
- mvp
- campaigns
@@ -13,7 +14,7 @@ dependencies:
references:
- PRD.md
priority: high
ordinal: 5000
ordinal: 21000
---
## Description
@@ -24,11 +25,11 @@ Build the campaign management UI and backend mutations for reusable local search
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Campaign create/edit forms use React Hook Form, Zod, and shadcn form components
- [ ] #2 Campaigns support predefined categories plus Anderes with a required custom input
- [ ] #3 Campaigns store PLZ, radius, cadence, max new leads, max audits, active/paused state, and Germany-only context
- [ ] #4 Each campaign has a Jetzt ausführen action and shows last run, next run, and current run status
- [ ] #5 Form validation gives clear German error messages for invalid PLZ, radius, cadence, and limits
- [x] #1 Campaign create/edit forms use React Hook Form, Zod, and shadcn form components
- [x] #2 Campaigns support predefined categories plus Anderes with a required custom input
- [x] #3 Campaigns store PLZ, radius, cadence, max new leads, max audits, active/paused state, and Germany-only context
- [x] #4 Each campaign has a Jetzt ausführen action and shows last run, next run, and current run status
- [x] #5 Form validation gives clear German error messages for invalid PLZ, radius, cadence, and limits
<!-- AC:END -->
## Implementation Plan
@@ -40,3 +41,21 @@ Build the campaign management UI and backend mutations for reusable local search
4. Add run metadata fields for last run, next run, and current status.
5. Verify campaign forms and dashboard state transitions.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started subagent-driven, test-driven implementation with Codex Spark workers. Orchestrator will enforce red-green cycles, spec review, code quality review, and final verification before requesting manual confirmation.
Implemented TASK-5 subagent-driven and test-driven. Backend/domain slice passed spec and quality review after fix loops. Frontend slice passed spec review and quality re-review after accessibility/timer fixes. Verification: pnpm test passed 40/40; pnpm exec tsc -p tsconfig.json passed; pnpm lint passed with only existing generated Convex warnings; pnpm build passed with network allowed for next/font assets. Local route check: /dashboard/campaigns returns 307 to /login without session; /login returns 200. Browser visual flow still needs authenticated manual testing before closing task as Done.
Bugfix after manual testing: campaign create dialog crashed because SelectContent was placed inside FormControl, giving FormControl multiple children and triggering React.Children.only. Fixed category and recurrence Select composition so FormControl wraps only SelectTrigger while SelectContent remains inside Select as a sibling. Verification after fix: pnpm exec tsc -p tsconfig.json passed; pnpm lint passed with only existing generated warnings; pnpm test passed 40/40; pnpm build passed.
Bugfix after manual testing: campaign create dialog emitted controlled/uncontrolled input warning because campaignFormDefaults lacked name/customSearchTerm while RHF rendered controlled inputs. Added empty string defaults and defensive customSearchTerm value fallback. Verification after fix: pnpm exec tsc -p tsconfig.json passed; pnpm test passed 40/40; pnpm lint passed with only existing generated warnings; pnpm build passed.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-5 shipped campaign configuration and scheduling controls: React Hook Form/Zod/shadcn create/edit forms, predefined categories plus Anderes custom niche, Convex campaign persistence with Germany-only context, run request/status metadata, pause/resume controls, German validation, and post-manual-test bugfixes for Select composition and controlled inputs.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-6
title: Integrate Google Geocoding and Places lead discovery
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:12'
updated_date: '2026-06-04 13:24'
labels:
- mvp
- integrations
@@ -24,19 +25,35 @@ Connect the campaign runner to Google Geocoding and Google Places. The system ge
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 German PLZ values are geocoded to coordinates and cached on the campaign or run
- [ ] #2 Google Places searches use category mappings or custom niche text plus configured radius
- [ ] #3 Lead records store Place ID, business name, address, category, website, phone, rating metadata for internal use, and source timestamps where available
- [ ] #4 Runs respect max new leads and never start if another agent run is already active
- [ ] #5 API failures, empty results, skipped duplicates, and skipped blacklisted entities are visible in run logs
- [x] #1 German PLZ values are geocoded to coordinates and cached on the campaign or run
- [x] #2 Google Places searches use category mappings or custom niche text plus configured radius
- [x] #3 Lead records store Place ID, business name, address, category, website, phone, rating metadata for internal use, and source timestamps where available
- [x] #4 Runs respect max new leads and never start if another agent run is already active
- [x] #5 API failures, empty results, skipped duplicates, and skipped blacklisted entities are visible in run logs
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Define category-to-Places-query mappings for the initial MVP categories.
2. Add Google Geocoding integration with Germany-focused requests.
3. Add Google Places search integration using stored campaign settings.
4. Persist discovered leads with source metadata and run linkage.
5. Add run-level logging for success, empty, duplicate, blacklisted, and error cases.
1. Add failing helper tests for Google category/query mapping, response parsing, duplicate/blacklist decisions, and source metadata.
2. Implement pure lead discovery helpers with GOOGLE_GEOCODING_API_KEY and GOOGLE_PLACES_API_KEY contract.
3. Add failing Convex/schema tests or type checks for campaign requestRun guard, scheduled processing, geocode caching, and lead source persistence.
4. Implement Convex leadDiscovery processing, run transitions, logging, limits, duplicate and blacklist skips.
5. Run pnpm test, pnpm exec tsc -p tsconfig.json, pnpm lint; review and fix findings.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Starting TASK-6 after TASK-5 completion. Adjusted plan: reuse campaigns.requestRun and existing campaign run status UI; use split GOOGLE_GEOCODING_API_KEY and GOOGLE_PLACES_API_KEY from .env.local; no outreach or audit creation in this task.
Implemented TASK-6 subagent-driven and test-driven. Worker 1 built pure Google discovery helpers with RED/GREEN tests. Spec reviewer requested website URL persistence; fixed with TDD mapper and re-review approved. Code-quality reviewer requested exact blacklist lookups and moving campaign timestamp updates to actual run start; fixed and re-review approved. Final verification: npx convex codegen passed; pnpm exec tsc -p tsconfig.json passed; pnpm test passed 51/51; pnpm lint passed with only existing generated BetterAuth warnings.
Bugfix after manual UI test: campaigns.requestRun previously treated any pending run as active forever, so old Task-5 pending runs blocked new lead discovery starts. Added TDD coverage for stale pending runs, a 10-minute pending grace period, and automatic cancellation/logging of stale pending runs before creating a new run. Verification: pnpm exec tsc -p tsconfig.json passed; pnpm test passed 52/52; pnpm lint passed with only existing generated BetterAuth warnings; npx convex codegen passed.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
TASK-6 shipped Google Geocoding and Places lead discovery wired into the existing campaign run flow. It geocodes German PLZ values, caches coordinates, searches Places with preset mappings or custom text plus radius, stores Google source-backed lead metadata, respects per-run limits and active-run guards, logs failures/empty/duplicate/blacklist outcomes, and includes the stale-pending-run cleanup discovered during manual UI testing.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
---
id: TASK-7
title: 'Add lead qualification, deduplication, and blacklist handling'
status: To Do
status: Done
assignee: []
created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 14:09'
labels:
- mvp
- leads
@@ -24,19 +25,57 @@ Implement the rules that turn raw business discoveries into usable lead states.
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
- [ ] #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
- [ ] #5 Priority values Hoch, Mittel, Niedrig, Zurückstellen, and Gesperrt are assigned or editable with clear reasons
- [x] #1 Leads with no usable email are placed in Kontakt fehlt while preserving phone and source data
- [x] #2 Generic business emails are preferred and named emails are accepted only when explicitly found as business contact addresses
- [x] #3 Hard duplicates are detected by domain, Google Place ID, or email; probable duplicates are flagged by name plus address or phone
- [x] #4 Manual blacklist entries for domain, email, phone, company name, and Place ID are enforced during discovery and review
- [x] #5 Priority values Hoch, Mittel, Niedrig, Zurückstellen, and Gesperrt are assigned or editable with clear reasons
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add blacklist CRUD in Convex and dashboard UI.
2. Implement email/contact extraction result fields and Kontakt fehlt transitions.
3. Add hard and probable duplicate matching rules.
4. Add priority assignment rules based on website/contact signals.
5. Surface reasons and source data in lead detail and run logs.
Subagent-driven TDD execution plan
Orchestrator responsibilities:
1. Coordinate TASK-7 implementation end to end.
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 -->
## 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
assignee: []
created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 14:08'
labels:
- mvp
- audit
@@ -19,24 +20,37 @@ ordinal: 8000
## Description
<!-- 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 -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
- [ ] #3 Crawler extracts visible text, page title, meta description, headings, links, phone numbers, email candidates, 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
- [ ] #5 Crawler failures produce useful dashboard-visible errors without blocking unrelated leads
- [ ] #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 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 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 -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
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.
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 -->
## 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,407 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod/v4";
import {
campaignFormDefaults,
campaignFormSchema,
mapCampaignFormToPayload,
} from "@/lib/campaign-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
type CampaignFormValues = z.infer<typeof campaignFormSchema>;
type CampaignFormSeed = Partial<CampaignFormValues> & {
_id?: string;
};
type CampaignFormDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
campaign?: CampaignFormSeed | null;
onSubmit: (
payload: Omit<CampaignFormValues, "status"> &
Required<Pick<CampaignFormValues, "status">> & {
countryCode: "DE";
country: "Deutschland";
},
) => Promise<void>;
};
const categoryOptions = [
"Anwalt",
"Bauunternehmen",
"Friseur",
"Gastronomie",
"Handwerk",
"Immobilien",
"Kfz-Werkstatt",
"Marketing",
"Restaurant",
"Zahnarzt",
"Anderes",
] as const;
const recurrenceOptions: Record<CampaignFormValues["recurrence"], string> = {
manual: "manuell",
daily: "täglich",
weekly: "wöchentlich",
monthly: "monatlich",
};
const statusLabel: Record<CampaignFormValues["status"], string> = {
active: "Aktiv",
paused: "Pausiert",
};
const customCategoryValue = "Anderes";
export function CampaignFormDialog({
open,
onOpenChange,
campaign,
onSubmit,
}: CampaignFormDialogProps) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const methods = useForm<CampaignFormValues>({
resolver: zodResolver(campaignFormSchema),
defaultValues: campaignFormDefaults,
});
const {
control,
reset,
setValue,
formState: { isSubmitting },
} = methods;
const selectedCategory = useWatch({
control,
name: "category",
defaultValue: campaignFormDefaults.category,
});
const selectedStatus = useWatch({
control,
name: "status",
defaultValue: campaignFormDefaults.status,
});
const showCustomSearch = selectedCategory === customCategoryValue;
useEffect(() => {
const defaults = campaign
? {
status: campaign.status ?? campaignFormDefaults.status,
categoryMode: campaign.categoryMode ?? campaignFormDefaults.categoryMode,
recurrence: campaign.recurrence ?? campaignFormDefaults.recurrence,
radiusKm: campaign.radiusKm ?? campaignFormDefaults.radiusKm,
maxNewLeadsPerRun:
campaign.maxNewLeadsPerRun ?? campaignFormDefaults.maxNewLeadsPerRun,
maxAuditsPerRun:
campaign.maxAuditsPerRun ?? campaignFormDefaults.maxAuditsPerRun,
name: campaign.name ?? "",
category: campaign.category ?? "",
customSearchTerm: campaign.customSearchTerm ?? "",
postalCode: campaign.postalCode ?? campaignFormDefaults.postalCode,
}
: campaignFormDefaults;
reset(defaults);
}, [campaign, reset]);
useEffect(() => {
if (showCustomSearch) {
setValue("categoryMode", "custom");
return;
}
setValue("categoryMode", "preset");
setValue("customSearchTerm", "");
}, [showCustomSearch, setValue]);
const dialogTitle = useMemo(
() => (campaign ? "Kampagne bearbeiten" : "Kampagne anlegen"),
[campaign],
);
const submitLabel = useMemo(
() => (campaign ? "Speichern" : "Erstellen"),
[campaign],
);
const submitForm = async (values: CampaignFormValues) => {
setPending(true);
setError(null);
try {
const payload = mapCampaignFormToPayload(values as Record<string, unknown>);
await onSubmit({
...payload,
status: values.status,
categoryMode: values.categoryMode,
category: values.category,
customSearchTerm: values.customSearchTerm || undefined,
postalCode: values.postalCode,
radiusKm: values.radiusKm,
recurrence: values.recurrence,
maxNewLeadsPerRun: values.maxNewLeadsPerRun,
maxAuditsPerRun: values.maxAuditsPerRun,
name: values.name,
});
onOpenChange(false);
} catch {
setError("Speichern fehlgeschlagen.");
} finally {
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
</DialogDescription>
<DialogCloseButton />
</DialogHeader>
<Form form={methods} onSubmit={submitForm}>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOptions.map((category) => (
<SelectItem value={category} key={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{showCustomSearch && (
<FormField
control={control}
name="customSearchTerm"
render={({ field }) => (
<FormItem>
<FormLabel>Eigene Nische</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder="Beispiel: Webdesigner für Restaurants"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>PLZ</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
inputMode="numeric"
maxLength={5}
placeholder="10115"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="radiusKm"
render={({ field }) => (
<FormItem>
<FormLabel>Radius (km)</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
step={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="recurrence"
render={({ field }) => (
<FormItem>
<FormLabel>Wiederholung</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Wiederholung wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(recurrenceOptions).map(([value, label]) => (
<SelectItem value={value} key={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={control}
name="maxNewLeadsPerRun"
render={({ field }) => (
<FormItem>
<FormLabel>Max. neue Leads</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="maxAuditsPerRun"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Audits</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<div className="flex items-center gap-3">
<FormControl>
<Switch
checked={field.value === "active"}
onCheckedChange={(checked) =>
field.onChange(checked ? "active" : "paused")
}
/>
</FormControl>
<span>{statusLabel[selectedStatus ?? "paused"]}</span>
</div>
<FormMessage />
</FormItem>
)}
/>
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
<div className="flex flex-wrap gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting || pending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || pending}>
{isSubmitting || pending ? "Speichert..." : submitLabel}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,454 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { MapPin, Pencil, Play, RefreshCcw, Plus } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
import { campaignFormDefaults } from "@/lib/campaign-form";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { CampaignFormDialog } from "@/components/campaigns/campaign-form-dialog";
type CampaignsListResult = FunctionReturnType<typeof api.campaigns.list>;
type CampaignRow = NonNullable<CampaignsListResult>[number];
type RecurrenceLabel = Record<CampaignRow["recurrence"], string>;
type CurrentRunStatusLabel = {
[key: string]: string;
};
const recurrenceLabel: RecurrenceLabel = {
manual: "manuell",
daily: "täglich",
weekly: "wöchentlich",
monthly: "monatlich",
};
const statusLabel: CurrentRunStatusLabel = {
running: "Läuft",
pending: "Ausstehend",
succeeded: "Erledigt",
failed: "Fehlgeschlagen",
canceled: "Abgebrochen",
idle: "Leerlauf",
paused: "Pausiert",
};
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
timeStyle: "short",
});
function formatDateTime(value?: number | null): string {
if (!value) {
return "Nicht gesetzt";
}
return dateFormatter.format(new Date(value));
}
const formPayloadFromCampaign = (campaign?: CampaignRow | null) => {
if (!campaign) {
return campaignFormDefaults;
}
return {
status: campaign.status,
categoryMode: campaign.categoryMode,
recurrence: campaign.recurrence,
radiusKm: campaign.radiusKm,
maxNewLeadsPerRun: campaign.maxNewLeadsPerRun,
maxAuditsPerRun: campaign.maxAuditsPerRun,
name: campaign.name,
category: campaign.category,
customSearchTerm: campaign.customSearchTerm ?? "",
postalCode: campaign.postalCode,
};
};
const formatNiche = (campaign: CampaignRow): string => {
if (campaign.category !== "Anderes") {
return campaign.category;
}
return campaign.customSearchTerm?.trim()
? `${campaign.category}: ${campaign.customSearchTerm}`
: campaign.category;
};
export function CampaignsBoard() {
const campaigns = useQuery(api.campaigns.list, { limit: 100 });
const createCampaign = useMutation(api.campaigns.create);
const updateCampaign = useMutation(api.campaigns.update);
const setStatus = useMutation(api.campaigns.setStatus);
const requestRun = useMutation(api.campaigns.requestRun);
const [editingCampaign, setEditingCampaign] = useState<CampaignRow | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const [actionBusyId, setActionBusyId] = useState<Id<"campaigns"> | null>(null);
const [actionLabel, setActionLabel] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [rowError, setRowError] = useState<string | null>(null);
const actionLabelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearActionLabelTimer = () => {
if (actionLabelTimerRef.current) {
clearTimeout(actionLabelTimerRef.current);
actionLabelTimerRef.current = null;
}
};
const setActionLabelWithTimeout = (
label: string,
clearAfterMs = 1200,
) => {
clearActionLabelTimer();
setActionLabel(label);
if (clearAfterMs > 0) {
actionLabelTimerRef.current = setTimeout(() => setActionLabel(null), clearAfterMs);
}
};
useEffect(() => {
return () => {
clearActionLabelTimer();
};
}, []);
const campaignsSorted = useMemo(() => {
if (!campaigns) {
return [];
}
return [...campaigns].sort((a, b) => b.createdAt - a.createdAt);
}, [campaigns]);
const closeDialog = () => {
setEditingCampaign(null);
setIsFormOpen(false);
setFormError(null);
};
const openCreateDialog = () => {
setEditingCampaign(null);
setRowError(null);
setIsFormOpen(true);
};
const openEditDialog = (campaign: CampaignRow) => {
setEditingCampaign(campaign);
setRowError(null);
setIsFormOpen(true);
};
const submitCampaign = async (payload: {
status: CampaignRow["status"];
categoryMode: CampaignRow["categoryMode"];
category: string;
customSearchTerm?: string;
postalCode: string;
radiusKm: number;
maxNewLeadsPerRun: number;
maxAuditsPerRun: number;
recurrence: CampaignRow["recurrence"];
countryCode: "DE";
country: "Deutschland";
name: string;
}) => {
setActionLabel("Speichere...");
setFormError(null);
try {
if (!editingCampaign) {
await createCampaign(payload);
} else {
await updateCampaign({
id: editingCampaign._id,
...payload,
});
}
setActionLabelWithTimeout("Gespeichert");
setIsFormOpen(false);
setEditingCampaign(null);
} catch {
setFormError("Speichern fehlgeschlagen.");
setActionLabelWithTimeout("Fehler", 2000);
}
};
const runCampaign = async (campaign: CampaignRow) => {
setActionBusyId(campaign._id);
setRowError(null);
try {
await requestRun({ id: campaign._id });
setActionLabelWithTimeout(`${campaign.name}: Lauf gestartet`);
} catch {
setRowError("Kampagne konnte nicht gestartet werden.");
setActionLabelWithTimeout("Kampagne konnte nicht gestartet werden.", 2400);
} finally {
setActionBusyId(null);
}
};
const toggleCampaign = async (campaign: CampaignRow) => {
const nextStatus = campaign.status === "active" ? "paused" : "active";
setActionBusyId(campaign._id);
setRowError(null);
try {
await setStatus({ id: campaign._id, status: nextStatus });
setActionLabelWithTimeout(
`${campaign.name}: ${nextStatus === "active" ? "Aktiviert" : "Pausiert"}`,
);
} catch {
setRowError("Status konnte nicht geändert werden.");
setActionLabelWithTimeout("Status konnte nicht geändert werden.", 2400);
} finally {
setActionBusyId(null);
}
};
if (campaigns === undefined) {
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="h-7 w-48 rounded-md bg-muted" />
<div className="h-8 w-24 rounded-md bg-muted" />
</div>
<div className="grid gap-3">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-28 rounded-lg" key={index} />
))}
</div>
</section>
);
}
return (
<section className="space-y-4">
<CampaignFormDialog
campaign={editingCampaign ? formPayloadFromCampaign(editingCampaign) : null}
open={isFormOpen}
onOpenChange={closeDialog}
onSubmit={submitCampaign}
/>
<div className="flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm text-muted-foreground">Lokale Kampagnenverwaltung</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
Kampagnen
</h1>
</div>
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
<Plus className="size-4" />
Kampagne anlegen
</Button>
</div>
{formError ? <p className="text-sm text-destructive" role="alert">{formError}</p> : null}
{rowError ? <p className="text-sm text-destructive" role="alert">{rowError}</p> : null}
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
{campaignsSorted.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>Keine Kampagnen</CardTitle>
<CardDescription>
Lege zuerst eine Kampagne mit Kategorie, PLZ und Limits an.
</CardDescription>
</CardHeader>
</Card>
) : (
<>
<div className="hidden overflow-x-auto rounded-lg border bg-card md:block">
<table className="w-full min-w-[820px] border-separate border-spacing-0">
<thead>
<tr className="text-left text-sm text-muted-foreground">
<th className="sticky left-0 bg-card p-3 font-normal">Kampagne</th>
<th className="p-3 font-normal">PLZ / Radius</th>
<th className="p-3 font-normal">Cadence</th>
<th className="p-3 font-normal">Limits</th>
<th className="p-3 font-normal">Status</th>
<th className="p-3 font-normal">Lauf</th>
<th className="p-3 font-normal">Aktionen</th>
</tr>
</thead>
<tbody>
{campaignsSorted.map((campaign) => (
<tr
className="border-t"
key={campaign._id}
>
<td className="max-w-[220px] p-3 align-top">
<div className="space-y-1">
<p className="truncate font-medium">{campaign.name}</p>
<p className="text-sm text-muted-foreground">
{formatNiche(campaign)}
</p>
</div>
</td>
<td className="max-w-[180px] p-3 align-top">
<div className="space-y-1 text-sm text-muted-foreground">
<p className="inline-flex items-center gap-1">
<MapPin className="size-3" />
<span>{campaign.postalCode}</span>
</p>
<p>{campaign.radiusKm} km Umkreis</p>
</div>
</td>
<td className="p-3 align-top">
<span className="rounded-md bg-muted px-2 py-1 text-sm">
{recurrenceLabel[campaign.recurrence]}
</span>
</td>
<td className="p-3 align-top">
<p className="text-sm">
Leads: {campaign.maxNewLeadsPerRun} · Audits:{" "}
{campaign.maxAuditsPerRun}
</p>
</td>
<td className="p-3 align-top">
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</td>
<td className="p-3 align-top">
<div className="space-y-1 text-sm text-muted-foreground">
<p>Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p>Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p>Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}</p>
</div>
</td>
<td className="p-3 align-top">
<div className="flex flex-wrap gap-2">
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
className="w-full sm:w-auto"
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
className="w-full sm:w-auto"
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 md:hidden">
{campaignsSorted.map((campaign) => (
<Card key={campaign._id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate">{campaign.name}</CardTitle>
<CardDescription className="truncate">
{formatNiche(campaign)}
</CardDescription>
</div>
<Badge
variant={campaign.status === "active" ? "default" : "secondary"}
>
{campaign.status === "active" ? "Aktiv" : "Pausiert"}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="inline-flex items-center gap-1 text-muted-foreground">
<MapPin className="size-3" />
<span>{campaign.postalCode}</span>
</div>
<span>{campaign.radiusKm} km</span>
</div>
<Separator className="bg-border" />
<div>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
<p>
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
{campaign.maxAuditsPerRun}
</p>
</div>
<div>
<p className="text-muted-foreground">Letzter Lauf: {formatDateTime(campaign.lastRunAt)}</p>
<p className="text-muted-foreground">Nächster Lauf: {formatDateTime(campaign.nextRunAt)}</p>
<p className="text-muted-foreground">
Run-Status: {statusLabel[campaign.currentRunStatus] ?? campaign.currentRunStatus}
</p>
</div>
<div className="grid gap-2">
<Button
variant="outline"
onClick={() => openEditDialog(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Pencil className="size-4" />
Bearbeiten
</Button>
<Button
variant="outline"
onClick={() => toggleCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<RefreshCcw className="size-4" />
{campaign.status === "active" ? "Pausieren" : "Fortfahren"}
</Button>
<Button
onClick={() => runCampaign(campaign)}
disabled={actionBusyId === campaign._id}
className="w-full justify-start"
>
<Play className="size-4" />
Jetzt ausführen
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
</section>
);
}

View File

@@ -6,6 +6,7 @@ import { LogOut } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { DashboardThemeToggle } from "@/components/dashboard-theme";
import { dashboardNavigation } from "@/lib/dashboard-navigation";
import { useState } from "react";
import { cn } from "@/lib/utils";
@@ -15,6 +16,7 @@ export function DashboardSidebar() {
const pathname = usePathname();
const router = useRouter();
const [isSigningOut, setIsSigningOut] = useState(false);
const [signOutError, setSignOutError] = useState<string | null>(null);
const { data: session, isPending } = authClient.useSession();
return (
@@ -46,7 +48,7 @@ export function DashboardSidebar() {
<Link
aria-current={isActive ? "page" : undefined}
className={cn(
"flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors",
"flex h-9 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
isActive
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
@@ -70,20 +72,36 @@ export function DashboardSidebar() {
{session?.user?.email ?? "admin@local"}
</p>
</div>
<div className="mb-2">
<DashboardThemeToggle />
</div>
<Button
className="w-full justify-start"
variant="outline"
onClick={async () => {
setIsSigningOut(true);
await authClient.signOut();
router.replace("/login");
router.refresh();
setSignOutError(null);
try {
await authClient.signOut();
router.replace("/login");
router.refresh();
} catch {
setSignOutError("Abmeldung fehlgeschlagen.");
} finally {
setIsSigningOut(false);
}
}}
disabled={isSigningOut}
>
<LogOut />
{isSigningOut ? "Abmeldung..." : "Abmelden"}
{isSigningOut ? "Abmeldung läuft..." : "Abmelden"}
</Button>
{signOutError ? (
<p className="mt-2 text-xs text-destructive" role="status">
{signOutError}
</p>
) : null}
</div>
</aside>
);

View File

@@ -0,0 +1,92 @@
"use client";
import { Moon, Sun } from "lucide-react";
import {
createContext,
type ReactNode,
useContext,
useMemo,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type DashboardTheme = "light" | "dark";
type DashboardThemeContextValue = {
theme: DashboardTheme;
toggleTheme: () => void;
};
const storageKey = "webdev-dashboard-theme";
const DashboardThemeContext =
createContext<DashboardThemeContextValue | null>(null);
export function DashboardThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<DashboardTheme>(() => {
if (typeof window === "undefined") {
return "light";
}
const storedTheme = window.localStorage.getItem(storageKey);
if (storedTheme === "dark" || storedTheme === "light") {
return storedTheme;
}
return "light";
});
const value = useMemo<DashboardThemeContextValue>(
() => ({
theme,
toggleTheme: () => {
setTheme((currentTheme) => {
const nextTheme = currentTheme === "dark" ? "light" : "dark";
window.localStorage.setItem(storageKey, nextTheme);
return nextTheme;
});
},
}),
[theme],
);
return (
<DashboardThemeContext.Provider value={value}>
<div
suppressHydrationWarning
className={cn(
"min-h-dvh bg-background text-foreground md:flex",
theme === "dark" && "dark",
)}
>
{children}
</div>
</DashboardThemeContext.Provider>
);
}
export function DashboardThemeToggle() {
const context = useContext(DashboardThemeContext);
if (!context) {
return null;
}
const isDark = context.theme === "dark";
const Icon = isDark ? Sun : Moon;
return (
<Button
className="w-full justify-start"
variant="ghost"
onClick={context.toggleTheme}
aria-pressed={isDark}
>
<Icon />
{isDark ? "Hellmodus" : "Dunkelmodus"}
</Button>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useQuery } from "convex/react";
import type { FunctionReturnType } from "convex/server";
import { ArrowRight, Building2, MapPin } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
import {
groupLeadFunnelCards,
type LeadFunnelCard,
type LeadFunnelStageId,
} from "@/lib/dashboard-model";
import { cn } from "@/lib/utils";
type LeadFunnelQueryResult = FunctionReturnType<typeof api.leads.listFunnel>;
const stageActionHref: Record<LeadFunnelStageId, string> = {
missing_contact: "/dashboard/leads",
audit_ready: "/dashboard/audits",
review_open: "/dashboard/outreach",
contacted: "/dashboard/outreach",
follow_up: "/dashboard/outreach",
deferred: "/dashboard/leads",
};
export function LeadFunnelBoard() {
const leads: LeadFunnelQueryResult | undefined = useQuery(
api.leads.listFunnel,
{ limit: 100 },
);
if (leads === undefined) {
return <LeadFunnelSkeleton />;
}
const groups = groupLeadFunnelCards(leads);
const totalCards = groups.reduce((total, group) => total + group.cards.length, 0);
if (totalCards === 0) {
return (
<section
className="rounded-lg border bg-card p-6 text-card-foreground"
aria-labelledby="lead-funnel-heading"
>
<p className="text-sm font-medium text-muted-foreground">
Lead-Funnel
</p>
<h2
className="mt-2 text-xl font-semibold tracking-normal"
id="lead-funnel-heading"
>
Noch keine Leads im Arbeitsfluss
</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Sobald Kampagnen Leads erzeugen oder importieren, erscheinen sie hier
nach Kontaktlage, Audit-Stand und Review-Bedarf sortiert.
</p>
</section>
);
}
return (
<section className="grid gap-3" aria-labelledby="lead-funnel-heading">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2
className="text-xl font-semibold tracking-normal"
id="lead-funnel-heading"
>
Lead-Funnel
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{totalCards} Leads nach Kontaktlage, Audit-Stand und nächster
manueller Aktion.
</p>
</div>
<p className="text-sm font-medium text-muted-foreground">
Kein automatischer Versand
</p>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{groups.map((group) => (
<section
className="flex min-h-[24rem] flex-col rounded-lg border bg-card text-card-foreground"
key={group.stage.id}
aria-labelledby={`${group.stage.id}-heading`}
>
<div className="border-b p-3">
<div className="flex items-center justify-between gap-3">
<h3
className="text-sm font-semibold"
id={`${group.stage.id}-heading`}
>
{group.stage.title}
</h3>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
{group.cards.length}
</span>
</div>
<p className="mt-2 text-xs leading-5 text-muted-foreground">
{group.stage.description}
</p>
</div>
<div className="grid gap-2 p-2">
{group.cards.length > 0 ? (
group.cards.map((card) => (
<LeadFunnelCardView card={card} key={card.id} />
))
) : (
<p className="rounded-md border border-dashed p-3 text-xs leading-5 text-muted-foreground">
Keine Leads in dieser Spalte.
</p>
)}
</div>
</section>
))}
</div>
</section>
);
}
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
return (
<article
className="rounded-lg border bg-background p-3"
aria-labelledby={`${card.id}-company`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4
className="truncate text-sm font-semibold"
id={`${card.id}-company`}
>
{card.company}
</h4>
<p className="mt-1 inline-flex max-w-full items-center gap-1 truncate text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
<span className="truncate">{card.niche}</span>
</p>
</div>
<span
className={cn(
"shrink-0 rounded-md px-2 py-1 text-xs font-medium",
card.priorityLabel === "Hoch"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{card.priorityLabel}
</span>
</div>
<p className="mt-3 inline-flex max-w-full items-center gap-1 truncate text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span className="truncate">{card.location}</span>
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<span className="rounded-md bg-secondary px-2 py-1 text-xs text-secondary-foreground">
{card.contactStatusLabel}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
{card.contactDetail}
</span>
</div>
<Link
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-medium text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50"
href={stageActionHref[card.stageId]}
>
{card.nextAction}
<ArrowRight className="size-4" />
</Link>
</article>
);
}
function LeadFunnelSkeleton() {
return (
<section className="grid gap-3" aria-label="Lead-Funnel wird geladen">
<div>
<div className="h-6 w-40 rounded-md bg-muted" />
<div className="mt-2 h-4 w-80 max-w-full rounded-md bg-muted" />
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{Array.from({ length: 6 }, (_, index) => (
<div
className="min-h-[24rem] rounded-lg border bg-card p-3"
key={index}
>
<div className="h-5 w-28 rounded-md bg-muted" />
<div className="mt-4 grid gap-2">
<div className="h-28 rounded-lg bg-muted" />
<div className="h-24 rounded-lg bg-muted" />
</div>
</div>
))}
</div>
</section>
);
}

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>
);
}

41
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-medium",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
outline:
"text-foreground border-border bg-background hover:bg-muted/40",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
type BadgeProps = React.HTMLAttributes<HTMLDivElement> &
VariantProps<typeof badgeVariants>;
const Badge = ({ className, variant, ...props }: BadgeProps) => (
<span
className={cn(
badgeVariants({
variant,
}),
className,
)}
{...props}
/>
);
Badge.displayName = "Badge";
export { Badge, badgeVariants };

74
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,74 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground", className)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-base leading-none font-semibold tracking-normal", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-4 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

106
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,106 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/40", className)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-4 shadow-lg",
className,
)}
{...props}
/>
</DialogPortal>
));
DialogContent.displayName = "DialogContent";
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mb-3 flex items-center justify-between gap-2", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-base font-semibold tracking-normal", className)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = "DialogDescription";
const DialogClose = DialogPrimitive.Close;
const DialogCloseButton = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>((props, ref) => (
<DialogClose
ref={ref}
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Dialog schließen"
asChild
>
<button {...props}>
<X className="size-4" />
</button>
</DialogClose>
));
DialogCloseButton.displayName = "DialogCloseButton";
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
DialogClose,
DialogCloseButton,
};

218
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
type SubmitHandler,
type UseFormReturn,
} from "react-hook-form";
import { cn } from "@/lib/utils";
type FormProps<TFieldValues extends FieldValues> = Omit<
React.FormHTMLAttributes<HTMLFormElement>,
"onSubmit" | "children"
> & {
form: UseFormReturn<TFieldValues>;
onSubmit: SubmitHandler<TFieldValues>;
children: React.ReactNode;
};
const FormItemContext = React.createContext<{ id: string } | null>(null);
type FormFieldContextValue = {
name: string;
};
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null);
const Form = <TFieldValues extends FieldValues>({
form,
onSubmit,
children,
className,
...props
}: FormProps<TFieldValues>) => {
return (
<FormProvider {...form}>
<form
className={cn("w-full space-y-4", className)}
onSubmit={form.handleSubmit(onSubmit)}
{...props}
>
{children}
</form>
</FormProvider>
);
};
const useFormField = () => {
const itemContext = React.useContext(FormItemContext);
const fieldContext = React.useContext(FormFieldContext);
const { getFieldState, formState, control } = useFormContext();
if (!itemContext || !fieldContext) {
throw new Error("useFormField must be used within a <FormField>.");
}
return {
control,
id: itemContext.id,
name: fieldContext.name,
...getFieldState(fieldContext.name, formState),
};
};
const FormField = <
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
>({
...props
}: Omit<ControllerProps<TFieldValues, TName>, "render"> & {
render: ControllerProps<TFieldValues, TName>["render"];
}) => {
return (
<FormFieldContext.Provider value={{ name: String(props.name) }}>
<Controller
control={props.control}
name={props.name}
render={props.render}
/>
</FormFieldContext.Provider>
);
};
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("grid gap-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => {
const { error, id } = useFormField();
return (
<label
ref={ref}
htmlFor={props.htmlFor ?? id}
className={cn("text-sm leading-none font-medium", className)}
style={error ? { color: "var(--destructive)" } : undefined}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const getFormControlAriaDescribedBy = (fieldId: string, hasError: boolean) => {
const descriptionId = `${fieldId}-description`;
const messageId = `${fieldId}-message`;
if (hasError) {
return `${descriptionId} ${messageId}`;
}
return descriptionId;
};
const FormControl = React.forwardRef<
HTMLElement,
React.HTMLAttributes<HTMLElement>
>(({ className, children, ...props }, ref) => {
const { id, error } = useFormField();
const controlId = props.id ?? id;
const control = React.Children.only(children);
if (!React.isValidElement(control)) {
return null;
}
const typedControl = control as React.ReactElement<
React.ClassAttributes<unknown> & Record<string, unknown>
>;
const controlClassName = (typedControl.props as { className?: string })
.className;
return React.cloneElement(typedControl, {
id: controlId,
ref: ref,
className: cn("relative", className, controlClassName),
...props,
"aria-invalid": error ? "true" : "false",
"aria-describedby": getFormControlAriaDescribedBy(
controlId,
!!error?.message,
),
"aria-errormessage": error?.message ? `${controlId}-message` : undefined,
});
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { id } = useFormField();
return (
<p
ref={ref}
id={`${id}-description`}
className={cn("text-xs leading-5 text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { error, id } = useFormField();
if (!error?.message) {
return null;
}
return (
<p
ref={ref}
id={`${id}-message`}
className={cn("text-xs text-destructive", className)}
role="alert"
{...props}
>
{typeof error.message === "string" ? error.message : String(error.message)}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
Form,
useFormField,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
};

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(
"flex h-8 w-full rounded-md border border-input bg-background px-2.5 text-sm text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

19
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn("text-sm font-medium leading-none", className)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

87
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { ChevronDown, Check } from "lucide-react";
import * as Radix from "radix-ui";
import { cn } from "@/lib/utils";
const Select = Radix.Select.Root;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof Radix.Select.Trigger>,
React.ComponentPropsWithoutRef<typeof Radix.Select.Trigger>
>(({ className, children, ...props }, ref) => (
<Radix.Select.Trigger
ref={ref}
className={cn(
"flex h-8 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<Radix.Select.Icon asChild>
<ChevronDown className="size-4" />
</Radix.Select.Icon>
</Radix.Select.Trigger>
));
SelectTrigger.displayName = "SelectTrigger";
const SelectValue = React.forwardRef<
React.ElementRef<typeof Radix.Select.Value>,
React.ComponentPropsWithoutRef<typeof Radix.Select.Value>
>(({ className, ...props }, ref) => (
<Radix.Select.Value
ref={ref}
className={cn("text-sm", className)}
{...props}
/>
));
SelectValue.displayName = "SelectValue";
const SelectContent = React.forwardRef<
React.ElementRef<typeof Radix.Select.Content>,
React.ComponentPropsWithoutRef<typeof Radix.Select.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<Radix.Select.Portal>
<Radix.Select.Content
ref={ref}
position={position}
className={cn(
"z-50 w-[var(--radix-select-trigger-width)] min-w-44 rounded-md border bg-popover text-popover-foreground shadow-md outline-none",
className,
)}
{...props}
>
<Radix.Select.Viewport className="rounded-md p-1">
<Radix.Select.Group>{children}</Radix.Select.Group>
</Radix.Select.Viewport>
</Radix.Select.Content>
</Radix.Select.Portal>
));
SelectContent.displayName = "SelectContent";
const SelectItem = React.forwardRef<
React.ElementRef<typeof Radix.Select.Item>,
React.ComponentPropsWithoutRef<typeof Radix.Select.Item>
>(({ className, children, ...props }, ref) => (
<Radix.Select.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1 text-sm outline-none aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground",
className,
)}
{...props}
>
<Radix.Select.ItemText>{children}</Radix.Select.ItemText>
<Radix.Select.ItemIndicator className="absolute right-2 inline-flex items-center">
<Check className="size-4" />
</Radix.Select.ItemIndicator>
</Radix.Select.Item>
));
SelectItem.displayName = "SelectItem";
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem };

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
className={className}
decorative
{...props}
/>
));
Separator.displayName = "Separator";
export { Separator };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Skeleton = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative overflow-hidden rounded-md bg-muted/60 before:absolute before:inset-0 before:translate-x-[-100%] before:animate-[shimmer_1.5s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent",
className,
)}
{...props}
/>
));
Skeleton.displayName = "Skeleton";
export { Skeleton };

25
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { Switch as SwitchPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-input bg-background p-[2px] transition-all disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb className="block size-5 rounded-full bg-background shadow-sm transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" />
</SwitchPrimitive.Root>
));
Switch.displayName = "Switch";
export { Switch };

View File

@@ -13,6 +13,7 @@ import type * as blacklist from "../blacklist.js";
import type * as campaigns from "../campaigns.js";
import type * as domain from "../domain.js";
import type * as http from "../http.js";
import type * as leadDiscovery from "../leadDiscovery.js";
import type * as leads from "../leads.js";
import type * as outreach from "../outreach.js";
import type * as runs from "../runs.js";
@@ -31,6 +32,7 @@ declare const fullApi: ApiFromModules<{
campaigns: typeof campaigns;
domain: typeof domain;
http: typeof http;
leadDiscovery: typeof leadDiscovery;
leads: typeof leads;
outreach: typeof outreach;
runs: typeof runs;

View File

@@ -1,7 +1,15 @@
import { v } from "convex/values";
import {
normalizeDomain,
normalizeEmailAddress,
normalizePhone,
normalizeText,
} from "../lib/lead-discovery-google";
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(
v.literal("domain"),
@@ -11,8 +19,193 @@ const blacklistType = v.union(
v.literal("google_place_id"),
);
function normalizeBlacklistValue(value: string) {
return value.trim().toLowerCase();
type BlacklistType =
| "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({
@@ -22,11 +215,238 @@ export const create = mutation({
note: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await ctx.db.insert("blacklistEntries", {
...args,
normalizedValue: normalizeBlacklistValue(args.value),
const type = args.type as BlacklistType;
const normalizedValue = normalizeBlacklistValue(type, 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(),
});
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

@@ -1,10 +1,79 @@
import { v } from "convex/values";
import {
CAMPAIGN_COUNTRY_CODE,
CAMPAIGN_COUNTRY_NAME,
CAMPAIGN_RECURRENCES,
CAMPAIGN_STATUSES,
} from "../lib/campaign-form";
import {
calculateNextRunAt,
getCampaignCurrentRunStatus,
} from "../lib/campaign-scheduling";
import {
validateCampaignCreateInput,
validateCampaignUpdateInput,
} from "../lib/campaign-validation";
import { canStartAgentRun, isStalePendingAgentRun } from "../lib/lead-discovery-run";
import { normalizeListLimit } from "./domain";
import { mutation, query } from "./_generated/server";
import { internal } from "./_generated/api";
import { Doc } from "./_generated/dataModel";
import { mutation, query, QueryCtx } from "./_generated/server";
type CampaignDoc = Doc<"campaigns">;
type CampaignWithRunStatus = Omit<CampaignDoc, "lastRunAt"> & {
currentRunStatus: string;
lastRunAt: number | null;
};
const campaignStatus = v.union(
...CAMPAIGN_STATUSES.map((status) => v.literal(status)),
);
const campaignRecurrence = v.union(
...CAMPAIGN_RECURRENCES.map((recurrence) => v.literal(recurrence)),
);
const optionalNextRunAt = v.optional(v.union(v.number(), v.null()));
const limitArg = v.optional(v.number());
function normalizeNextRunAt(args: {
status: CampaignDoc["status"];
recurrence: CampaignDoc["recurrence"];
lastRunAt?: number | null;
now: number;
}): number | null {
return calculateNextRunAt({
status: args.status,
recurrence: args.recurrence,
lastRunAt: args.lastRunAt,
now: args.now,
});
}
async function enrichCampaignWithRunStatus(
ctx: QueryCtx,
campaign: CampaignDoc,
): Promise<CampaignWithRunStatus> {
const latestRun = await ctx.db
.query("agentRuns")
.withIndex("by_campaignId_and_updatedAt", (q) =>
q.eq("campaignId", campaign._id),
)
.order("desc")
.take(1);
const run = latestRun.at(0) ?? null;
return {
...campaign,
currentRunStatus: getCampaignCurrentRunStatus({
campaignStatus: campaign.status,
agentRuns: run ? [run] : [],
}),
lastRunAt: campaign.lastRunAt ?? run?.updatedAt ?? null,
};
}
export const create = mutation({
args: {
name: v.string(),
@@ -18,31 +87,273 @@ export const create = mutation({
radiusKm: v.number(),
maxNewLeadsPerRun: v.number(),
maxAuditsPerRun: v.number(),
recurrence: v.union(
v.literal("manual"),
v.literal("daily"),
v.literal("weekly"),
v.literal("monthly"),
),
status: v.optional(v.union(v.literal("active"), v.literal("paused"))),
nextRunAt: v.optional(v.number()),
recurrence: campaignRecurrence,
status: v.optional(campaignStatus),
countryCode: v.optional(v.literal(CAMPAIGN_COUNTRY_CODE)),
country: v.optional(v.literal(CAMPAIGN_COUNTRY_NAME)),
nextRunAt: optionalNextRunAt,
},
handler: async (ctx, args) => {
const now = Date.now();
const status = args.status ?? "paused";
const sanitized = validateCampaignCreateInput({
status,
recurrence: args.recurrence,
postalCode: args.postalCode,
radiusKm: args.radiusKm,
maxNewLeadsPerRun: args.maxNewLeadsPerRun,
maxAuditsPerRun: args.maxAuditsPerRun,
countryCode: args.countryCode,
country: args.country,
});
return await ctx.db.insert("campaigns", {
...args,
status: args.status ?? "paused",
name: args.name,
categoryMode: args.categoryMode,
category: args.category,
customSearchTerm: args.customSearchTerm,
postalCode: args.postalCode,
region: args.region,
latitude: args.latitude,
longitude: args.longitude,
radiusKm: args.radiusKm,
maxNewLeadsPerRun: args.maxNewLeadsPerRun,
maxAuditsPerRun: args.maxAuditsPerRun,
recurrence: sanitized.recurrence,
status: sanitized.status,
countryCode: sanitized.countryCode,
country: sanitized.country,
nextRunAt:
args.nextRunAt === undefined
? normalizeNextRunAt({
status: sanitized.status,
recurrence: sanitized.recurrence,
now,
})
: args.nextRunAt,
createdAt: now,
updatedAt: now,
});
},
});
export const update = mutation({
args: {
id: v.id("campaigns"),
name: v.optional(v.string()),
categoryMode: v.optional(v.union(v.literal("preset"), v.literal("custom"))),
category: v.optional(v.string()),
customSearchTerm: v.optional(v.string()),
postalCode: v.optional(v.string()),
region: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
radiusKm: v.optional(v.number()),
maxNewLeadsPerRun: v.optional(v.number()),
maxAuditsPerRun: v.optional(v.number()),
recurrence: v.optional(campaignRecurrence),
status: v.optional(campaignStatus),
countryCode: v.optional(v.literal(CAMPAIGN_COUNTRY_CODE)),
country: v.optional(v.literal(CAMPAIGN_COUNTRY_NAME)),
nextRunAt: optionalNextRunAt,
},
handler: async (ctx, args) => {
const now = Date.now();
const campaign = await ctx.db.get(args.id);
if (!campaign) {
throw new Error("Kampagne nicht gefunden.");
}
const sanitized = validateCampaignUpdateInput({
postalCode: args.postalCode,
radiusKm: args.radiusKm,
maxNewLeadsPerRun: args.maxNewLeadsPerRun,
maxAuditsPerRun: args.maxAuditsPerRun,
recurrence: args.recurrence,
status: args.status,
countryCode: args.countryCode,
country: args.country,
});
const patch: Record<string, unknown> = {
updatedAt: now,
countryCode: sanitized.countryCode,
country: sanitized.country,
};
if (args.name !== undefined) {
patch.name = args.name;
}
if (args.categoryMode !== undefined) {
patch.categoryMode = args.categoryMode;
}
if (args.category !== undefined) {
patch.category = args.category;
}
if (args.customSearchTerm !== undefined) {
patch.customSearchTerm = args.customSearchTerm;
}
if (args.postalCode !== undefined) {
patch.postalCode = args.postalCode;
}
if (args.region !== undefined) {
patch.region = args.region;
}
if (args.latitude !== undefined) {
patch.latitude = args.latitude;
}
if (args.longitude !== undefined) {
patch.longitude = args.longitude;
}
if (args.radiusKm !== undefined) {
patch.radiusKm = args.radiusKm;
}
if (args.maxNewLeadsPerRun !== undefined) {
patch.maxNewLeadsPerRun = args.maxNewLeadsPerRun;
}
if (args.maxAuditsPerRun !== undefined) {
patch.maxAuditsPerRun = args.maxAuditsPerRun;
}
if (args.recurrence !== undefined) {
patch.recurrence = sanitized.recurrence;
}
if (args.status !== undefined) {
patch.status = sanitized.status;
}
if (args.nextRunAt !== undefined) {
patch.nextRunAt = args.nextRunAt;
} else if (
(args.status !== undefined && args.status !== campaign.status)
|| (args.recurrence !== undefined && args.recurrence !== campaign.recurrence)
) {
const nextStatus = args.status ?? campaign.status;
const nextRecurrence = args.recurrence ?? campaign.recurrence;
patch.nextRunAt = normalizeNextRunAt({
status: nextStatus,
recurrence: nextRecurrence,
lastRunAt: campaign.lastRunAt,
now,
});
}
await ctx.db.patch(args.id, patch);
return args.id;
},
});
export const setStatus = mutation({
args: {
id: v.id("campaigns"),
status: campaignStatus,
},
handler: async (ctx, args) => {
const now = Date.now();
const campaign = await ctx.db.get(args.id);
if (!campaign) {
throw new Error("Kampagne nicht gefunden.");
}
await ctx.db.patch(args.id, {
status: args.status,
nextRunAt:
args.status === "paused"
? null
: calculateNextRunAt({
recurrence: campaign.recurrence,
status: args.status,
lastRunAt: campaign.lastRunAt,
now,
}),
updatedAt: now,
});
return args.id;
},
});
export const requestRun = mutation({
args: {
id: v.id("campaigns"),
},
handler: async (ctx, args) => {
const now = Date.now();
const campaign = await ctx.db.get(args.id);
if (!campaign) {
throw new Error("Kampagne nicht gefunden.");
}
const possiblyActiveRuns = [
...(await ctx.db
.query("agentRuns")
.withIndex("by_status", (q) => q.eq("status", "pending"))
.take(20)),
...(await ctx.db
.query("agentRuns")
.withIndex("by_status", (q) => q.eq("status", "running"))
.take(1)),
];
const stalePendingRuns = possiblyActiveRuns.filter((run) =>
isStalePendingAgentRun(run, now),
);
for (const staleRun of stalePendingRuns) {
await ctx.db.patch(staleRun._id, {
status: "canceled",
currentStep: "lead_discovery",
errorSummary: "Ausstehender Lauf wurde nach Timeout automatisch abgebrochen.",
finishedAt: now,
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: staleRun._id,
level: "warning",
message: "Ausstehender Lauf wurde nach Timeout automatisch abgebrochen.",
details: [{ label: "Alter Status", value: "pending" }],
createdAt: now,
});
}
if (!canStartAgentRun(possiblyActiveRuns, now)) {
throw new Error("Es läuft bereits ein Agentenlauf.");
}
const runId = await ctx.db.insert("agentRuns", {
type: "campaign",
campaignId: args.id,
status: "pending",
counters: {
leadsFound: 0,
leadsCreated: 0,
auditsCreated: 0,
outreachPrepared: 0,
errors: 0,
},
createdAt: now,
updatedAt: now,
});
await ctx.scheduler.runAfter(0, internal.leadDiscovery.processCampaignRun, {
runId,
});
return runId;
},
});
export const get = query({
args: { id: v.id("campaigns") },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
const campaign = await ctx.db.get(args.id);
if (!campaign) {
return null;
}
return await enrichCampaignWithRunStatus(ctx, campaign);
},
});
@@ -54,16 +365,18 @@ export const list = query({
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
if (args.status) {
let campaigns;
if (args.status !== undefined) {
const status = args.status;
return await ctx.db
campaigns = await ctx.db
.query("campaigns")
.withIndex("by_status", (q) => q.eq("status", status))
.order("desc")
.take(limit);
} else {
campaigns = await ctx.db.query("campaigns").order("desc").take(limit);
}
return await ctx.db.query("campaigns").order("desc").take(limit);
return await Promise.all(campaigns.map((campaign) => enrichCampaignWithRunStatus(ctx, campaign)));
},
});

View File

@@ -12,7 +12,7 @@ const SECRET_KEY_PATTERNS = [
];
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 = [
"new",
"missing_contact",

634
convex/leadDiscovery.ts Normal file
View File

@@ -0,0 +1,634 @@
import { v } from "convex/values";
import {
GOOGLE_PLACES_FIELD_MASK,
buildGeocodingUrl,
getBlacklistLookupValues,
getBlacklistMatches,
getCandidateEmailValues,
getPlacesSearchSpec,
normalizeDomain,
normalizePhone,
normalizeText,
normalizePlacesResponse,
parseGeocodingResponse,
} from "../lib/lead-discovery-google";
import {
buildLeadDiscoveryLeadRecord,
buildLeadDiscoveryCounters,
getLeadDiscoveryPriority,
} from "../lib/lead-discovery-run";
import { calculateNextRunAt } from "../lib/campaign-scheduling";
import { internal } from "./_generated/api";
import { Doc, Id } from "./_generated/dataModel";
import { internalAction, internalMutation } from "./_generated/server";
type CampaignDoc = Doc<"campaigns">;
const nullableString = v.union(v.string(), v.null());
const nullableNumber = v.union(v.number(), v.null());
const candidateValidator = v.object({
placeId: v.string(),
businessName: v.string(),
address: v.string(),
websiteUrl: nullableString,
websiteDomain: nullableString,
phone: nullableString,
rating: nullableNumber,
userRatingCount: nullableNumber,
businessStatus: nullableString,
googleTypes: v.array(v.string()),
googlePrimaryType: 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"),
sourceFetchedAt: v.number(),
});
const eventDetailValidator = v.object({
label: v.string(),
value: v.string(),
source: v.optional(v.string()),
});
function getRequiredEnv(key: string) {
const value = process.env[key]?.trim();
if (!value) {
throw new Error(`${key} ist nicht gesetzt.`);
}
return value;
}
function messageFromError(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function getCampaignNiche(campaign: CampaignDoc) {
if (
campaign.categoryMode === "custom" ||
campaign.category === "Anderes"
) {
return campaign.customSearchTerm?.trim() || campaign.category;
}
return campaign.category;
}
async function fetchJson(url: string, init?: RequestInit) {
const response = await fetch(url, init);
if (!response.ok) {
const body = await response.text();
throw new Error(
`Google API request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
);
}
return await response.json();
}
export const processCampaignRun = internalAction({
args: {
runId: v.id("agentRuns"),
},
handler: async (ctx, args) => {
const started: {
campaign: CampaignDoc;
runId: Id<"agentRuns">;
} | null = await ctx.runMutation(internal.leadDiscovery.startCampaignRun, {
runId: args.runId,
});
if (!started) {
return null;
}
try {
const geocodingApiKey = getRequiredEnv("GOOGLE_GEOCODING_API_KEY");
const placesApiKey = getRequiredEnv("GOOGLE_PLACES_API_KEY");
const campaign = started.campaign;
const fetchedAt = Date.now();
let latitude = campaign.latitude;
let longitude = campaign.longitude;
if (typeof latitude !== "number" || typeof longitude !== "number") {
const geocodingUrl = buildGeocodingUrl({
postalCode: campaign.postalCode,
apiKey: geocodingApiKey,
});
const geocodingJson = await fetchJson(geocodingUrl);
const geocoding = parseGeocodingResponse(geocodingJson, fetchedAt);
latitude = geocoding.latitude;
longitude = geocoding.longitude;
await ctx.runMutation(internal.leadDiscovery.cacheCampaignGeocode, {
campaignId: campaign._id,
latitude,
longitude,
geocodedAt: geocoding.fetchedAt,
geocodingPlaceId: geocoding.placeId,
geocodingFormattedAddress: geocoding.formattedAddress,
});
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "info",
message: "PLZ geocodiert.",
details: [
{ label: "PLZ", value: campaign.postalCode, source: "google_geocoding" },
{
label: "Koordinaten",
value: `${latitude}, ${longitude}`,
source: "google_geocoding",
},
],
});
} else {
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "info",
message: "Geocoding-Cache der Kampagne verwendet.",
details: [
{ label: "PLZ", value: campaign.postalCode },
{ label: "Koordinaten", value: `${latitude}, ${longitude}` },
],
});
}
const searchSpec = getPlacesSearchSpec({
categoryMode: campaign.categoryMode,
category: campaign.category,
customSearchTerm: campaign.customSearchTerm,
postalCode: campaign.postalCode,
radiusKm: campaign.radiusKm,
latitude,
longitude,
});
const placesJson = await fetchJson(
`https://places.googleapis.com/v1/places:${searchSpec.endpoint}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": placesApiKey,
"X-Goog-FieldMask": GOOGLE_PLACES_FIELD_MASK,
},
body: JSON.stringify(searchSpec.body),
},
);
const candidates = normalizePlacesResponse(placesJson, Date.now());
if (candidates.length === 0) {
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "warning",
message: "Google Places lieferte keine Ergebnisse.",
details: [
{ label: "Suchtyp", value: searchSpec.searchType, source: "google_places" },
{ label: "Kategorie", value: getCampaignNiche(campaign), source: "google_places" },
],
});
}
const result: {
leadsFound: number;
leadsCreated: number;
skippedDuplicates: number;
skippedBlacklisted: number;
errors: number;
} = await ctx.runMutation(internal.leadDiscovery.persistDiscoveredLeads, {
runId: args.runId,
campaignId: campaign._id,
maxNewLeads: campaign.maxNewLeadsPerRun,
niche: getCampaignNiche(campaign),
postalCode: campaign.postalCode,
candidates,
});
await ctx.runMutation(internal.leadDiscovery.finishCampaignRun, {
runId: args.runId,
status: "succeeded",
currentStep: "lead_discovery",
counters: buildLeadDiscoveryCounters({
leadsFound: result.leadsFound,
leadsCreated: result.leadsCreated,
errors: result.errors,
}),
});
return result;
} catch (error) {
const errorSummary = messageFromError(error);
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "error",
message: "Lead-Recherche fehlgeschlagen.",
details: [{ label: "Fehler", value: errorSummary }],
});
await ctx.runMutation(internal.leadDiscovery.finishCampaignRun, {
runId: args.runId,
status: "failed",
currentStep: "lead_discovery",
errorSummary,
counters: buildLeadDiscoveryCounters({
leadsFound: 0,
leadsCreated: 0,
errors: 1,
}),
});
return null;
}
},
});
export const startCampaignRun = internalMutation({
args: {
runId: v.id("agentRuns"),
},
handler: async (ctx, args) => {
const now = Date.now();
const run = await ctx.db.get(args.runId);
if (!run || !run.campaignId || run.status !== "pending") {
return null;
}
const activeRunning = await ctx.db
.query("agentRuns")
.withIndex("by_status", (q) => q.eq("status", "running"))
.take(1);
if (activeRunning.length > 0) {
await ctx.db.patch(args.runId, {
status: "canceled",
currentStep: "lead_discovery",
errorSummary: "Ein anderer Agentenlauf ist bereits aktiv.",
finishedAt: now,
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "warning",
message: "Lauf nicht gestartet, weil ein anderer Agentenlauf aktiv ist.",
createdAt: now,
});
return null;
}
const campaign = await ctx.db.get(run.campaignId);
if (!campaign) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "lead_discovery",
errorSummary: "Kampagne nicht gefunden.",
finishedAt: now,
updatedAt: now,
});
return null;
}
await ctx.db.patch(args.runId, {
status: "running",
currentStep: "lead_discovery",
startedAt: now,
updatedAt: now,
});
await ctx.db.patch(campaign._id, {
lastRunAt: now,
nextRunAt: calculateNextRunAt({
recurrence: campaign.recurrence,
status: campaign.status,
lastRunAt: now,
now,
}),
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Lead-Recherche gestartet.",
details: [
{ label: "Kampagne", value: campaign.name },
{ label: "PLZ", value: campaign.postalCode },
],
createdAt: now,
});
return { runId: args.runId, campaign };
},
});
export const cacheCampaignGeocode = internalMutation({
args: {
campaignId: v.id("campaigns"),
latitude: v.number(),
longitude: v.number(),
geocodedAt: v.number(),
geocodingPlaceId: v.string(),
geocodingFormattedAddress: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.campaignId, {
latitude: args.latitude,
longitude: args.longitude,
geocodedAt: args.geocodedAt,
geocodingPlaceId: args.geocodingPlaceId,
geocodingFormattedAddress: args.geocodingFormattedAddress,
updatedAt: Date.now(),
});
},
});
export const appendRunEvent = internalMutation({
args: {
runId: v.id("agentRuns"),
level: v.union(v.literal("info"), v.literal("warning"), v.literal("error")),
message: v.string(),
details: v.optional(v.array(eventDetailValidator)),
},
handler: async (ctx, args) => {
await ctx.db.insert("agentRunEvents", {
...args,
createdAt: Date.now(),
});
},
});
export const persistDiscoveredLeads = internalMutation({
args: {
runId: v.id("agentRuns"),
campaignId: v.id("campaigns"),
maxNewLeads: v.number(),
niche: v.string(),
postalCode: v.string(),
candidates: v.array(candidateValidator),
},
handler: async (ctx, args) => {
const now = Date.now();
let leadsCreated = 0;
let skippedDuplicates = 0;
let skippedBlacklisted = 0;
let errors = 0;
for (const candidate of args.candidates) {
if (leadsCreated >= args.maxNewLeads) {
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Lead-Limit des Laufs erreicht.",
details: [{ label: "Limit", value: String(args.maxNewLeads) }],
createdAt: Date.now(),
});
break;
}
if (!candidate.businessName.trim()) {
errors += 1;
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "warning",
message: "Google-Places-Ergebnis ohne Unternehmensname übersprungen.",
details: [{ label: "Place ID", value: candidate.placeId }],
createdAt: Date.now(),
});
continue;
}
const normalizedPlaceId = normalizeDomain(candidate.placeId);
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
const normalizedEmails = getCandidateEmailValues(candidate);
const normalizedPhone = normalizePhone(candidate.phone);
const normalizedCompanyName = normalizeText(candidate.businessName);
const normalizedAddress = normalizeText(candidate.address);
const duplicateByPlaceId = normalizedPlaceId
? await ctx.db
.query("leads")
.withIndex("by_normalizedGooglePlaceId", (q) =>
q.eq("normalizedGooglePlaceId", normalizedPlaceId),
)
.take(1)
: [];
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;
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Doppelter Lead übersprungen.",
details: [
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
],
createdAt: Date.now(),
});
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 = [];
for (const lookup of getBlacklistLookupValues(candidate)) {
const rows = await ctx.db
.query("blacklistEntries")
.withIndex("by_type_and_normalizedValue", (q) =>
q
.eq("type", lookup.type)
.eq("normalizedValue", lookup.normalizedValue),
)
.take(1);
blacklistRows.push(...rows);
}
const blacklistMatches = getBlacklistMatches(candidate, blacklistRows);
if (blacklistMatches.length > 0) {
skippedBlacklisted += 1;
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "warning",
message: "Gesperrter Lead übersprungen.",
details: blacklistMatches.map((match) => ({
label: match.type,
value: match.value,
source: "blacklist",
})),
createdAt: Date.now(),
});
continue;
}
const lead = buildLeadDiscoveryLeadRecord({
campaignId: args.campaignId,
runId: args.runId,
niche: args.niche,
postalCode: args.postalCode,
candidate,
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);
leadsCreated += 1;
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Lead aus Google Places gespeichert.",
details: [
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
],
createdAt: Date.now(),
});
}
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Lead-Recherche abgeschlossen.",
details: [
{ label: "Gefunden", value: String(args.candidates.length) },
{ label: "Gespeichert", value: String(leadsCreated) },
{ label: "Dubletten übersprungen", value: String(skippedDuplicates) },
{ label: "Sperrliste übersprungen", value: String(skippedBlacklisted) },
],
createdAt: Date.now(),
});
return {
leadsFound: args.candidates.length,
leadsCreated,
skippedDuplicates,
skippedBlacklisted,
errors,
};
},
});
export const finishCampaignRun = internalMutation({
args: {
runId: v.id("agentRuns"),
status: v.union(v.literal("succeeded"), v.literal("failed"), v.literal("canceled")),
currentStep: v.optional(v.string()),
errorSummary: v.optional(v.string()),
counters: v.object({
leadsFound: v.number(),
leadsCreated: v.number(),
auditsCreated: v.number(),
outreachPrepared: v.number(),
errors: v.number(),
}),
},
handler: async (ctx, args) => {
const patch: {
status: typeof args.status;
updatedAt: number;
finishedAt: number;
counters: typeof args.counters;
currentStep?: string;
errorSummary?: string;
} = {
status: args.status,
updatedAt: Date.now(),
finishedAt: Date.now(),
counters: args.counters,
};
if (args.currentStep !== undefined) {
patch.currentStep = args.currentStep;
}
if (args.errorSummary !== undefined) {
patch.errorSummary = args.errorSummary;
}
await ctx.db.patch(args.runId, patch);
},
});

View File

@@ -1,11 +1,97 @@
import { v } from "convex/values";
import { getUsableContactEmailFromEntries } from "../lib/lead-discovery-google";
import { normalizeListLimit } from "./domain";
import type { Doc, Id } from "./_generated/dataModel";
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({
args: {
campaignId: v.optional(v.id("campaigns")),
discoveryRunId: v.optional(v.id("agentRuns")),
companyName: v.string(),
niche: v.optional(v.string()),
address: v.optional(v.string()),
@@ -13,8 +99,20 @@ export const create = mutation({
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())),
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: 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()),
emailSource: v.optional(v.string()),
contactPerson: v.optional(v.string()),
@@ -24,8 +122,10 @@ export const create = mutation({
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"),
@@ -37,6 +137,20 @@ export const create = mutation({
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()),
},
handler: async (ctx, args) => {
@@ -44,16 +158,151 @@ export const create = mutation({
return await ctx.db.insert("leads", {
...args,
normalizedEmail: args.normalizedEmail,
normalizedPhone: args.normalizedPhone,
normalizedCompanyName: args.normalizedCompanyName,
normalizedAddress: args.normalizedAddress,
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
priority: args.priority ?? "medium",
contactStatus: args.contactStatus ?? "new",
duplicateStatus: "unchecked",
blacklistStatus: "clear",
duplicateStatus: args.duplicateStatus ?? "unchecked",
blacklistStatus: args.blacklistStatus ?? "clear",
createdAt: 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({
args: { id: v.id("leads") },
handler: async (ctx, args) => {
@@ -105,3 +354,48 @@ export const list = query({
return await ctx.db.query("leads").order("desc").take(limit);
},
});
export const listFunnel = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const limit = normalizeListLimit(args.limit);
const leads = await ctx.db.query("leads").order("desc").take(limit);
return await Promise.all(
leads.map(async (lead) => {
const outreach = await ctx.db
.query("outreachRecords")
.withIndex("by_leadId", (q) => q.eq("leadId", lead._id))
.order("desc")
.take(1);
const latestOutreach = outreach[0] ?? null;
return {
id: lead._id,
companyName: lead.companyName,
niche: lead.niche ?? null,
address: lead.address ?? null,
city: lead.city ?? null,
postalCode: lead.postalCode ?? null,
priority: lead.priority,
contactStatus: lead.contactStatus,
blacklistStatus: lead.blacklistStatus,
email: lead.email ?? null,
phone: lead.phone ?? null,
contactPerson: lead.contactPerson ?? null,
websiteDomain: lead.websiteDomain ?? null,
outreach: latestOutreach
? {
approvalStatus: latestOutreach.approvalStatus,
sendStatus: latestOutreach.sendStatus,
responseStatus: latestOutreach.responseStatus,
salesStatus: latestOutreach.salesStatus,
}
: null,
};
}),
);
},
});

View File

@@ -8,6 +8,7 @@ const leadPriority = v.union(
v.literal("medium"),
v.literal("low"),
v.literal("defer"),
v.literal("blocked"),
);
const leadContactStatus = v.union(
v.literal("new"),
@@ -125,6 +126,9 @@ export default defineSchema({
region: v.optional(v.string()),
latitude: v.optional(v.number()),
longitude: v.optional(v.number()),
geocodedAt: v.optional(v.number()),
geocodingPlaceId: v.optional(v.string()),
geocodingFormattedAddress: v.optional(v.string()),
radiusKm: v.number(),
maxNewLeadsPerRun: v.number(),
maxAuditsPerRun: v.number(),
@@ -135,8 +139,10 @@ export default defineSchema({
v.literal("monthly"),
),
status: campaignStatus,
countryCode: v.optional(v.string()),
country: v.optional(v.string()),
lastRunAt: v.optional(v.number()),
nextRunAt: v.optional(v.number()),
nextRunAt: v.optional(v.union(v.number(), v.null())),
createdAt: v.number(),
updatedAt: v.number(),
})
@@ -146,18 +152,37 @@ export default defineSchema({
leads: defineTable({
campaignId: v.optional(v.id("campaigns")),
discoveryRunId: v.optional(v.id("agentRuns")),
companyName: v.string(),
niche: v.optional(v.string()),
address: v.optional(v.string()),
city: v.optional(v.string()),
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
normalizedGooglePlaceId: v.optional(v.string()),
googleMapsUrl: v.optional(v.string()),
googlePrimaryType: v.optional(v.string()),
googleTypes: v.optional(v.array(v.string())),
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: 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()),
emailSource: 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,
contactStatus: leadContactStatus,
duplicateStatus: leadDuplicateStatus,
@@ -167,9 +192,18 @@ export default defineSchema({
updatedAt: v.number(),
})
.index("by_campaignId", ["campaignId"])
.index("by_discoveryRunId", ["discoveryRunId"])
.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_websiteDomain", ["websiteDomain"])
.index("by_normalizedCompanyName", ["normalizedCompanyName"])
.index("by_priority_and_contactStatus", ["priority", "contactStatus"]),
audits: defineTable({
@@ -276,6 +310,7 @@ export default defineSchema({
})
.index("by_status", ["status"])
.index("by_type_and_status", ["type", "status"])
.index("by_campaignId_and_updatedAt", ["campaignId", "updatedAt"])
.index("by_campaignId_and_status", ["campaignId", "status"])
.index("by_auditId", ["auditId"]),

117
lib/campaign-form.ts Normal file
View File

@@ -0,0 +1,117 @@
import { z } from "zod/v4";
const CAMPAIGN_NAME_MIN = 3;
const CAMPAIGN_NAME_MAX = 120;
export const CAMPAIGN_COUNTRY_CODE = "DE";
export const CAMPAIGN_COUNTRY_NAME = "Deutschland";
const MIN_RADIUS_KM = 1;
const MAX_RADIUS_KM = 5000;
const MIN_LEADS_PER_RUN = 1;
const MAX_LEADS_PER_RUN = 9999;
const MIN_AUDITS_PER_RUN = 1;
const MAX_AUDITS_PER_RUN = 9999;
export const CAMPAIGN_CATEGORY_MODES = ["preset", "custom"] as const;
export type CampaignCategoryMode = (typeof CAMPAIGN_CATEGORY_MODES)[number];
export const CAMPAIGN_RECURRENCES = [
"manual",
"daily",
"weekly",
"monthly",
] as const;
export type CampaignRecurrence = (typeof CAMPAIGN_RECURRENCES)[number];
export const CAMPAIGN_STATUSES = ["active", "paused"] as const;
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
export const ORTHODOX_POSTAL_CODE_MESSAGE =
"Bitte eine gültige deutsche PLZ (Postleitzahl) mit genau 5 Ziffern angeben.";
const postalCodeSchema = z
.string()
.regex(/^\d{5}$/, ORTHODOX_POSTAL_CODE_MESSAGE);
const nonEmptyString = (label: string) =>
z
.string()
.trim()
.min(1, `${label} ist erforderlich.`);
const positiveBoundedInt = (label: string, min: number, max: number) =>
z
.number({ message: `${label} muss eine Zahl sein.` })
.finite(`${label} muss eine Zahl sein.`)
.min(min, `${label} muss mindestens ${min} sein.`)
.int(`${label} muss eine ganze Zahl sein.`)
.max(max, `${label} muss maximal ${max} sein.`);
export const campaignFormSchema = z
.object({
name: nonEmptyString("Name").min(CAMPAIGN_NAME_MIN).max(CAMPAIGN_NAME_MAX),
categoryMode: z.union(
CAMPAIGN_CATEGORY_MODES.map((value) => z.literal(value)),
{
error: "Bitte zwischen vorgegebener Kategorie oder eigener Kategorie wählen.",
},
),
category: nonEmptyString("Kategorie"),
customSearchTerm: z.string().optional(),
postalCode: postalCodeSchema,
radiusKm: positiveBoundedInt("Radius", MIN_RADIUS_KM, MAX_RADIUS_KM),
maxNewLeadsPerRun: positiveBoundedInt(
"Max. neue Leads",
MIN_LEADS_PER_RUN,
MAX_LEADS_PER_RUN,
),
maxAuditsPerRun: positiveBoundedInt(
"Max. Audits",
MIN_AUDITS_PER_RUN,
MAX_AUDITS_PER_RUN,
),
recurrence: z.union(
CAMPAIGN_RECURRENCES.map((value) => z.literal(value)),
{
error:
"Bitte eine gültige Häufigkeit wählen: manuell, täglich, wöchentlich oder monatlich.",
},
),
status: z.union(
CAMPAIGN_STATUSES.map((value) => z.literal(value)),
{ error: "Status ist ungültig." },
),
})
.superRefine((values, ctx) => {
const needsCustomSearchTerm =
values.categoryMode === "custom" || values.category === "Anderes";
if (needsCustomSearchTerm && !values.customSearchTerm?.trim()) {
ctx.addIssue({
code: "custom",
path: ["customSearchTerm"],
message:
"Für Kategorie 'Anderes' ist ein eigener Suchbegriff erforderlich.",
});
}
});
export const campaignFormDefaults = {
name: "",
status: "active" as CampaignStatus,
categoryMode: "preset" as CampaignCategoryMode,
category: "Anwalt",
customSearchTerm: "",
recurrence: "daily" as CampaignRecurrence,
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
postalCode: "10115",
};
export function mapCampaignFormToPayload(values: Record<string, unknown>) {
return {
...values,
countryCode: CAMPAIGN_COUNTRY_CODE as "DE",
country: CAMPAIGN_COUNTRY_NAME as "Deutschland",
};
}

103
lib/campaign-scheduling.ts Normal file
View File

@@ -0,0 +1,103 @@
import { CAMPAIGN_RECURRENCES, CAMPAIGN_STATUSES } from "./campaign-form";
export type CampaignRecurrence = (typeof CAMPAIGN_RECURRENCES)[number];
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
export type CampaignFormRecurrenceInput = {
recurrence: CampaignRecurrence | (string & {});
status: CampaignStatus;
lastRunAt?: number | null;
now?: number;
};
export type CampaignRunInfo = {
campaignStatus: CampaignStatus;
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
};
export function isAllowedCampaignRecurrence(value: string): boolean {
return CAMPAIGN_RECURRENCES.includes(value as CampaignRecurrence);
}
function addDaysUTC(base: number, days: number): number {
const date = new Date(base);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() + days,
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
}
function addMonthsUTC(base: number, months: number): number {
const date = new Date(base);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth() + months,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
}
export function calculateNextRunAt(input: CampaignFormRecurrenceInput): number | null {
if (input.status !== "active") {
return null;
}
if (!isAllowedCampaignRecurrence(input.recurrence)) {
return null;
}
if (input.recurrence === "manual") {
return null;
}
const anchor = input.lastRunAt ?? input.now ?? Date.now();
if (input.recurrence === "daily") {
return addDaysUTC(anchor, 1);
}
if (input.recurrence === "weekly") {
return addDaysUTC(anchor, 7);
}
return addMonthsUTC(anchor, 1);
}
export function getCampaignCurrentRunStatus(input: CampaignRunInfo): string {
const agentRuns = input.agentRuns ?? [];
if (agentRuns.length > 0) {
const ordered = [...agentRuns].sort((a, b) => {
const aUpdatedAt = typeof a.updatedAt === "number" ? a.updatedAt : 0;
const bUpdatedAt = typeof b.updatedAt === "number" ? b.updatedAt : 0;
return bUpdatedAt - aUpdatedAt;
});
const latestStatus = ordered.at(0)?.status;
if (latestStatus === "running") {
return "running";
}
if (latestStatus === "pending") {
return "pending";
}
if (
latestStatus === "succeeded" ||
latestStatus === "failed" ||
latestStatus === "canceled"
) {
return latestStatus;
}
}
return input.campaignStatus === "paused" ? "paused" : "idle";
}

218
lib/campaign-validation.ts Normal file
View File

@@ -0,0 +1,218 @@
import { CAMPAIGN_COUNTRY_CODE, CAMPAIGN_COUNTRY_NAME, CAMPAIGN_RECURRENCES, CAMPAIGN_STATUSES, ORTHODOX_POSTAL_CODE_MESSAGE } from "./campaign-form";
import { CampaignRecurrence, CampaignStatus } from "./campaign-scheduling";
const CAMPAIGN_POSTAL_CODE_REGEX = /^\d{5}$/;
const MIN_RADIUS_KM = 1;
const MAX_RADIUS_KM = 5000;
const MIN_LEADS_PER_RUN = 1;
const MAX_LEADS_PER_RUN = 9999;
const MIN_AUDITS_PER_RUN = 1;
const MAX_AUDITS_PER_RUN = 9999;
function assert(condition: boolean, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function assertFiniteInteger(
value: number,
fieldLabel: string,
min: number,
max: number,
) {
assert(Number.isFinite(value), `${fieldLabel} muss eine Zahl sein.`);
assert(Number.isInteger(value), `${fieldLabel} muss eine ganze Zahl sein.`);
assert(
value >= min,
`${fieldLabel} muss mindestens ${min} sein.`,
);
assert(
value <= max,
`${fieldLabel} darf höchstens ${max} sein.`,
);
}
function assertAllowed<T extends string>(
value: string,
allowed: readonly T[],
errorMessage: string,
): T {
assert(allowed.includes(value as T), errorMessage);
return value as T;
}
function assertCountryContext(countryCode?: string, country?: string) {
const hasCountryCode = countryCode !== undefined;
const hasCountry = country !== undefined;
assert(
!(hasCountryCode || hasCountry) || (hasCountryCode && hasCountry),
"Deutschland-Kontext muss vollständig gesetzt oder ausgelassen werden.",
);
if (hasCountryCode || hasCountry) {
assert(
countryCode === CAMPAIGN_COUNTRY_CODE && country === CAMPAIGN_COUNTRY_NAME,
"Nur Deutschland-Context ist erlaubt.",
);
}
}
export type CampaignCreatePayload = {
status: CampaignStatus;
recurrence: CampaignRecurrence;
postalCode: string;
radiusKm: number;
maxNewLeadsPerRun: number;
maxAuditsPerRun: number;
countryCode: string;
country: string;
};
export type CampaignUpdatePayload = Partial<
Omit<CampaignCreatePayload, "status" | "recurrence">
> & {
status?: CampaignStatus;
recurrence?: CampaignRecurrence;
countryCode: string;
country: string;
};
type CampaignCreateInput = {
status: string;
recurrence: string;
postalCode: string;
radiusKm: number;
maxNewLeadsPerRun: number;
maxAuditsPerRun: number;
countryCode?: string;
country?: string;
};
type CampaignUpdateInput = {
postalCode?: string;
radiusKm?: number;
maxNewLeadsPerRun?: number;
maxAuditsPerRun?: number;
status?: string;
recurrence?: string;
countryCode?: string;
country?: string;
};
export function validateCampaignCreateInput(
input: CampaignCreateInput,
): CampaignCreatePayload {
assertCountryContext(input.countryCode, input.country);
const status = assertAllowed(
input.status,
CAMPAIGN_STATUSES,
"Status ist ungültig.",
);
const recurrence = assertAllowed(
input.recurrence,
CAMPAIGN_RECURRENCES,
"Frequenz ist ungültig.",
);
assert(
CAMPAIGN_POSTAL_CODE_REGEX.test(input.postalCode),
ORTHODOX_POSTAL_CODE_MESSAGE,
);
assertFiniteInteger(input.radiusKm, "Radius", MIN_RADIUS_KM, MAX_RADIUS_KM);
assertFiniteInteger(
input.maxNewLeadsPerRun,
"Max. neue Leads",
MIN_LEADS_PER_RUN,
MAX_LEADS_PER_RUN,
);
assertFiniteInteger(
input.maxAuditsPerRun,
"Max. Audits",
MIN_AUDITS_PER_RUN,
MAX_AUDITS_PER_RUN,
);
return {
status,
recurrence,
postalCode: input.postalCode,
radiusKm: input.radiusKm,
maxNewLeadsPerRun: input.maxNewLeadsPerRun,
maxAuditsPerRun: input.maxAuditsPerRun,
countryCode: CAMPAIGN_COUNTRY_CODE,
country: CAMPAIGN_COUNTRY_NAME,
};
}
export function validateCampaignUpdateInput(
input: CampaignUpdateInput,
): CampaignUpdatePayload {
const updates: CampaignUpdatePayload = {
countryCode: CAMPAIGN_COUNTRY_CODE,
country: CAMPAIGN_COUNTRY_NAME,
};
assertCountryContext(input.countryCode, input.country);
if (input.status !== undefined) {
updates.status = assertAllowed(
input.status,
CAMPAIGN_STATUSES,
"Status ist ungültig.",
);
}
if (input.recurrence !== undefined) {
updates.recurrence = assertAllowed(
input.recurrence,
CAMPAIGN_RECURRENCES,
"Frequenz ist ungültig.",
);
}
if (input.postalCode !== undefined) {
assert(
CAMPAIGN_POSTAL_CODE_REGEX.test(input.postalCode),
ORTHODOX_POSTAL_CODE_MESSAGE,
);
updates.postalCode = input.postalCode;
}
if (input.radiusKm !== undefined) {
assertFiniteInteger(input.radiusKm, "Radius", MIN_RADIUS_KM, MAX_RADIUS_KM);
updates.radiusKm = input.radiusKm;
}
if (input.maxNewLeadsPerRun !== undefined) {
assertFiniteInteger(
input.maxNewLeadsPerRun,
"Max. neue Leads",
MIN_LEADS_PER_RUN,
MAX_LEADS_PER_RUN,
);
updates.maxNewLeadsPerRun = input.maxNewLeadsPerRun;
}
if (input.maxAuditsPerRun !== undefined) {
assertFiniteInteger(
input.maxAuditsPerRun,
"Max. Audits",
MIN_AUDITS_PER_RUN,
MAX_AUDITS_PER_RUN,
);
updates.maxAuditsPerRun = input.maxAuditsPerRun;
}
if (
input.countryCode !== undefined
|| input.country !== undefined
|| input.status !== undefined
|| input.recurrence !== undefined
|| input.postalCode !== undefined
|| input.radiusKm !== undefined
|| input.maxNewLeadsPerRun !== undefined
|| input.maxAuditsPerRun !== undefined
) {
return updates;
}
return updates;
}

View File

@@ -29,6 +29,319 @@ export type ReviewQueueItem = {
detail: string;
};
export type LeadPriority = "high" | "medium" | "low" | "defer" | "blocked";
export type LeadContactStatus =
| "new"
| "missing_contact"
| "audit_ready"
| "outreach_ready"
| "contacted"
| "replied"
| "do_not_contact";
export type LeadBlacklistStatus = "clear" | "blocked";
export type LeadDuplicateStatus =
| "unchecked"
| "unique"
| "possible_duplicate"
| "duplicate";
export type OutreachApprovalStatus = "draft" | "approved" | "rejected";
export type OutreachSendStatus = "not_sent" | "queued" | "sent" | "failed";
export type OutreachResponseStatus =
| "none"
| "manual_reply_recorded"
| "no_interest"
| "follow_up_needed";
export type OutreachSalesStatus =
| "follow_up_planned"
| "follow_up_sent"
| "reply_received"
| "not_interested"
| "later"
| "meeting_scheduled"
| "proposal_requested"
| "proposal_sent"
| "won"
| "lost"
| "do_not_pursue";
export type LeadFunnelStageId =
| "missing_contact"
| "audit_ready"
| "review_open"
| "contacted"
| "follow_up"
| "deferred";
export type LeadFunnelStage = {
id: LeadFunnelStageId;
title: string;
description: string;
};
export type LeadFunnelOutreach = {
approvalStatus?: OutreachApprovalStatus | null;
sendStatus?: OutreachSendStatus | null;
responseStatus?: OutreachResponseStatus | null;
salesStatus?: OutreachSalesStatus | null;
};
export type LeadFunnelInput = {
id: string;
companyName: string;
niche?: string | null;
address?: string | null;
city?: string | null;
postalCode?: string | null;
priority: LeadPriority;
contactStatus: LeadContactStatus;
blacklistStatus: LeadBlacklistStatus;
email?: string | null;
phone?: string | null;
contactPerson?: string | null;
websiteDomain?: string | null;
outreach?: LeadFunnelOutreach | null;
};
export type LeadFunnelCard = {
id: string;
stageId: LeadFunnelStageId;
company: string;
niche: string;
location: string;
priorityLabel: string;
contactStatusLabel: string;
nextAction: string;
websiteDomain?: string | null;
contactDetail: string;
};
export type LeadFunnelGroup = {
stage: LeadFunnelStage;
cards: LeadFunnelCard[];
};
export const leadFunnelStages: LeadFunnelStage[] = [
{
id: "missing_contact",
title: "Kontakt fehlt",
description: "Leads ohne belastbare E-Mail oder Telefonnummer.",
},
{
id: "audit_ready",
title: "Audit bereit",
description: "Analyse ist vorbereitet und braucht Einordnung.",
},
{
id: "review_open",
title: "Freigabe offen",
description: "Kontaktstrategie, Audit-Link oder Text warten auf Review.",
},
{
id: "contacted",
title: "Kontaktiert",
description: "Erstkontakt ist erfolgt; Antwort wird manuell gepflegt.",
},
{
id: "follow_up",
title: "Follow-up",
description: "Respektvolle Wiedervorlage ohne automatischen Versand.",
},
{
id: "deferred",
title: "Zurückgestellt",
description: "Nicht jetzt kontaktieren oder bewusst pausieren.",
},
];
export const leadPriorityLabels: Record<LeadPriority, string> = {
high: "Hoch",
medium: "Mittel",
low: "Niedrig",
defer: "Zurückstellen",
blocked: "Gesperrt",
};
export const leadContactStatusLabels: Record<LeadContactStatus, string> = {
new: "Neu",
missing_contact: "Kontakt fehlt",
audit_ready: "Audit bereit",
outreach_ready: "Freigabe offen",
contacted: "Kontaktiert",
replied: "Antwort erfasst",
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 {
return {
id: lead.id,
stageId: getLeadFunnelStageId(lead),
company: lead.companyName,
niche: lead.niche ?? "Nische offen",
location: formatLeadLocation(lead),
priorityLabel: getLeadPriorityLabel(lead.priority),
contactStatusLabel: getLeadContactStatusLabel(lead.contactStatus),
nextAction: getLeadNextAction(lead),
websiteDomain: lead.websiteDomain,
contactDetail: formatContactDetail(lead),
};
}
export function groupLeadFunnelCards(
leads: LeadFunnelInput[],
): LeadFunnelGroup[] {
const cards = leads.map(toLeadFunnelCard);
return leadFunnelStages.map((stage) => ({
stage,
cards: cards.filter((card) => card.stageId === stage.id),
}));
}
function getLeadFunnelStageId(lead: LeadFunnelInput): LeadFunnelStageId {
if (
lead.blacklistStatus === "blocked" ||
lead.priority === "defer" ||
lead.priority === "blocked" ||
lead.contactStatus === "do_not_contact"
) {
return "deferred";
}
if (lead.outreach?.responseStatus === "follow_up_needed") {
return "follow_up";
}
if (
lead.outreach?.salesStatus === "follow_up_planned" &&
lead.outreach.sendStatus === "sent"
) {
return "follow_up";
}
if (
lead.contactStatus === "contacted" ||
lead.contactStatus === "replied" ||
lead.outreach?.sendStatus === "sent"
) {
return "contacted";
}
if (
lead.contactStatus === "outreach_ready" ||
lead.outreach?.approvalStatus === "draft"
) {
return "review_open";
}
if (lead.contactStatus === "audit_ready") {
return "audit_ready";
}
return "missing_contact";
}
function getLeadNextAction(lead: LeadFunnelInput): string {
const stageId = getLeadFunnelStageId(lead);
if (stageId === "deferred") {
return "Zurückstellung prüfen";
}
if (stageId === "follow_up") {
return "Follow-up manuell prüfen";
}
if (stageId === "contacted") {
return "Antwortstatus nachtragen";
}
if (stageId === "review_open") {
return "Freigabe im Review öffnen";
}
if (stageId === "audit_ready") {
return "Audit prüfen";
}
return "Kontaktquelle recherchieren";
}
function formatLeadLocation(lead: LeadFunnelInput): string {
if (lead.postalCode && lead.city) {
return `${lead.postalCode} ${lead.city}`;
}
return lead.city ?? lead.postalCode ?? lead.address ?? "Ort offen";
}
function formatContactDetail(lead: LeadFunnelInput): string {
const details = [lead.email, lead.phone].filter(Boolean);
if (details.length > 0) {
return details.join(" · ");
}
return "Keine Kontaktdaten";
}
export const pipelineStages: PipelineStage[] = [
{
title: "Kampagnen",
@@ -46,9 +359,9 @@ export const pipelineStages: PipelineStage[] = [
},
{
title: "Audit-Freigabe",
description: "Interne Audits warten auf manuelle Pruefung.",
description: "Interne Audits warten auf manuelle Prüfung.",
count: 6,
meta: "2 Seiten bereit zur Veroeffentlichung",
meta: "2 Seiten bereit zur Veröffentlichung",
icon: ShieldCheck,
},
{
@@ -67,7 +380,7 @@ export const dashboardKpis: DashboardKpi[] = [
detail: "aus 3 aktiven Kampagnen",
},
{
label: "Audit-Entwuerfe",
label: "Audit-Entwürfe",
value: "6",
detail: "manuelle Freigabe offen",
},

View File

@@ -17,12 +17,12 @@ export type DashboardNavigationItem = {
};
export const dashboardNavigation: DashboardNavigationItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Campaigns", href: "/dashboard/campaigns", icon: MapPinned },
{ label: "Übersicht", href: "/dashboard", icon: LayoutDashboard },
{ label: "Kampagnen", href: "/dashboard/campaigns", icon: MapPinned },
{ label: "Leads", href: "/dashboard/leads", icon: UsersRound },
{ label: "Audits", href: "/dashboard/audits", icon: FileSearch },
{ label: "Outreach", href: "/dashboard/outreach", icon: MailCheck },
{ label: "Review", href: "/dashboard/outreach", icon: MailCheck },
{ label: "Analytics", href: "/dashboard/analytics", icon: BarChart3 },
{ label: "Blacklist", href: "/dashboard/blacklist", icon: OctagonMinus },
{ label: "Settings", href: "/dashboard/settings", icon: Settings },
{ label: "Sperrliste", href: "/dashboard/blacklist", icon: OctagonMinus },
{ label: "Einstellungen", href: "/dashboard/settings", icon: Settings },
];

View File

@@ -0,0 +1,673 @@
export const GOOGLE_PLACES_FIELD_MASK =
"places.id,places.displayName,places.formattedAddress,places.websiteUri,places.nationalPhoneNumber,places.internationalPhoneNumber,places.rating,places.userRatingCount,places.businessStatus,places.types,places.primaryType,places.googleMapsUri";
type CampaignLike = {
categoryMode?: "preset" | "custom";
category?: string | null;
customSearchTerm?: string | null;
postalCode: string;
radiusKm: number;
latitude?: number;
longitude?: number;
};
type PlacesNearbyBody = {
includedTypes: string[];
maxResultCount: number;
locationRestriction: {
circle: {
center: {
latitude: number;
longitude: number;
};
radius: number;
};
};
};
type PlacesTextBody = {
textQuery: string;
maxResultCount: number;
locationBias?: {
circle: {
center: {
latitude: number;
longitude: number;
};
radius: number;
};
};
};
type PlacesSearchBody = PlacesNearbyBody | PlacesTextBody;
export type PlacesSearchSpec = {
searchType: "nearby" | "text";
endpoint: "searchNearby" | "searchText";
body: PlacesSearchBody;
};
const GOOGLE_PLACES_MAX_RESULTS = 20;
const PRESET_CATEGORY_TYPES: Record<string, string> = {
Anwalt: "lawyer",
Restaurant: "restaurant",
Café: "cafe",
Cafe: "cafe",
Friseur: "hair_salon",
Zahnarzt: "dentist",
Physiotherapie: "physiotherapist",
};
function ensureRadiusMeters(radiusKm: number) {
if (typeof radiusKm !== "number" || !Number.isFinite(radiusKm)) {
throw new Error("Radius must be a finite number.");
}
return Math.round(radiusKm * 1000);
}
function normalizeCustomSearchTerm(value?: string | null) {
return (value ?? "").trim();
}
export function getPlacesSearchSpec(campaignLike: CampaignLike): PlacesSearchSpec {
const category = normalizeCustomSearchTerm(campaignLike.category);
const isCustomSearch =
campaignLike.categoryMode === "custom" || category === "Anderes";
const isNearbyPreset =
campaignLike.categoryMode !== "custom" && category in PRESET_CATEGORY_TYPES;
if (isNearbyPreset) {
const latitude = campaignLike.latitude;
const longitude = campaignLike.longitude;
if (typeof latitude !== "number" || typeof longitude !== "number") {
throw new Error("Nearby places search requires latitude and longitude.");
}
return {
searchType: "nearby",
endpoint: "searchNearby",
body: {
includedTypes: [PRESET_CATEGORY_TYPES[category]!],
maxResultCount: GOOGLE_PLACES_MAX_RESULTS,
locationRestriction: {
circle: {
center: {
latitude,
longitude,
},
radius: ensureRadiusMeters(campaignLike.radiusKm),
},
},
},
};
}
const baseTerm = isCustomSearch
? normalizeCustomSearchTerm(campaignLike.customSearchTerm)
: category;
const locationQuerySuffix = campaignLike.postalCode
? `${campaignLike.postalCode} Deutschland`
: "Deutschland";
const fallbackTerm = baseTerm
? `${baseTerm} in ${locationQuerySuffix}`
: `Unternehmen in ${locationQuerySuffix}`;
const textBody: PlacesTextBody = {
textQuery: fallbackTerm,
maxResultCount: GOOGLE_PLACES_MAX_RESULTS,
};
if (
typeof campaignLike.latitude === "number" &&
typeof campaignLike.longitude === "number"
) {
textBody.locationBias = {
circle: {
center: {
latitude: campaignLike.latitude,
longitude: campaignLike.longitude,
},
radius: ensureRadiusMeters(campaignLike.radiusKm),
},
};
}
return {
searchType: "text",
endpoint: "searchText",
body: textBody,
};
}
type LegacyGeocodingResponse = {
status: string;
results?: Array<{
geometry?: {
location?: {
lat?: unknown;
lng?: unknown;
};
};
formatted_address?: string;
place_id?: string;
}>;
};
export type GeocodingCoordinates = {
latitude: number;
longitude: number;
formattedAddress: string;
placeId: string;
fetchedAt: number;
};
export function buildGeocodingUrl({
postalCode,
apiKey,
}: {
postalCode: string;
apiKey: string;
}): string {
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.set("address", `${postalCode}, Deutschland`);
url.searchParams.set("components", `country:DE|postal_code:${postalCode}`);
url.searchParams.set("language", "de");
url.searchParams.set("region", "de");
url.searchParams.set("key", apiKey);
return url.toString();
}
export function parseGeocodingResponse(
response: LegacyGeocodingResponse,
fetchedAt: number,
) {
if (!response || response.status !== "OK") {
throw new Error(`Geocoding failed with status "${response?.status ?? "unknown"}".`);
}
const firstResult = response.results?.[0];
if (!firstResult) {
throw new Error("Geocoding returned no results.");
}
const latitude = firstResult.geometry?.location?.lat;
const longitude = firstResult.geometry?.location?.lng;
const formattedAddress = firstResult.formatted_address;
const placeId = firstResult.place_id;
if (typeof latitude !== "number" || !Number.isFinite(latitude)) {
throw new Error("Geocoding result is missing latitude.");
}
if (typeof longitude !== "number" || !Number.isFinite(longitude)) {
throw new Error("Geocoding result is missing longitude.");
}
if (!formattedAddress) {
throw new Error("Geocoding result is missing formatted address.");
}
if (!placeId) {
throw new Error("Geocoding result is missing place id.");
}
return {
latitude,
longitude,
formattedAddress,
placeId,
fetchedAt,
};
}
type GooglePlaceDisplayName =
| string
| {
text?: string;
};
type GooglePlaceContactEmailSource = {
email: string;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
};
type GooglePlaceApiPlace = {
id?: string;
displayName?: GooglePlaceDisplayName;
formattedAddress?: string;
websiteUri?: string;
nationalPhoneNumber?: string;
internationalPhoneNumber?: string;
rating?: number;
userRatingCount?: number;
businessStatus?: string;
types?: string[];
primaryType?: string;
googleMapsUri?: string;
};
export type GooglePlacesApiResponse = {
places?: GooglePlaceApiPlace[] | null;
};
export type GooglePlaceCandidate = {
placeId: string;
businessName: string;
address: string;
websiteUrl: string | null;
websiteDomain: string | null;
phone: string | null;
email?: string | null;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
contactEmails?: GooglePlaceContactEmailSource[];
rating: number | null;
userRatingCount: number | null;
businessStatus: string | null;
googleTypes: string[];
googlePrimaryType: string | null;
googleMapsUrl: string | null;
sourceProvider: "google_places";
sourceFetchedAt: number;
};
function normalizeDisplayName(value?: GooglePlaceDisplayName) {
if (typeof value === "string") {
return value.trim();
}
return value?.text?.trim() ?? "";
}
function normalizeNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function normalizeWebsiteDomain(input?: string | null) {
if (!input) {
return null;
}
try {
const url = new URL(input);
const host = url.hostname.toLowerCase();
return host.replace(/^www\./, "");
} catch {
return null;
}
}
const GENERIC_BUSINESS_EMAIL_LOCAL_PARTS = new Set([
"info",
"kontakt",
"hello",
"hallo",
"office",
"post",
"service",
"team",
"anfrage",
]);
export function normalizeText(value?: string | null) {
return value?.trim().toLowerCase().replace(/\s+/g, " ") ?? "";
}
export function normalizeEmailAddress(value?: string | null) {
const valueTrimmed = value?.trim().toLowerCase();
if (!valueTrimmed) {
return null;
}
const [localPart, domain] = valueTrimmed.split("@");
if (!localPart || !domain) {
return null;
}
if (!/^[a-z0-9._%+-]+$/.test(localPart)) {
return null;
}
if (!/^[^\s@]+\.[^\s@]+$/.test(domain)) {
return null;
}
return valueTrimmed;
}
export type UsableContactEmail = {
email: string;
emailSource: string | null;
contactPerson: string | null;
};
type ParsedContactEmail = {
email: string;
emailSource: string | null;
contactPerson: string | null;
isBusinessContactAddress: boolean;
isGeneric: boolean;
};
type ContactEmailRuleInput = {
email: string;
emailSource?: string | null;
contactPerson?: string | null;
isBusinessContactAddress?: boolean;
};
export function getUsableContactEmailFromEntries(
entries: ContactEmailRuleInput[] | undefined,
) {
if (!Array.isArray(entries) || entries.length === 0) {
return null;
}
const parsedEntries: ParsedContactEmail[] = [];
for (const emailEntry of entries) {
const normalized = normalizeEmailAddress(emailEntry.email);
if (!normalized) {
continue;
}
parsedEntries.push({
email: normalized,
emailSource: emailEntry.emailSource ?? null,
contactPerson: emailEntry.contactPerson ?? null,
isBusinessContactAddress: emailEntry.isBusinessContactAddress === true,
isGeneric: isGenericBusinessEmail(normalized),
});
}
const generic = parsedEntries.find((entry) => entry.isGeneric);
if (generic) {
return {
email: generic.email,
emailSource: generic.emailSource,
contactPerson: generic.contactPerson,
};
}
const named = parsedEntries.find((entry) => entry.isBusinessContactAddress);
if (!named) {
return null;
}
return {
email: named.email,
emailSource: named.emailSource,
contactPerson: named.contactPerson,
};
}
function getCandidateEmailMetadata(candidate: GooglePlaceCandidate) {
const emails: GooglePlaceContactEmailSource[] = [];
if (candidate.email) {
emails.push({
email: candidate.email,
emailSource: candidate.emailSource,
contactPerson: candidate.contactPerson,
isBusinessContactAddress: candidate.isBusinessContactAddress,
});
}
if (Array.isArray(candidate.contactEmails)) {
emails.push(...candidate.contactEmails);
}
return emails;
}
export function getCandidateEmailValues(candidate: GooglePlaceCandidate) {
return getCandidateEmailMetadata(candidate)
.map((entry) => normalizeEmailAddress(entry.email))
.filter((value): value is string => value !== null);
}
function splitEmailLocalPart(email: string) {
const [localPart] = email.split("@");
return localPart?.split("+")[0] ?? "";
}
function isGenericBusinessEmail(email: string) {
const normalizedLocalPart = splitEmailLocalPart(email).toLowerCase();
return GENERIC_BUSINESS_EMAIL_LOCAL_PARTS.has(normalizedLocalPart);
}
export function getUsableContactEmail(
candidate: GooglePlaceCandidate,
): UsableContactEmail | null {
return getUsableContactEmailFromEntries(
getCandidateEmailMetadata(candidate).map((entry) => ({
email: entry.email,
emailSource: entry.emailSource,
contactPerson: entry.contactPerson,
isBusinessContactAddress: entry.isBusinessContactAddress,
})),
);
}
export function normalizePlacesResponse(
response: GooglePlacesApiResponse,
fetchedAt: number,
): GooglePlaceCandidate[] {
const places = Array.isArray(response?.places) ? response.places : [];
return places.flatMap((place) => {
if (!place || !place.id) {
return [];
}
const websiteUrl = place.websiteUri?.trim() ?? null;
const candidate: GooglePlaceCandidate = {
placeId: place.id,
businessName: normalizeDisplayName(place.displayName),
address: place.formattedAddress?.trim() ?? "",
websiteUrl,
websiteDomain: normalizeWebsiteDomain(websiteUrl),
phone: place.nationalPhoneNumber ?? place.internationalPhoneNumber ?? null,
rating: normalizeNumber(place.rating),
userRatingCount: normalizeNumber(place.userRatingCount),
businessStatus: place.businessStatus?.trim() ?? null,
googleTypes: Array.isArray(place.types) ? place.types : [],
googlePrimaryType: place.primaryType?.trim() ?? null,
googleMapsUrl: place.googleMapsUri?.trim() ?? null,
sourceProvider: "google_places",
sourceFetchedAt: fetchedAt,
};
return [candidate];
});
}
export type ExistingLeadLike = {
googlePlaceId?: string | null;
websiteDomain?: string | null;
email?: string | null;
companyName?: string | null;
address?: string | null;
phone?: string | null;
};
export type BlacklistRow = {
type: "domain" | "email" | "phone" | "company" | "google_place_id";
value: string;
normalizedValue: string;
};
export type BlacklistLookupValue = {
type: "domain" | "email" | "phone" | "company" | "google_place_id";
normalizedValue: string;
};
export function normalizeDomain(value?: string | null) {
return value?.trim().toLowerCase().replace(/^www\./, "") ?? "";
}
export function normalizePhone(value?: string | null) {
if (!value) {
return "";
}
const digits = value.replace(/\D+/g, "");
if (digits.startsWith("00")) {
return digits.slice(2);
}
return digits;
}
function uniqueLookupValues(values: BlacklistLookupValue[]) {
const seen = new Set<string>();
return values.filter((value) => {
const key = `${value.type}:${value.normalizedValue}`;
if (!value.normalizedValue || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
export function getBlacklistLookupValues(
candidate: GooglePlaceCandidate,
): BlacklistLookupValue[] {
const emailAddresses = getCandidateEmailValues(candidate);
return uniqueLookupValues([
{
type: "google_place_id",
normalizedValue: normalizeDomain(candidate.placeId),
},
{
type: "domain",
normalizedValue: normalizeDomain(candidate.websiteDomain),
},
{
type: "company",
normalizedValue: normalizeText(candidate.businessName),
},
{
type: "phone",
normalizedValue: normalizePhone(candidate.phone),
},
{
type: "phone",
normalizedValue: normalizeDomain(candidate.phone),
},
...emailAddresses.map((email) => ({
type: "email" as const,
normalizedValue: email ?? "",
})),
]);
}
export function isDuplicateCandidate(
candidate: GooglePlaceCandidate,
existing: ExistingLeadLike[],
): boolean {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateEmails = getCandidateEmailValues(candidate);
return existing.some((entry) => {
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
const entryDomain = normalizeDomain(entry.websiteDomain);
const entryEmail = normalizeEmailAddress(entry.email);
return (
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
(candidateDomain && entryDomain === candidateDomain) ||
candidateEmails.some(
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
)
);
});
}
export function isProbableDuplicateCandidate(
candidate: GooglePlaceCandidate,
existing: ExistingLeadLike[],
): boolean {
const candidateCompany = normalizeText(candidate.businessName);
const candidateAddress = normalizeText(candidate.address);
const candidatePhone = normalizePhone(candidate.phone);
return existing.some((entry) => {
const entryCompany = normalizeText(entry.companyName);
const entryAddress = normalizeText(entry.address);
const entryPhone = normalizePhone(entry.phone);
const isSameCompanyAndAddress =
candidateCompany &&
candidateAddress &&
entryCompany &&
entryAddress &&
candidateCompany === entryCompany &&
candidateAddress === entryAddress;
const isSamePhone = candidatePhone && entryPhone && candidatePhone === entryPhone;
return isSameCompanyAndAddress || isSamePhone;
});
}
export function getBlacklistMatches(
candidate: GooglePlaceCandidate,
blacklistRows: BlacklistRow[],
) {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateCompany = normalizeText(candidate.businessName);
const candidatePhone = normalizePhone(candidate.phone);
return blacklistRows.filter((row) => {
if (!row.normalizedValue) {
return false;
}
switch (row.type) {
case "google_place_id":
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
case "domain":
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
case "company":
return (
candidateCompany !== "" && row.normalizedValue === candidateCompany
);
case "phone":
return (
candidatePhone !== "" &&
(row.normalizedValue === candidatePhone ||
normalizePhone(row.value) === candidatePhone)
);
case "email":
return getCandidateEmailValues(candidate).some(
(candidateEmail) => candidateEmail === row.normalizedValue,
);
default:
return false;
}
});
}

263
lib/lead-discovery-run.ts Normal file
View File

@@ -0,0 +1,263 @@
import {
normalizePhone,
normalizeText,
getUsableContactEmail,
type GooglePlaceCandidate,
} from "./lead-discovery-google";
import type { Id } from "../convex/_generated/dataModel";
type AgentRunLike = {
status: string;
updatedAt?: number;
};
type LeadDiscoveryCounterInput = {
leadsFound: number;
leadsCreated: number;
errors: number;
};
type LeadDiscoveryContactInput = {
usableEmail?: string | null;
};
export type LeadDiscoveryPriority = "high" | "medium" | "low" | "defer" | "blocked";
type LeadDiscoveryPriorityInput = {
isBlacklisted?: boolean;
isDuplicate?: boolean;
hasWebsite?: boolean;
hasWebsiteSignal?: boolean;
};
type LeadDiscoveryLeadRecordInput<TCampaignId extends string, TRunId extends string> = {
campaignId: TCampaignId;
runId: TRunId;
niche: string;
postalCode: string;
candidate: GooglePlaceCandidate;
now: number;
};
function optionalString(value: string | null) {
return value && value.trim().length > 0 ? value : undefined;
}
function optionalNumber(value: number | null) {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
export const PENDING_AGENT_RUN_GRACE_MS = 10 * 60 * 1000;
export function isStalePendingAgentRun(run: AgentRunLike, now: number) {
const updatedAt = typeof run.updatedAt === "number" ? run.updatedAt : 0;
return (
run.status === "pending" &&
updatedAt > 0 &&
now - updatedAt > PENDING_AGENT_RUN_GRACE_MS
);
}
export function canStartAgentRun(runs: AgentRunLike[], now = Date.now()) {
return !runs.some((run) => {
if (run.status === "running") {
return true;
}
return run.status === "pending" && !isStalePendingAgentRun(run, now);
});
}
export function buildLeadDiscoveryCounters(input: LeadDiscoveryCounterInput) {
return {
leadsFound: input.leadsFound,
leadsCreated: input.leadsCreated,
auditsCreated: 0,
outreachPrepared: 0,
errors: input.errors,
};
}
export function getLeadDiscoveryContactStatus(
input: LeadDiscoveryContactInput,
) {
if (input.usableEmail) {
return "new";
}
return "missing_contact";
}
export function buildLeadDiscoveryLeadRecord<
TCampaignId extends string,
TRunId extends string,
>(input: LeadDiscoveryLeadRecordInput<TCampaignId, TRunId>) {
type LeadDiscoveryDuplicateStatus =
| "unchecked"
| "unique"
| "possible_duplicate"
| "duplicate";
const usableEmail = getUsableContactEmail(input.candidate);
const lead: {
campaignId: TCampaignId;
discoveryRunId: TRunId;
companyName: string;
niche: string;
address: string;
postalCode: string;
googlePlaceId: string;
googleMapsUrl?: string;
googlePrimaryType?: string;
googleTypes: string[];
googleRating?: number;
googleUserRatingCount?: number;
googleBusinessStatus?: string;
sourceProvider: "google_places";
sourceFetchedAt: number;
websiteUrl?: string;
websiteDomain?: string;
phone?: string;
normalizedGooglePlaceId?: string;
normalizedEmail?: string;
normalizedPhone?: string;
normalizedCompanyName?: string;
normalizedAddress?: string;
email?: string;
emailSource?: string;
contactPerson?: string;
priorityReason?: string;
duplicateReason?: string;
duplicateOfLeadId?: Id<"leads">;
blacklistReason?: string;
priority: LeadDiscoveryPriority;
contactStatus: "new" | "missing_contact";
duplicateStatus: LeadDiscoveryDuplicateStatus;
blacklistStatus: "clear";
createdAt: number;
updatedAt: number;
} = {
campaignId: input.campaignId,
discoveryRunId: input.runId,
companyName: input.candidate.businessName,
niche: input.niche,
address: input.candidate.address,
postalCode: input.postalCode,
googlePlaceId: input.candidate.placeId,
googleTypes: input.candidate.googleTypes,
sourceProvider: input.candidate.sourceProvider,
sourceFetchedAt: input.candidate.sourceFetchedAt,
priority: "medium",
contactStatus: getLeadDiscoveryContactStatus({
usableEmail: usableEmail?.email,
}),
duplicateStatus: "unique",
blacklistStatus: "clear",
createdAt: input.now,
updatedAt: input.now,
};
const googleMapsUrl = optionalString(input.candidate.googleMapsUrl);
const googlePrimaryType = optionalString(input.candidate.googlePrimaryType);
const googleRating = optionalNumber(input.candidate.rating);
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
const websiteUrl = optionalString(input.candidate.websiteUrl);
const websiteDomain = optionalString(input.candidate.websiteDomain);
const phone = optionalString(input.candidate.phone);
const normalizedPhone = normalizePhone(phone);
const normalizedCompanyName = normalizeText(input.candidate.businessName);
const normalizedAddress = normalizeText(input.candidate.address);
if (normalizedCompanyName !== "") {
lead.normalizedCompanyName = normalizedCompanyName;
}
if (normalizedAddress !== "") {
lead.normalizedAddress = normalizedAddress;
}
if (normalizedPhone !== "") {
lead.normalizedPhone = normalizedPhone;
}
if (googleMapsUrl !== undefined) {
lead.googleMapsUrl = googleMapsUrl;
}
if (googlePrimaryType !== undefined) {
lead.googlePrimaryType = googlePrimaryType;
}
if (googleRating !== undefined) {
lead.googleRating = googleRating;
}
if (googleUserRatingCount !== undefined) {
lead.googleUserRatingCount = googleUserRatingCount;
}
if (googleBusinessStatus !== undefined) {
lead.googleBusinessStatus = googleBusinessStatus;
}
if (websiteUrl !== undefined) {
lead.websiteUrl = websiteUrl;
}
if (websiteDomain !== undefined) {
lead.websiteDomain = websiteDomain;
}
if (phone !== undefined) {
lead.phone = phone;
}
if (usableEmail) {
lead.normalizedEmail = usableEmail.email;
lead.email = usableEmail.email;
if (usableEmail.emailSource !== null) {
lead.emailSource = usableEmail.emailSource;
}
if (usableEmail.contactPerson !== null) {
lead.contactPerson = usableEmail.contactPerson;
}
} else {
lead.contactStatus = "missing_contact";
}
return lead;
}
export function getLeadDiscoveryPriority(
input: LeadDiscoveryPriorityInput,
): { priority: LeadDiscoveryPriority; reason: string } {
if (input.isBlacklisted) {
return {
priority: "blocked",
reason: "Lead ist auf der Sperrliste.",
};
}
if (input.isDuplicate) {
return {
priority: "defer",
reason: "Dublettenprüfung oder Reviewpause.",
};
}
if (!input.hasWebsite) {
return {
priority: "high",
reason: "Kein Website-Indikator vorhanden.",
};
}
if (input.hasWebsiteSignal) {
return {
priority: "low",
reason: "Website vorhanden: geringer Kontaktaufwand.",
};
}
return {
priority: "medium",
reason: "Standardpriorität.",
};
}

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"@convex-dev/better-auth": "^0.12.2",
"@hookform/resolvers": "^5.4.0",
"better-auth": "^1.6.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -21,9 +22,11 @@
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-hook-form": "^7.77.0",
"shadcn": "^4.10.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

42
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@convex-dev/better-auth':
specifier: ^0.12.2
version: 0.12.2(@standard-schema/spec@1.1.0)(better-auth@1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(convex@1.40.0(react@19.2.4))(hono@4.12.23)(react@19.2.4)(typescript@5.9.3)
'@hookform/resolvers':
specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.77.0(react@19.2.4))
better-auth:
specifier: ^1.6.14
version: 1.6.14(next@16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -38,6 +41,9 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
react-hook-form:
specifier: ^7.77.0
version: 7.77.0(react@19.2.4)
shadcn:
specifier: ^4.10.0
version: 4.10.0(@types/node@20.19.41)(typescript@5.9.3)
@@ -47,6 +53,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
zod:
specifier: ^4.4.3
version: 4.4.3
devDependencies:
'@tailwindcss/postcss':
specifier: ^4
@@ -528,6 +537,11 @@ packages:
peerDependencies:
hono: ^4
'@hookform/resolvers@5.4.0':
resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
peerDependencies:
react-hook-form: ^7.55.0
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1585,6 +1599,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -3614,6 +3631,12 @@ packages:
peerDependencies:
react: ^19.2.4
react-hook-form@7.77.0:
resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -4382,7 +4405,7 @@ snapshots:
'@better-fetch/fetch': 1.1.21
'@opentelemetry/semantic-conventions': 1.41.1
'@standard-schema/spec': 1.1.0
better-call: 1.3.5(zod@3.25.76)
better-call: 1.3.5(zod@4.4.3)
jose: 6.2.3
kysely: 0.29.2
nanostores: 1.3.0
@@ -4624,6 +4647,11 @@ snapshots:
dependencies:
hono: 4.12.23
'@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.4))':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.77.0(react@19.2.4)
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -5645,6 +5673,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -6067,7 +6097,7 @@ snapshots:
'@better-fetch/fetch': 1.1.21
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
better-call: 1.3.5(zod@3.25.76)
better-call: 1.3.5(zod@4.4.3)
defu: 6.1.7
jose: 6.2.3
kysely: 0.29.2
@@ -6081,14 +6111,14 @@ snapshots:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
better-call@1.3.5(zod@3.25.76):
better-call@1.3.5(zod@4.4.3):
dependencies:
'@better-auth/utils': 0.4.1
'@better-fetch/fetch': 1.1.21
rou3: 0.7.12
set-cookie-parser: 3.1.0
optionalDependencies:
zod: 3.25.76
zod: 4.4.3
body-parser@2.2.2:
dependencies:
@@ -7754,6 +7784,10 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-hook-form@7.77.0(react@19.2.4):
dependencies:
react: 19.2.4
react-is@16.13.1: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.4):

265
tests/campaign-form.test.ts Normal file
View File

@@ -0,0 +1,265 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as campaignForm from "../lib/campaign-form";
type CampaignFormModule = {
campaignFormSchema: {
safeParse: (value: unknown) => {
success: boolean;
data?: Record<string, unknown>;
error?: {
flatten?: () => { fieldErrors: Record<string, string[] | undefined> };
issues?: Array<{
path?: Array<string | number>;
message?: string;
}>;
};
};
};
mapCampaignFormToPayload: (values: Record<string, unknown>) => Record<string, unknown>;
};
type ErrorLike = CampaignFormModule["campaignFormSchema"]["safeParse"] extends (
value: unknown,
) => infer Result
? Result
: never;
const formModule = campaignForm as unknown as CampaignFormModule;
function toString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function extractFieldMessages(
result: ErrorLike,
field: string,
): string[] {
const messages: string[] = [];
const flatten = result.error?.flatten?.();
const flattenedMessages = flatten?.fieldErrors?.[field];
if (flattenedMessages && Array.isArray(flattenedMessages)) {
for (const message of flattenedMessages) {
if (typeof message === "string" && message.length > 0) {
messages.push(message);
}
}
}
for (const issue of result.error?.issues ?? []) {
const isTargetField = issue.path?.at(-1) === field;
if (
isTargetField &&
typeof issue.message === "string" &&
issue.message.length > 0
) {
messages.push(issue.message);
}
}
if (messages.length === 0) {
const fallback = JSON.stringify(result.error);
if (fallback.length > 2) {
messages.push(fallback);
}
}
return messages;
}
function createMinimalValidForm(overrides: Record<string, unknown> = {}) {
return {
name: "Malerbetrieb Konstruktiv",
categoryMode: "preset",
category: "Maler",
customSearchTerm: "",
postalCode: "79098",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
recurrence: "daily",
status: "active",
...overrides,
};
}
test("valid minimal campaign form maps to a Germany-only payload", () => {
const schemaResult = formModule.campaignFormSchema.safeParse(
createMinimalValidForm(),
);
assert.equal(schemaResult.success, true);
const payload = formModule.mapCampaignFormToPayload(
schemaResult.data as Record<string, unknown>,
) as Record<string, unknown>;
const germanyIndicators = [
payload.countryCode,
payload.country,
payload.region,
];
const hasGermany = germanyIndicators.some((value) => {
const normalized = toString(value)?.toLowerCase();
return (
normalized === "de" ||
normalized === "deutschland" ||
normalized?.includes("deutschland")
);
});
assert.equal(
hasGermany,
true,
"Mapped payload should enforce Germany-only context",
);
});
test("German PLZ validation enforces exactly five digits with clear German feedback", () => {
const schema = formModule.campaignFormSchema;
const invalidPostcodes = [
"",
"1234",
"123456",
"abcde",
"12 34",
];
for (const postalCode of invalidPostcodes) {
const result = schema.safeParse(createMinimalValidForm({ postalCode }));
assert.equal(result.success, false);
const messages = extractFieldMessages(result as ErrorLike, "postalCode");
assert.ok(messages.length > 0, "Expected a validation message for postal code");
const messageText = messages.join(" ");
assert.match(
messageText,
/PLZ|Postleitzahl/i,
`Expected postal-code message in German semantics, got: ${messageText}`,
);
assert.match(
messageText,
/5|fünf|5-stellig|stellig/i,
`Expected explicit five-digit guidance, got: ${messageText}`,
);
}
});
test("predefined categories do not require custom niche input", () => {
const preset = createMinimalValidForm({
categoryMode: "preset",
category: "Maler",
customSearchTerm: "",
});
const result = formModule.campaignFormSchema.safeParse(preset);
assert.equal(result.success, true, "Predefined category should parse without custom term");
});
test("Anderes/custom category requires a non-empty custom niche field", () => {
const anderes = formModule.campaignFormSchema.safeParse(
createMinimalValidForm({
categoryMode: "custom",
category: "Anderes",
customSearchTerm: "",
}),
);
assert.equal(anderes.success, false);
const messages = extractFieldMessages(anderes as ErrorLike, "customSearchTerm");
assert.ok(messages.length > 0, "Expected a validation message for missing custom term");
const messageText = messages.join(" ");
assert.match(
messageText,
/Anderes|eigene|benötigt|erforderlich|muss/i,
`Expected explicit guidance for Anderes custom term, got: ${messageText}`,
);
});
test("radius and lead/audit limits reject invalid zero, negative, and out-of-range values", () => {
const schema = formModule.campaignFormSchema;
const fieldLimits: Array<{ field: "radiusKm" | "maxNewLeadsPerRun" | "maxAuditsPerRun"; invalid: number[] }> = [
{ field: "radiusKm", invalid: [0, -1, 10000] },
{ field: "maxNewLeadsPerRun", invalid: [0, -1, 10000] },
{ field: "maxAuditsPerRun", invalid: [0, -1, 10000] },
];
for (const { field, invalid } of fieldLimits) {
for (const value of invalid) {
const values = createMinimalValidForm({ [field]: value });
const result = schema.safeParse(values);
assert.equal(result.success, false);
const messages = extractFieldMessages(result as ErrorLike, field);
assert.ok(
messages.length > 0,
`Expected validation for invalid value on ${field}: ${value}`,
);
const messageText = messages.join(" ");
assert.match(
messageText,
/muss|mindestens|zwischen|gültig|positiv/i,
`Expected German limit/range explanation for ${field}, got: ${messageText}`,
);
}
}
});
test("radius and lead/audit limits reject decimal values", () => {
const schema = formModule.campaignFormSchema;
const fieldLimits: Array<{ field: "radiusKm" | "maxNewLeadsPerRun" | "maxAuditsPerRun"; invalid: number[] }> = [
{ field: "radiusKm", invalid: [10.5, 0.1, 12.99] },
{ field: "maxNewLeadsPerRun", invalid: [1.9, 5.25] },
{ field: "maxAuditsPerRun", invalid: [0.01, 2.75] },
];
for (const { field, invalid } of fieldLimits) {
for (const value of invalid) {
const values = createMinimalValidForm({ [field]: value });
const result = schema.safeParse(values);
assert.equal(result.success, false);
const messages = extractFieldMessages(result as ErrorLike, field);
assert.ok(
messages.length > 0,
`Expected validation for decimal value on ${field}: ${value}`,
);
const messageText = messages.join(" ");
assert.match(
messageText,
/Ganzzahl|ganze|integer|Nachkommastellen|dezim|komma/i,
`Expected decimal-rejection guidance for ${field}, got: ${messageText}`,
);
}
}
});
test("recurrence/cadence only accepts manual, daily, weekly, monthly", () => {
const schema = formModule.campaignFormSchema;
const validValues = ["manual", "daily", "weekly", "monthly"] as const;
const invalidValues = ["hourly", "biweekly", "yearly", ""] as const;
for (const recurrence of validValues) {
const valid = schema.safeParse(createMinimalValidForm({ recurrence }));
assert.equal(valid.success, true, `Expected recurrence ${recurrence} to parse`);
}
for (const recurrence of invalidValues) {
const invalid = schema.safeParse(createMinimalValidForm({ recurrence }));
assert.equal(invalid.success, false, `Expected recurrence ${recurrence} to be rejected`);
const messages = extractFieldMessages(invalid as ErrorLike, "recurrence");
assert.ok(messages.length > 0);
const messageText = messages.join(" ");
assert.match(
messageText,
/manuell|täglich|wöchentlich|monatlich|Auswahl|gültig/i,
`Expected actionable German cadence guidance, got: ${messageText}`,
);
}
});

View File

@@ -0,0 +1,270 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as campaignScheduling from "../lib/campaign-scheduling";
type CampaignSchedulingModule = {
calculateNextRunAt: (input: {
recurrence: string;
status: "active" | "paused";
lastRunAt?: number | null;
now?: number;
}) => number | null;
getCampaignCurrentRunStatus: (
input: {
campaignStatus: "active" | "paused";
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
},
) => string;
isAllowedCampaignRecurrence?: (value: string) => boolean;
CAMPAIGN_RECURRENCES?: readonly string[];
};
type SchedulingRun = {
status: string;
updatedAt?: number;
};
const schedulingModule = campaignScheduling as unknown as CampaignSchedulingModule;
function addDaysUTC(timestamp: number, days: number) {
const date = new Date(timestamp);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() + days,
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
}
function addMonthsUTC(timestamp: number, months: number) {
const date = new Date(timestamp);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth() + months,
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCMilliseconds(),
);
}
function expectFunction<T>(value: unknown, name: string): T {
assert.equal(
typeof value,
"function",
`Expected ${name} to be implemented and exported`,
);
return value as T;
}
function runWithLatest(updatedRuns: SchedulingRun[]) {
return updatedRuns.sort((a, b) => {
const aTime = typeof a.updatedAt === "number" ? a.updatedAt : 0;
const bTime = typeof b.updatedAt === "number" ? b.updatedAt : 0;
return bTime - aTime;
});
}
test("recurrence validation only allows manual/daily/weekly/monthly", () => {
const allowed = ["manual", "daily", "weekly", "monthly"];
const forbidden = ["hourly", "2xweekly", "biweekly", ""];
if (typeof schedulingModule.isAllowedCampaignRecurrence === "function") {
for (const value of allowed) {
assert.equal(
schedulingModule.isAllowedCampaignRecurrence(value),
true,
`${value} should be accepted`,
);
}
for (const value of forbidden) {
assert.equal(
schedulingModule.isAllowedCampaignRecurrence(value),
false,
`${value} should be rejected`,
);
}
return;
}
assert.ok(
Array.isArray(schedulingModule.CAMPAIGN_RECURRENCES),
"Expected recurrence validation helper or CAMPAIGN_RECURRENCES list",
);
assert.deepEqual(
[...schedulingModule.CAMPAIGN_RECURRENCES!].sort(),
[...allowed].sort(),
);
});
test("next run is null for manual or paused campaigns and scheduled for active recurring campaigns", () => {
const calculateNextRunAt = expectFunction<(input: {
recurrence: string;
status: "active" | "paused";
lastRunAt?: number | null;
now?: number;
}) => number | null>(schedulingModule.calculateNextRunAt, "calculateNextRunAt");
const lastRunAt = Date.UTC(2026, 5, 1, 8, 0, 0);
assert.equal(
calculateNextRunAt({
recurrence: "manual",
status: "active",
lastRunAt,
}),
null,
);
assert.equal(
calculateNextRunAt({
recurrence: "daily",
status: "paused",
lastRunAt,
}),
null,
);
const dailyNext = calculateNextRunAt({
recurrence: "daily",
status: "active",
lastRunAt,
});
const weeklyNext = calculateNextRunAt({
recurrence: "weekly",
status: "active",
lastRunAt,
});
const monthlyNext = calculateNextRunAt({
recurrence: "monthly",
status: "active",
lastRunAt,
});
assert.equal(dailyNext, addDaysUTC(lastRunAt, 1));
assert.equal(weeklyNext, addDaysUTC(lastRunAt, 7));
assert.equal(monthlyNext, addMonthsUTC(lastRunAt, 1));
});
test("run status favors running/pending over finished states", () => {
const getCampaignCurrentRunStatus = expectFunction<
(input: {
campaignStatus: "active" | "paused";
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
}) => string
>(schedulingModule.getCampaignCurrentRunStatus, "getCampaignCurrentRunStatus");
const activeWithRunningAndFinished = runWithLatest([
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 2, 10, 0, 0) },
{ status: "running", updatedAt: Date.UTC(2026, 5, 2, 10, 5, 0) },
{ status: "failed", updatedAt: Date.UTC(2026, 5, 2, 9, 50, 0) },
]);
const runningStatus = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: activeWithRunningAndFinished,
});
assert.equal(
runningStatus,
"running",
"Active running campaign should surface running as current status",
);
const pendingOverFinished = runWithLatest([
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 2, 10, 0, 0) },
{ status: "pending", updatedAt: Date.UTC(2026, 5, 2, 10, 5, 0) },
{ status: "failed", updatedAt: Date.UTC(2026, 5, 2, 9, 50, 0) },
]);
const pendingStatus = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: pendingOverFinished,
});
assert.equal(
pendingStatus,
"pending",
"Pending should outrank finished statuses when no running is active",
);
const pausedWithoutRuns = getCampaignCurrentRunStatus({
campaignStatus: "paused",
agentRuns: [],
});
assert.equal(
pausedWithoutRuns,
"paused",
"Paused campaigns should not report campaign activity by default",
);
});
test("run status uses latest run first (running, pending, otherwise terminal)", () => {
const getCampaignCurrentRunStatus = expectFunction<
(input: {
campaignStatus: "active" | "paused";
agentRuns?: Array<{
status: string;
updatedAt?: number;
}>;
}) => string
>(schedulingModule.getCampaignCurrentRunStatus, "getCampaignCurrentRunStatus");
const activeWithoutRuns = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: [],
});
assert.equal(activeWithoutRuns, "idle");
const unsortedRuns = [
{ status: "failed", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 8, 0, 0) },
{ status: "failed", updatedAt: Date.UTC(2026, 5, 1, 7, 0, 0) },
];
const latestRunWins = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: unsortedRuns,
});
assert.equal(
latestRunWins,
"failed",
"Latest status should determine current status, not any older run",
);
const unsortedPending = [
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
{ status: "pending", updatedAt: Date.UTC(2026, 5, 1, 9, 5, 0) },
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 1, 7, 0, 0) },
];
const latestPending = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: unsortedPending,
});
assert.equal(
latestPending,
"pending",
"Pending latest run should be surfaced when it is the most recent.",
);
const unsortedRunning = [
{ status: "succeeded", updatedAt: Date.UTC(2026, 5, 1, 9, 0, 0) },
{ status: "running", updatedAt: Date.UTC(2026, 5, 1, 10, 5, 0) },
{ status: "pending", updatedAt: Date.UTC(2026, 5, 1, 8, 0, 0) },
];
const latestRunning = getCampaignCurrentRunStatus({
campaignStatus: "active",
agentRuns: unsortedRunning,
});
assert.equal(
latestRunning,
"running",
"Running should be surfaced when it is the latest run.",
);
});

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
validateCampaignCreateInput,
validateCampaignUpdateInput,
} from "../lib/campaign-validation";
test("campaign mutation validation normalizes and enforces fixed Germany context", () => {
const payload = validateCampaignCreateInput({
status: "active",
recurrence: "daily",
postalCode: "10115",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
});
assert.equal(payload.countryCode, "DE");
assert.equal(payload.country, "Deutschland");
});
test("campaign validation rejects invalid German PLZ", () => {
assert.throws(
() =>
validateCampaignCreateInput({
status: "active",
recurrence: "daily",
postalCode: "1234",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) => {
return (
error instanceof Error &&
error.message.includes("5") &&
/PLZ|Postleitzahl/i.test(error.message)
);
},
);
});
test("campaign validation rejects decimal limits", () => {
assert.throws(
() =>
validateCampaignCreateInput({
status: "active",
recurrence: "daily",
postalCode: "10115",
radiusKm: 10.5,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) => {
return error instanceof Error && error.message.includes("ganze");
},
);
});
test("campaign validation rejects invalid recurrence/status in German", () => {
assert.throws(
() =>
validateCampaignCreateInput({
status: "running",
recurrence: "daily",
postalCode: "10115",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) => error instanceof Error && error.message.includes("Status"),
);
assert.throws(
() =>
validateCampaignCreateInput({
status: "active",
recurrence: "hourly",
postalCode: "10115",
radiusKm: 10,
maxNewLeadsPerRun: 5,
maxAuditsPerRun: 5,
}),
(error: unknown) =>
error instanceof Error && /Frequenz|ungültig/.test(error.message),
);
});
test("campaign update validation rejects partial Germany-context payloads", () => {
assert.throws(
() =>
validateCampaignUpdateInput({
countryCode: "DE",
}),
(error: unknown) =>
error instanceof Error &&
/vollständig|Deutschland-Kontext/.test(error.message),
);
});

View File

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

View File

@@ -2,9 +2,20 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
getLeadBlacklistStatusLabel,
getLeadContactStatusLabel,
getLeadDuplicateStatusLabel,
getLeadPriorityLabel,
leadBlacklistStatusOptions,
leadContactStatusOptions,
leadDuplicateStatusOptions,
leadPriorityOptions,
dashboardKpis,
dashboardNavigation,
groupLeadFunnelCards,
leadFunnelStages,
pipelineStages,
toLeadFunnelCard,
reviewQueue,
} from "../lib/dashboard-model";
@@ -16,14 +27,14 @@ test("dashboardNavigation contains the expected sidebar routes in order", () =>
assert.deepEqual(
dashboardNavigation.map((item: NavigationItem) => [item.label, item.href]),
[
["Dashboard", "/dashboard"],
["Campaigns", "/dashboard/campaigns"],
["Übersicht", "/dashboard"],
["Kampagnen", "/dashboard/campaigns"],
["Leads", "/dashboard/leads"],
["Audits", "/dashboard/audits"],
["Outreach", "/dashboard/outreach"],
["Review", "/dashboard/outreach"],
["Analytics", "/dashboard/analytics"],
["Blacklist", "/dashboard/blacklist"],
["Settings", "/dashboard/settings"],
["Sperrliste", "/dashboard/blacklist"],
["Einstellungen", "/dashboard/settings"],
],
);
});
@@ -39,6 +50,145 @@ test("pipelineStages keep the first-screen workflow focused on pipeline overview
);
});
test("leadFunnelStages expose the agreed German funnel columns", () => {
assert.deepEqual(
leadFunnelStages.map((stage) => stage.title),
[
"Kontakt fehlt",
"Audit bereit",
"Freigabe offen",
"Kontaktiert",
"Follow-up",
"Zurückgestellt",
],
);
});
test("toLeadFunnelCard exposes scan data and derives missing contact next action", () => {
const card = toLeadFunnelCard({
id: "lead-1",
companyName: "Malerbetrieb Klein",
niche: "Maler",
city: "Freiburg",
postalCode: "79098",
priority: "high",
contactStatus: "missing_contact",
blacklistStatus: "clear",
});
assert.equal(card.stageId, "missing_contact");
assert.equal(card.company, "Malerbetrieb Klein");
assert.equal(card.niche, "Maler");
assert.equal(card.location, "79098 Freiburg");
assert.equal(card.priorityLabel, "Hoch");
assert.equal(card.contactStatusLabel, "Kontakt fehlt");
assert.equal(card.nextAction, "Kontaktquelle recherchieren");
});
test("groupLeadFunnelCards derives review, follow-up, and deferred columns without schema migration", () => {
const groups = groupLeadFunnelCards([
{
id: "lead-review",
companyName: "Physio am Park",
city: "Freiburg",
priority: "medium",
contactStatus: "outreach_ready",
blacklistStatus: "clear",
outreach: {
approvalStatus: "draft",
sendStatus: "not_sent",
responseStatus: "none",
salesStatus: "follow_up_planned",
},
},
{
id: "lead-follow-up",
companyName: "Tischlerei Weber",
city: "Emmendingen",
priority: "medium",
contactStatus: "contacted",
blacklistStatus: "clear",
outreach: {
approvalStatus: "approved",
sendStatus: "sent",
responseStatus: "follow_up_needed",
salesStatus: "follow_up_planned",
},
},
{
id: "lead-replied",
companyName: "Salon Licht",
city: "Freiburg",
priority: "low",
contactStatus: "replied",
blacklistStatus: "clear",
},
{
id: "lead-defer",
companyName: "Cafe Morgen",
city: "Basel",
priority: "defer",
contactStatus: "new",
blacklistStatus: "clear",
},
]);
assert.deepEqual(
groups.map((group) => [group.stage.id, group.cards.map((card) => card.id)]),
[
["missing_contact", []],
["audit_ready", []],
["review_open", ["lead-review"]],
["contacted", ["lead-replied"]],
["follow_up", ["lead-follow-up"]],
["deferred", ["lead-defer"]],
],
);
});
test("toLeadFunnelCard maps blocked priority to deferred stage with blocker label", () => {
const card = toLeadFunnelCard({
id: "lead-blocked",
companyName: "Sperr Beispiel",
city: "Freiburg",
priority: "blocked",
contactStatus: "new",
blacklistStatus: "blocked",
});
assert.equal(card.stageId, "deferred");
assert.equal(card.priorityLabel, "Gesperrt");
assert.equal(card.nextAction, "Zurückstellung prüfen");
});
test("dashboard-model exposes stable lead label helpers for UI mapping", () => {
assert.deepEqual(leadPriorityOptions, [
"high",
"medium",
"low",
"defer",
"blocked",
]);
assert.equal(getLeadPriorityLabel("high"), "Hoch");
assert.equal(getLeadContactStatusLabel("missing_contact"), "Kontakt fehlt");
assert.equal(getLeadBlacklistStatusLabel("blocked"), "Gesperrt");
});
test("dashboard-model exposes duplicate status options and labels", () => {
assert.deepEqual(leadDuplicateStatusOptions, [
"unchecked",
"unique",
"possible_duplicate",
"duplicate",
]);
assert.equal(getLeadDuplicateStatusLabel("duplicate"), "Duplikat");
});
test("dashboard-model exposes contact status options for lead review controls", () => {
assert.equal(leadContactStatusOptions[1], "missing_contact");
assert.equal(leadBlacklistStatusOptions.length, 2);
});
test("dashboardKpis and reviewQueue expose the above-the-fold dashboard summary", () => {
assert.equal(dashboardKpis.length, 4);
assert.equal(reviewQueue.length, 3);

View File

@@ -0,0 +1,699 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
GOOGLE_PLACES_FIELD_MASK,
buildGeocodingUrl,
getUsableContactEmail,
getUsableContactEmailFromEntries,
getBlacklistMatches,
getBlacklistLookupValues,
getPlacesSearchSpec,
isProbableDuplicateCandidate,
isDuplicateCandidate,
normalizeEmailAddress,
normalizePlacesResponse,
parseGeocodingResponse,
} from "../lib/lead-discovery-google";
test("places search spec maps known presets to nearby search and converts radius to meters", () => {
const nearbySpec = getPlacesSearchSpec({
categoryMode: "preset",
category: "Anwalt",
postalCode: "10115",
latitude: 52.52,
longitude: 13.405,
radiusKm: 12,
});
assert.equal(nearbySpec.searchType, "nearby");
assert.equal(nearbySpec.endpoint, "searchNearby");
if (nearbySpec.searchType !== "nearby" || nearbySpec.endpoint !== "searchNearby") {
throw new Error("Expected nearby search spec for preset category.");
}
const nearbyBody = nearbySpec.body as {
includedTypes: string[];
locationRestriction: {
circle: {
center: {
latitude: number;
longitude: number;
};
radius: number;
};
};
};
assert.deepEqual(nearbyBody.includedTypes, ["lawyer"]);
assert.equal(
nearbyBody.locationRestriction.circle.radius,
12_000,
);
assert.equal(nearbyBody.locationRestriction.circle.center.latitude, 52.52);
assert.equal(nearbyBody.locationRestriction.circle.center.longitude, 13.405);
});
test("places search spec uses text search for custom/Anderes and includes query context", () => {
const customSpec = getPlacesSearchSpec({
categoryMode: "custom",
category: "Anderes",
customSearchTerm: "Barber Shop für Hunde",
postalCode: "80331",
latitude: 48.137,
longitude: 11.575,
radiusKm: 5,
});
assert.equal(customSpec.searchType, "text");
assert.equal(customSpec.endpoint, "searchText");
if (customSpec.searchType !== "text" || customSpec.endpoint !== "searchText") {
throw new Error("Expected text search spec for custom/Anderes.");
}
const customBody = customSpec.body as {
textQuery: string;
locationBias?: {
circle: {
center: { latitude: number; longitude: number };
radius: number;
};
};
};
assert.equal(
customBody.textQuery,
"Barber Shop für Hunde in 80331 Deutschland",
);
assert.deepEqual(customBody.locationBias, {
circle: {
center: { latitude: 48.137, longitude: 11.575 },
radius: 5_000,
},
});
const handwerkSpec = getPlacesSearchSpec({
categoryMode: "preset",
category: "Handwerk",
customSearchTerm: "ignored",
postalCode: "80331",
radiusKm: 5,
});
assert.equal(handwerkSpec.searchType, "text");
assert.equal(handwerkSpec.endpoint, "searchText");
if (handwerkSpec.searchType !== "text" || handwerkSpec.endpoint !== "searchText") {
throw new Error("Expected text search spec for unmapped preset category.");
}
const handwerkBody = handwerkSpec.body as { textQuery: string };
assert.equal(handwerkBody.textQuery, "Handwerk in 80331 Deutschland");
});
test("geocoding URL includes API key, DE region, and components filter", () => {
const url = new URL(
buildGeocodingUrl({ postalCode: "40210", apiKey: "geocode-key-123" }),
);
assert.equal(
url.origin + url.pathname,
"https://maps.googleapis.com/maps/api/geocode/json",
);
assert.equal(url.searchParams.get("address"), "40210, Deutschland");
assert.equal(url.searchParams.get("components"), "country:DE|postal_code:40210");
assert.equal(url.searchParams.get("language"), "de");
assert.equal(url.searchParams.get("region"), "de");
assert.equal(url.searchParams.get("key"), "geocode-key-123");
});
test("geocoding parser extracts location from OK response and rejects ZERO_RESULTS", () => {
const ok = parseGeocodingResponse(
{
status: "OK",
results: [
{
formatted_address: "Berlin, 10115 Berlin, Deutschland",
place_id: "place-id-1",
geometry: {
location: {
lat: 52.5170365,
lng: 13.3888599,
},
},
},
],
},
1717480000000,
);
assert.equal(ok.latitude, 52.5170365);
assert.equal(ok.longitude, 13.3888599);
assert.equal(ok.formattedAddress, "Berlin, 10115 Berlin, Deutschland");
assert.equal(ok.placeId, "place-id-1");
assert.equal(ok.fetchedAt, 1717480000000);
assert.throws(
() =>
parseGeocodingResponse(
{ status: "ZERO_RESULTS", results: [] },
1717480000123,
),
(error: unknown) =>
error instanceof Error &&
/ZERO_RESULTS|no geocoding results/i.test(error.message),
);
});
test("places normalization maps source metadata and normalizes website domain", () => {
const normalized = normalizePlacesResponse(
{
places: [
{
id: "place-1",
displayName: { text: "Beispiel Café" },
formattedAddress: "Hauptstraße 1, 60311 Frankfurt am Main, Deutschland",
websiteUri: "https://www.Example.De/some-path",
nationalPhoneNumber: "+49 30 123456",
internationalPhoneNumber: "+49 49 654321",
rating: 4.6,
userRatingCount: 42,
businessStatus: "OPERATIONAL",
types: ["restaurant", "cafe"],
primaryType: "restaurant",
googleMapsUri: "https://maps.google.com/place-id-1",
},
],
},
1717480001000,
);
assert.equal(normalized.length, 1);
assert.deepEqual(normalized[0], {
placeId: "place-1",
businessName: "Beispiel Café",
address: "Hauptstraße 1, 60311 Frankfurt am Main, Deutschland",
websiteUrl: "https://www.Example.De/some-path",
websiteDomain: "example.de",
phone: "+49 30 123456",
rating: 4.6,
userRatingCount: 42,
businessStatus: "OPERATIONAL",
googleTypes: ["restaurant", "cafe"],
googlePrimaryType: "restaurant",
googleMapsUrl: "https://maps.google.com/place-id-1",
sourceProvider: "google_places",
sourceFetchedAt: 1717480001000,
});
assert.equal(
GOOGLE_PLACES_FIELD_MASK,
"places.id,places.displayName,places.formattedAddress,places.websiteUri,places.nationalPhoneNumber,places.internationalPhoneNumber,places.rating,places.userRatingCount,places.businessStatus,places.types,places.primaryType,places.googleMapsUri",
);
});
test("duplicate detection uses placeId and websiteDomain", () => {
const existingLeads = [
{
googlePlaceId: "dup-1",
websiteDomain: "other.de",
email: "blocked@example.de",
},
{ googlePlaceId: "other-2", websiteDomain: "example.de", email: "blocked@example.de" },
];
assert.equal(
isDuplicateCandidate(
{
placeId: "dup-1",
businessName: "Test",
address: "A",
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(
isDuplicateCandidate(
{
placeId: "none",
businessName: "Test",
address: "A",
websiteUrl: "https://www.example.de",
websiteDomain: "example.de",
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceFetchedAt: 0,
},
existingLeads,
),
true,
);
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,
},
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,
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", () => {
const candidate = {
placeId: "place-blacklisted",
businessName: "Muster GmbH",
address: "A",
websiteUrl: "https://www.Blocked.de",
websiteDomain: "blocked.de",
phone: "+49 30 555 123",
email: "Info@Blocked.De",
contactEmails: [{ email: "Hello@blocked.de", isBusinessContactAddress: false }],
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places" as const,
sourceFetchedAt: 0,
};
assert.deepEqual(getBlacklistLookupValues(candidate), [
{ type: "google_place_id", normalizedValue: "place-blacklisted" },
{ type: "domain", normalizedValue: "blocked.de" },
{ type: "company", normalizedValue: "muster gmbh" },
{ type: "phone", normalizedValue: "4930555123" },
{ type: "phone", normalizedValue: "+49 30 555 123" },
{ type: "email", normalizedValue: "info@blocked.de" },
{ type: "email", normalizedValue: "hello@blocked.de" },
]);
const matches = getBlacklistMatches(
candidate,
[
{
type: "google_place_id",
value: "place-blacklisted",
normalizedValue: "place-blacklisted",
},
{ type: "domain", value: "blocked.de", normalizedValue: "blocked.de" },
{ type: "company", value: "Muster GmbH", normalizedValue: "muster gmbh" },
{ type: "phone", value: "+49 30 555 123", normalizedValue: "4930555123" },
{
type: "phone",
value: "+49 30 555 123",
normalizedValue: "+49 30 555 123",
},
{ type: "email", value: "x@example.de", normalizedValue: "x@example.de" },
{ type: "phone", value: "+49 30 999 999", normalizedValue: "4930999999" },
{
type: "email",
value: "Info@Blocked.De",
normalizedValue: "info@blocked.de",
},
],
);
const matchTypes = matches.map((match) => match.type).sort();
assert.deepEqual(
matchTypes,
["company", "domain", "google_place_id", "phone", "phone", "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

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