Integrate local business workflow and SaaS redesign

This commit is contained in:
2026-06-12 21:08:35 +02:00
parent f00c5a3193
commit 21c7e4c9a4
88 changed files with 2683 additions and 849 deletions

View File

@@ -96,7 +96,7 @@ export default defineSchema({
userId: v.string(),
message: v.string(),
read: v.boolean(),
}).index("by_user", ["userId"]),
}).index("by_user_read", ["userId", "read"]),
});
```
@@ -131,8 +131,9 @@ export const listUnread = query({
handler: async (ctx, args) => {
return await ctx.db
.query("notifications")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.filter((q) => q.eq(q.field("read"), false))
.withIndex("by_user_read", (q) =>
q.eq("userId", args.userId).eq("read", false),
)
.collect();
},
});
@@ -208,6 +209,8 @@ Note the reference path shape: a function in
- If the component needs pagination, use `paginator` from `convex-helpers`
instead of built-in `.paginate()`, because `.paginate()` does not work across
the component boundary.
- Define indexes for queried fields instead of using Convex `.filter()` after a
database query.
- Add `args` and `returns` validators to all public component functions, because
the component boundary requires explicit type contracts.
@@ -263,14 +266,14 @@ export const sendNotification = mutation({
```ts
// Bad: parent app table IDs are not valid component validators
args: {
userId: v.id("users");
userId: v.id("users"),
}
```
```ts
// Good: treat parent-owned IDs as strings at the boundary
args: {
userId: v.string();
userId: v.string(),
}
```

View File

@@ -10,7 +10,7 @@ for Convex schema and data migrations.
users: defineTable({
name: v.string(),
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
});
}).index("by_role", ["role"]);
// Migration: backfill the field
export const addDefaultRole = migrations.define({
@@ -225,7 +225,7 @@ export const verifyMigration = query({
handler: async (ctx) => {
const remaining = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("role"), undefined))
.withIndex("by_role", (q) => q.eq("role", undefined))
.take(10);
return {

View File

@@ -20,9 +20,10 @@ CONVEX_DEPLOYMENT=
NEXT_PUBLIC_CONVEX_SITE_URL=
BETTER_AUTH_SECRET=
# Google APIs
GOOGLE_GEOCODING_API_KEY=
GOOGLE_PLACES_API_KEY=
# Lead discovery
LOCAL_BUSINESS_DATA_API_KEY=
# Audit diagnostics
PAGESPEED_API_KEY=
PAGESPEED_TIMEOUT_MS=60000

219
.impeccable.md Normal file
View File

@@ -0,0 +1,219 @@
# Design Context
## Product
WebDev Pipeline is a SaaS acquisition and audit workspace for web designers, small agencies, and local marketing service providers. It helps customers find local business leads, evaluate their web presence, prepare specific mini-audits, and manage respectful outreach with human approval.
The product is not primarily an internal tool for Matthias Meister Webdesign. Matthias is the seed user and domain expert, but design decisions must serve external customers who will pay for a reliable, professional workflow they can trust with their own prospects.
## Target Audience
### Primary SaaS Users
Independent web designers, small web studios, small agencies, and local marketing providers in Germany or DACH. They sell websites, redesigns, local SEO, maintenance, or digital consulting to local businesses. They need a repeatable acquisition workflow that feels professional rather than spammy.
They often work alone or in small teams, so the product must reduce operational load: lead discovery, qualification, audit evidence, outreach drafting, follow-up tracking, and handoff status should be clear without requiring CRM-heavy process management.
### Team Buyers And Operators
Small agency owners, account leads, sales assistants, and freelancers delegating research/outreach preparation. They care about quality control, brand safety, clear approval gates, and whether the tool makes their agency look thoughtful rather than automated.
### Recipient Audience
The downstream recipients are local businesses. They are not dashboard users, but the SaaS must protect their dignity and trust. Public audit pages and emails created through the product must feel specific, respectful, and helpful. No public scores, scare tactics, or generic AI language.
## Core Use Cases
- Configure local campaigns by niche, postal code, radius, cadence, and daily/weekly limits.
- Discover fewer but higher-quality local business leads.
- Keep leads without email visible as "Kontakt fehlt" or similar action states instead of dropping them.
- Prioritize website potential: no website, outdated site, poor mobile experience, weak contact guidance, accessibility issues, local SEO opportunity.
- Capture evidence: screenshots, PageSpeed signals, relevant pages, text excerpts, checked links, and skill outputs.
- Turn evidence into reviewable public audit pages for prospects.
- Review and edit generated audit copy, email drafts, subjects, phone scripts, and follow-up drafts.
- Require explicit human approval before publishing or sending anything.
- Track replies, follow-ups, offers, wins, losses, blocked leads, duplicates, and do-not-contact states.
- Give small teams enough operational visibility without becoming a full CRM.
## Brand Personality
Concrete words: trustworthy, exacting, field-tested.
The interface should feel like a professional agency workbench: dense, capable, calm, and evidence-led. It should reassure customers that the product will help them acquire clients without damaging their reputation.
It should communicate: "This system helps your agency notice real opportunities, build specific proof, and contact people carefully."
Tone qualities:
- Professional, direct, and restrained.
- Useful before clever.
- Confident without hype.
- Operational but not cold.
- Respectful toward both the SaaS customer and the business being contacted.
- Quality-control oriented: review, approve, publish, send.
Avoid:
- Internal-only language tied to Matthias as the sole operator.
- Growth-hacker energy.
- Neon AI dashboards.
- Overfriendly startup copy.
- CRM bloat.
- Generic "AI-powered" emphasis.
- Threatening scores, shame language, or urgency theater.
- Decorative metrics that do not help the next decision.
## Design Direction
### Working Concept
"Agency Evidence Desk": a compact SaaS workspace where local-business opportunities become evidence-backed, human-approved outreach.
The product should visually combine:
- A consulting dossier: findings, evidence, public copy, approval history.
- An agency production desk: queues, roles, handoffs, status, limits.
- A calm control room: campaign health, blockers, next actions, and send safety are obvious.
The memorable thing should be the feeling of an agency-grade proof workflow, not another generic lead-gen dashboard.
### Theme
Light-first for everyday professional office work. It should not be sterile white. Use tinted surfaces that feel like organized dossier paper, with crisp contrast for repeated scanning.
Dark mode can exist for long review sessions, but it should remain serious and restrained. Avoid glowing "AI cockpit" styling.
### Layout Principles
- Prioritize "what needs review or approval now" over vanity KPIs.
- Use dense but calm SaaS dashboards; avoid landing-page composition inside the product.
- Prefer work queues, split panes, evidence stacks, approval rails, timelines, and status ledgers over equal card grids.
- Make workflow hierarchy visible through structure first, color second.
- Keep repeated records compact and scannable.
- Surface account/team safety states: limits, blocked leads, approval status, send readiness.
- On mobile, preserve critical review and approval actions; adapt the workflow rather than hiding it.
### Component Principles
- Cards are for records, queue items, modals, and framed tools only.
- Avoid card-inside-card compositions.
- Avoid generic icon-heading-text card grids.
- Filters should be action-oriented, not database-oriented.
- Badges should encode workflow state, risk, ownership, or approval status.
- Primary actions should be rare and state-specific.
- Publishing and sending actions must look consequential, not casual.
- Use icons for familiar tools and statuses; pair icon plus text when the consequence matters.
## Visual Language
### Palette Direction
Use OKLCH. The base should be a muted, professional operational palette:
- Deep green/teal for approved, safe-to-proceed, and primary operator action.
- Ochre or paper-yellow for manual review, attention, and pending approval.
- Muted blue for evidence, source material, screenshots, audit artifacts, and analytics.
- Restrained red only for failed, blocked, destructive, compliance-sensitive, or do-not-contact states.
- Tinted neutrals toward the brand hue; avoid plain grayscale.
The UI must not read as one-note green or default gray. It needs distinguishable semantic families for lead, audit, evidence, review, outreach, blocked, running, and manual-required states.
### Typography Direction
Brand words: trustworthy, exacting, field-tested.
Reflex fonts rejected for this project: Inter, IBM Plex, Space Grotesk, DM Sans, Instrument Sans, Newsreader, Fraunces, Playfair.
For future font exploration, prefer fonts that feel like professional labels, consulting dossiers, municipal forms, and precise tool interfaces rather than generic SaaS. Candidates to evaluate before implementation:
- Atkinson Hyperlegible Next for readable dense body UI.
- Commissioner for firm, constructed headings.
- Archivo or Familjen Grotesk for compact operational labels.
Do not switch fonts casually if it adds build or network risk. If using `next/font/google`, verify locally because font downloads can affect builds.
### Motion
Motion should confirm workflow state changes, not entertain. Use quick, precise transitions for selection, queue movement, publish confirmation, send confirmation, and progressive disclosure. Avoid bouncy motion and decorative reveal sequences.
## UX Priorities
### Dashboard
The dashboard should answer: "What needs attention across my acquisition workflow?" before "How many things exist?"
Preferred first screen:
- Review and approval queue.
- Campaign health and blocked runs.
- Contact missing / audit ready / review open / follow-up due buckets.
- Account or team-level safety signals.
- Metrics as supporting context, not hero content.
### Campaigns
Campaign UI should feel like controlled lead sourcing, not scraping. Make limits, cadence, recent run outcome, and next scheduled run highly visible. Manual start should be clear, but not framed as an uncontrolled blast action.
### Leads
Lead UI should be organized around next action:
- Kontakt recherchieren.
- Audit starten.
- Daten klären.
- Sperren / nicht kontaktieren.
- Zurückstellen.
Rows/cards should show why the action is needed and what evidence supports it, not just static company fields.
### Audits
Audit UI should foreground:
- Critical finding.
- Evidence available.
- Checked pages.
- Screenshot status.
- Public readiness.
- Review owner/status.
- Failure requiring manual intervention.
Avoid exposing raw PageSpeed scores externally. Internal technical signals are allowed only when they help the SaaS user decide what to say or do.
### Outreach
Outreach is the highest-trust workflow. The UI must make separation obvious:
- Draft generated.
- Draft reviewed.
- Audit published.
- Email approved.
- Final send confirmed.
Never make send feel automatic or casual. The product should protect the customer from reputational mistakes.
### Analytics
Analytics should diagnose the acquisition workflow, not create a generic metric wall. Prioritize conversion from discovered lead to contactable lead, audit-ready lead, approved outreach, sent email, reply, conversation, offer, and won client.
### Public Audit Pages
Fixed light design, quiet and respectful. No public scores, no pressure language, no overdramatic urgency. It should read like a specific observation from a competent web professional who actually looked at the site.
Public audit pages should make the SaaS customer look credible. The page should support a trust-building sales conversation, not feel like an automated report.
## Accessibility And Constraints
- Next.js App Router, Tailwind CSS, shadcn/ui, Convex, Better Auth.
- Preserve existing client/server boundaries.
- Do not change Convex hook order or mutation semantics during visual work.
- Maintain responsive behavior across mobile, tablet, and desktop.
- Avoid fragile visual hacks that conflict with tests around navigation, prefetch, or protected routes.
- Public audit pages must remain noindex and calm.
- This is SaaS-facing product UI. Avoid internal-only assumptions, single-user copy, and anything that would not scale to customer accounts or small teams.
## Current Design Gap
The current UI still reads too much like default shadcn: neutral cards, neutral muted labels, equal-weight badges, and generic dashboard spacing. The next craft pass should make larger structural changes: queue-first dashboard, split review workspace, action-oriented filters, evidence-first audit cards, semantic status surfaces, and SaaS-grade account/team framing.
The goal is not "prettier shadcn." The goal is a recognizable agency acquisition product that makes customers feel in control of evidence, approvals, outreach safety, and pipeline quality.

View File

@@ -4,69 +4,117 @@ import {
reviewQueue,
} from "@/lib/dashboard-model";
import { LeadFunnelBoard } from "@/components/lead-funnel-board";
import {
ArrowRight,
CheckCircle2,
ClipboardCheck,
FileSearch,
ShieldCheck,
} from "lucide-react";
const reviewSignals = [
{
label: "Evidence",
value: "belegt",
detail: "Audit-Aussagen brauchen Quellen, Screenshots oder geprüfte Seiten.",
icon: FileSearch,
surface: "evidence-surface",
},
{
label: "Approval",
value: "manuell",
detail: "Public Audit, E-Mail und finaler Versand bleiben getrennte Schritte.",
icon: ClipboardCheck,
surface: "review-surface",
},
{
label: "Reputation",
value: "geschützt",
detail: "Do-not-contact, Sperrliste und Limits sind Teil der Produktqualität.",
icon: ShieldCheck,
surface: "safe-surface",
},
];
export default function DashboardPage() {
return (
<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">
<header className="flex flex-col gap-3 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Interner Arbeitsbereich
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
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 Prüfung, kein automatischer Versand.
</p>
</div>
<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">
{dashboardKpis.map((kpi) => (
<article
className="rounded-lg border bg-card p-4 text-card-foreground"
key={kpi.label}
>
<p className="text-sm text-muted-foreground">{kpi.label}</p>
<p className="mt-3 text-3xl font-semibold tracking-normal">
{kpi.value}
</p>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
{kpi.detail}
</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">
Nächste Review-Schritte
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
Alles bleibt an manuelle Freigabe gekoppelt.
<div className="mx-auto flex w-full max-w-[92rem] flex-col gap-6">
<header className="agency-panel overflow-hidden">
<div className="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_minmax(22rem,0.42fr)] lg:p-6">
<div className="min-w-0">
<p className="agency-kicker">SaaS Workspace · Agency Evidence Desk</p>
<h1 className="mt-3 max-w-4xl font-heading text-4xl font-semibold tracking-normal text-foreground">
Review, Evidence und Outreach-Sicherheit in einem Arbeitsfluss.
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
WebDev Pipeline hilft Agenturen, lokale Chancen zu finden,
konkrete Belege zu sammeln und Outreach erst nach sauberer
Freigabe zu starten.
</p>
</div>
<div className="divide-y">
<div className="grid gap-2">
{reviewSignals.map((signal) => {
const Icon = signal.icon;
return (
<article
className="rounded-md border border-border/70 bg-background/55 p-3"
key={signal.label}
>
<div className="flex items-start gap-3">
<span
className={`flex size-9 shrink-0 items-center justify-center rounded-md ${signal.surface}`}
>
<Icon className="size-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-bold uppercase text-muted-foreground">
{signal.label}
</p>
<p className="text-sm font-semibold">{signal.value}</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{signal.detail}
</p>
</div>
</div>
</article>
);
})}
</div>
</div>
</header>
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
<div className="agency-panel p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="agency-kicker">Heute prüfen</p>
<h2 className="mt-1 font-heading text-xl font-semibold">
Approval Queue
</h2>
</div>
<span className="rounded-md bg-[var(--surface-review)] px-2.5 py-1 text-sm font-bold text-secondary-foreground">
{reviewQueue.length} offen
</span>
</div>
<div className="mt-4 grid gap-2">
{reviewQueue.map((item) => (
<article
className="grid gap-2 p-4 sm:grid-cols-[1fr_auto]"
className="rounded-md border border-border/75 bg-background/60 p-3"
key={`${item.title}-${item.company}`}
>
<div>
<h3 className="text-sm font-medium">{item.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{item.company}
</p>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">{item.title}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{item.company}
</p>
</div>
<ArrowRight className="mt-1 size-4 shrink-0 text-primary" />
</div>
<p className="max-w-sm text-sm leading-6 text-muted-foreground sm:text-right">
<p className="mt-2 text-sm leading-5 text-muted-foreground">
{item.detail}
</p>
</article>
@@ -74,30 +122,60 @@ export default function DashboardPage() {
</div>
</div>
<div className="rounded-lg border bg-card p-4 text-card-foreground">
<h2 className="text-base font-semibold tracking-normal">
Betriebsmodus
</h2>
<div className="mt-4 grid gap-3">
<section
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"
aria-label="Pipeline-Kennzahlen"
>
{dashboardKpis.map((kpi) => (
<article className="agency-rail p-4" key={kpi.label}>
<p className="text-xs font-bold uppercase text-muted-foreground">
{kpi.label}
</p>
<p className="mt-3 font-heading text-3xl font-semibold tracking-normal">
{kpi.value}
</p>
<p className="mt-2 text-sm leading-5 text-muted-foreground">
{kpi.detail}
</p>
</article>
))}
</section>
</section>
<LeadFunnelBoard />
<section className="agency-panel p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="agency-kicker">Workspace Health</p>
<h2 className="mt-1 font-heading text-xl font-semibold">
Safety Ledger
</h2>
</div>
<p className="inline-flex items-center gap-2 text-sm font-semibold text-muted-foreground">
<CheckCircle2 className="size-4 text-primary" />
Kundenreputation bleibt geschützt
</p>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
{pipelineHealth.map((item) => {
const Icon = item.icon;
return (
<div
className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3"
className="rounded-md border border-border/75 bg-background/60 p-3"
key={item.label}
>
<span className="inline-flex items-center gap-2 text-sm font-medium">
<Icon className="size-4 text-muted-foreground" />
<span className="inline-flex items-center gap-2 text-sm font-semibold">
<Icon className="size-4 text-primary" />
{item.label}
</span>
<span className="text-right text-sm text-muted-foreground">
<p className="mt-2 text-sm text-muted-foreground">
{item.value}
</span>
</p>
</div>
);
})}
</div>
</div>
</section>
</div>

View File

@@ -7,9 +7,9 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--font-heading: var(--font-geist-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -49,72 +49,90 @@
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--background: oklch(0.967 0.017 96);
--foreground: oklch(0.175 0.033 178);
--card: oklch(0.991 0.009 104);
--card-foreground: oklch(0.175 0.033 178);
--popover: oklch(0.991 0.009 104);
--popover-foreground: oklch(0.175 0.033 178);
--primary: oklch(0.31 0.083 177);
--primary-foreground: oklch(0.986 0.014 104);
--secondary: oklch(0.91 0.056 82);
--secondary-foreground: oklch(0.255 0.052 71);
--muted: oklch(0.917 0.023 102);
--muted-foreground: oklch(0.42 0.04 168);
--accent: oklch(0.61 0.111 242);
--accent-foreground: oklch(0.982 0.012 104);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--border: oklch(0.82 0.029 104);
--input: oklch(0.86 0.026 104);
--ring: oklch(0.54 0.095 177);
--chart-1: oklch(0.31 0.083 177);
--chart-2: oklch(0.61 0.111 242);
--chart-3: oklch(0.68 0.137 82);
--chart-4: oklch(0.57 0.12 30);
--chart-5: oklch(0.48 0.08 296);
--radius: 0.45rem;
--sidebar: oklch(0.214 0.034 181);
--sidebar-foreground: oklch(0.94 0.02 103);
--sidebar-primary: oklch(0.76 0.112 157);
--sidebar-primary-foreground: oklch(0.145 0.024 185);
--sidebar-accent: oklch(0.285 0.041 181);
--sidebar-accent-foreground: oklch(0.96 0.018 103);
--sidebar-border: oklch(0.94 0.018 103 / 15%);
--sidebar-ring: oklch(0.76 0.112 157);
--surface-rail: oklch(0.93 0.027 98);
--surface-panel: oklch(0.984 0.012 100);
--surface-pressed: oklch(0.885 0.035 103);
--surface-evidence: oklch(0.93 0.034 235);
--surface-review: oklch(0.932 0.06 82);
--surface-safe: oklch(0.91 0.06 151);
--warning-soft: oklch(0.932 0.06 82);
--success-soft: oklch(0.91 0.06 151);
--danger-soft: oklch(0.93 0.05 28);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.17 0.02 185);
--foreground: oklch(0.948 0.016 142);
--card: oklch(0.215 0.024 187);
--card-foreground: oklch(0.948 0.016 142);
--popover: oklch(0.215 0.024 187);
--popover-foreground: oklch(0.948 0.016 142);
--primary: oklch(0.76 0.112 157);
--primary-foreground: oklch(0.145 0.024 185);
--secondary: oklch(0.314 0.044 80);
--secondary-foreground: oklch(0.94 0.04 93);
--muted: oklch(0.27 0.023 185);
--muted-foreground: oklch(0.735 0.028 165);
--accent: oklch(0.77 0.121 82);
--accent-foreground: oklch(0.17 0.02 185);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--border: oklch(0.94 0.018 160 / 13%);
--input: oklch(0.94 0.018 160 / 17%);
--ring: oklch(0.76 0.112 157);
--chart-1: oklch(0.76 0.112 157);
--chart-2: oklch(0.77 0.121 82);
--chart-3: oklch(0.68 0.095 245);
--chart-4: oklch(0.7 0.12 32);
--chart-5: oklch(0.72 0.095 296);
--sidebar: oklch(0.13 0.018 187);
--sidebar-foreground: oklch(0.92 0.016 142);
--sidebar-primary: oklch(0.76 0.112 157);
--sidebar-primary-foreground: oklch(0.145 0.024 185);
--sidebar-accent: oklch(0.24 0.028 186);
--sidebar-accent-foreground: oklch(0.94 0.016 142);
--sidebar-border: oklch(0.94 0.018 160 / 12%);
--sidebar-ring: oklch(0.76 0.112 157);
--surface-rail: oklch(0.145 0.02 187);
--surface-panel: oklch(0.21 0.024 187);
--surface-pressed: oklch(0.27 0.027 184);
--surface-evidence: oklch(0.24 0.036 236);
--surface-review: oklch(0.314 0.044 80);
--surface-safe: oklch(0.25 0.046 151);
--warning-soft: oklch(0.314 0.044 80);
--success-soft: oklch(0.25 0.046 151);
--danger-soft: oklch(0.28 0.062 28);
}
@layer base {
@@ -123,8 +141,85 @@
}
body {
@apply bg-background text-foreground;
font-feature-settings: "cv02", "cv03", "cv04", "ss03";
}
html {
@apply font-sans;
}
}
@layer components {
.dashboard-canvas {
background-color: var(--background);
background-image:
linear-gradient(to right, color-mix(in oklch, var(--foreground), transparent 93%) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--foreground), transparent 95%) 1px, transparent 1px);
background-size: 48px 48px;
}
.agency-panel {
border: 1px solid color-mix(in oklch, var(--border), transparent 8%);
border-radius: var(--radius);
background:
linear-gradient(180deg, color-mix(in oklch, var(--card), white 22%), var(--card));
box-shadow:
0 1px 0 color-mix(in oklch, var(--foreground), transparent 94%),
0 20px 48px color-mix(in oklch, var(--foreground), transparent 94%);
}
.agency-rail {
border: 1px solid color-mix(in oklch, var(--border), transparent 12%);
border-radius: var(--radius);
background:
linear-gradient(135deg, var(--surface-panel), color-mix(in oklch, var(--surface-panel), var(--surface-evidence) 22%));
}
.agency-kicker {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.agency-tab {
display: inline-flex;
min-height: 2.25rem;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius);
border: 1px solid color-mix(in oklch, var(--border), transparent 10%);
background: color-mix(in oklch, var(--background), var(--card) 45%);
padding: 0.35rem 0.75rem;
font-size: 0.875rem;
font-weight: 700;
color: var(--muted-foreground);
transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease;
}
.agency-tab:hover {
background: var(--muted);
color: var(--foreground);
}
.agency-tab[aria-pressed="true"] {
border-color: color-mix(in oklch, var(--primary), transparent 25%);
background: color-mix(in oklch, var(--primary), var(--background) 84%);
color: var(--foreground);
}
.evidence-surface {
background: var(--surface-evidence);
color: color-mix(in oklch, var(--accent), var(--foreground) 55%);
}
.review-surface {
background: var(--surface-review);
color: var(--secondary-foreground);
}
.safe-surface {
background: var(--surface-safe);
color: color-mix(in oklch, var(--primary), var(--foreground) 42%);
}
}

View File

@@ -1,10 +1,10 @@
---
id: TASK-13
title: Build the audit and outreach review workspace
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 14:21'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- review
@@ -56,3 +56,9 @@ Implemented first TASK-13 prerequisite: PageSpeed completion now queues audit_ge
2026-06-05: Completed TASK-13 implementation subagent-driven and test-driven on branch codex-task-13-review-workspace. Worker A added authenticated Convex review workspace contracts, save/approve draft mutations, protected existing outreach create/list, audit ownership checks, sent-record protection, approval reset on regenerated copy, and combined review eligibility indexes. Worker B replaced /dashboard/outreach placeholder with the review workspace UI, editable audit/outreach drafts, raw/source toggles, used skills, phone-script gating, and save-before-approve/publish safeguards. Worker C fixed funnel regression so approved-but-unsent outreach remains in Freigabe offen. Reviews: backend spec approved, backend quality approved after fixes, UI spec approved, UI quality approved after fixes, funnel spec/quality approved, final TASK-13 spec approved. Verification passed: pnpm test (263/263), pnpm exec tsc --noEmit, pnpm lint (0 errors; existing BetterAuth generated warnings only), pnpm build with network escalation for Google Fonts.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-15
title: Add follow-up and manual sales status tracking
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:49'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- sales
@@ -49,3 +49,9 @@ Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is supe
Implemented and verified follow-up draft creation after send, manual approval boundaries for follow-up records, manual sales status labels/mutation, reply/no-interest suppression, and 12-month do-not-contact recheck visibility. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-16
title: Orchestrate recurring Convex agent jobs and audit lifecycle
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:49'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- convex
@@ -51,3 +51,9 @@ Started implementation pass for recurring Convex agent jobs, run locking, logs,
Implemented and verified Convex crons, due-campaign runner, single-active-run guard, visible campaign run logs, and audit lifecycle notification/deactivation controls. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-17
title: Add Rybbit audit analytics dashboard
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:50'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- analytics
@@ -51,3 +51,9 @@ Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for a
Completed remaining Rybbit campaign aggregation path: campaignMetrics now exposes audit path segments with campaign/niche/region, Rybbit campaign API returns per-path activity, and the Analytics dashboard groups audit opens/CTA clicks by campaign, niche, and region. Verification: targeted analytics tests pass.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-18
title: Add MVP quality gates and operational polish
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:49'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- quality
@@ -51,3 +51,9 @@ Started implementation pass for MVP quality gates, error observability, verifica
Implemented and verified German operational readiness surfaces, secret-safe integration status rows, verification notes for critical flows, and Coolify deployment notes. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-19
title: Add campaign performance metrics
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:49'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- analytics
@@ -51,3 +51,9 @@ Started implementation pass for campaign performance metrics and filters.
Implemented and verified lightweight campaign metrics query/dashboard, filter contract, run detail rows, and Rybbit-derived audit opens/CTA clicks alongside Convex metrics. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-21
title: Replace oversized Convex browser runtime dependency
status: In Progress
status: Done
assignee: []
created_date: '2026-06-04 15:30'
updated_date: '2026-06-04 16:41'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -43,3 +43,9 @@ Follow-up for repeated /tmp/chromium cannot execute binary file: Context7 confir
Follow-up for libnspr4.so runtime error: Context7 and local @sparticuz/chromium-min docs show remote pack includes al2023.tar.br, but package only auto-inflates it when AL2023 detection fires. Convex needs those shared libs without being detected. Added explicit AL2023 shared-library preparation after executablePath(): inflate CHROMIUM_PACK_PATH/al2023.tar.br and setupLambdaEnvironment(/tmp/al2023/lib) before Playwright launch. Verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (109/109); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-22
title: Add source assertions for Convex AL2023 Chromium lib setup
status: In Progress
status: Done
assignee: []
created_date: '2026-06-04 16:37'
updated_date: '2026-06-04 16:41'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -38,3 +38,9 @@ Added source-only assertion in tests/website-enrichment-action.test.ts for AL202
GREEN follow-up completed: runtime action now exposes chromium-min inflate/setupLambdaEnvironment, prepares /tmp/al2023/lib after executablePath resolution and before Playwright launch, and focused/full verification passes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-23
title: Improve website email extraction
status: In Progress
status: Done
assignee: []
created_date: '2026-06-04 17:28'
updated_date: '2026-06-04 17:34'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -33,3 +33,9 @@ Updated website-crawler extractor to support mailto query stripping/decoding, HT
Implemented via subagents/TDD: added RED tests for mailto query params, obfuscated email forms, footer/impressum usability, no-guessing false-positive guard, and mailto dedupe. Extractor now decodes common HTML entities, strips/decodes mailto query strings, parses [at]/(at)/punkt/dot/spaced forms with guardrails, expands footer/impressum/contact business context, and leaves TASK-7 selection unchanged. Verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (114/114); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-24
title: Improve crawler handling for Bock Rechtsanwaelte edge cases
status: In Progress
status: Done
assignee: []
created_date: '2026-06-04 18:04'
updated_date: '2026-06-04 18:09'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -48,3 +48,9 @@ Minimal scoped fix applied in lib/website-crawler.ts: mailto business-context no
Reproduced Bock Impressum against captured public HTML. Extractor found 5 candidates but all were business=false because mailto anchor offsets from original HTML were checked against normalized HTML; TASK-7 therefore returned null. Added RED tests for Bock-like Impressum mailto context and email-label contactPerson behavior. Fixed mailto path to evaluate business context against original input offsets and suppress contactPerson when anchor label is the email itself. Verified captured real HTML now returns usable chemnitz@bock-rechtsanwaelte.de. Full verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (116/116); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable. Timeout mitigation not changed yet because timeout root cause is not identified.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-25
title: Harden website enrichment against Convex action runtime aborts
status: In Progress
status: Done
assignee: []
created_date: '2026-06-05 06:59'
updated_date: '2026-06-05 07:04'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -41,3 +41,9 @@ Website enrichment actions can be killed by Convex with a transient invalid envi
2026-06-05: Implemented action-level budget guard (default 120s, TASK8_ACTION_BUDGET_MS override) around Playwright import, Chromium executable resolution, AL2023 library preparation, browser launch/context creation, page crawls, internal link checks, and desktop/mobile screenshots so long work rejects inside the action catch path before Convex invalidates the runtime. Verified with targeted website-enrichment action tests, full pnpm test, TypeScript, lint, and Convex dev typecheck/deploy.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-27
title: Trigger audit generation after PageSpeed audit
status: In Progress
status: Done
assignee: []
created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 19:49'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -34,3 +34,9 @@ Started verification pass. Implementation notes say TASK-27 is superseded by TAS
Verified existing PageSpeed-to-audit-generation handoff in pageSpeedAction. Successful and failure paths queue audit generation for the started lead, queue failures are warning-logged, existing queueLeadAuditGeneration dedupe remains in place, and regression source tests pass. Verification: pnpm test 305/305; pnpm lint 0 errors.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-29
title: Surface audit generations on dashboard audits
status: In Progress
status: Done
assignee: []
created_date: '2026-06-05 20:30'
updated_date: '2026-06-05 22:45'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -40,3 +40,9 @@ Show audit-generation pipeline data on /dashboard/audits when final audits rows
<!-- SECTION:NOTES:BEGIN -->
Implemented listDashboardRows with authenticated audit + audit_generation rows. Addressed QA finding by suppressing generation rows via direct auditId lookup and by_leadId lookup, not only the fetched dashboard audit page. Verified with pnpm test and pnpm lint; lint has only existing generated Better Auth warnings.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-30
title: Externalisiere die persönliche Audit-Pipeline
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 18:44'
updated_date: '2026-06-07 20:27'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -74,3 +74,9 @@ Orchestrator final verification: AC #1 checked after external Capture/Generation
2026-06-07 follow-up live Convex investigation for run j97d4ytrzccqcx3vc05dre30rh886wz4 on dev deployment different-caterpillar-213: Azure schema blocker is resolved; classification/multimodal/germanCopy succeeded. Current hard failure is qualityReview. Convex auditGenerations quality parsedJson shows LLM QA isValid=false for subjective copy notes (langatmig/redundant), plus German-Copy-Guard issues. Local reproduction of the live German copy showed deterministic guard false positives: emailBody missed observation/suggestion because observed text used "festgestellt" outside the narrow token pattern, and callScript.closeLine incorrectly required Ich-form for a collaborative closing line. Implemented TDD fix: German guard now recognizes festgestellt/feststellen/feststellbar and noun-form "Vorschlag"; call-script close lines no longer require Ich-form. Audit action now hard-blocks only deterministic German-Copy-Guard failures; subjective LLM QA false is persisted/logged as warning while allowing the audit to continue. Added regression tests for the live copy and source contract. Verification passed: pnpm test 366/366, pnpm exec tsc -p tsconfig.json --pretty false, pnpm lint 0 errors with two existing BetterAuth generated warnings, pnpm exec tsc -p convex/tsconfig.json --pretty false. Attempted Convex dev deployment was rejected by approval reviewer because it changes shared Dev behavior and user has not explicitly approved deployment.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-31
title: Require auth for usage event reads
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 20:27'
updated_date: '2026-06-06 20:31'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -41,3 +41,9 @@ RED: pnpm test -- tests/usage-events-source.test.ts is blocked by pre-existing t
GREEN: node --test tests/usage-events-source.test.ts passes 6/6. pnpm test -- tests/usage-events-source.test.ts compiles and usageEvents tests pass, but the overall runner fails on existing external-audit-pipeline-source.test.js: audit generation action sanitizes raw errors before run events and run failure summaries, outside Worker F scope.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-32
title: Wire v3 skill registry into audit generation
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 20:27'
updated_date: '2026-06-06 20:36'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -42,3 +42,9 @@ RED: pnpm test tests/audit-generation-action-source.test.ts tests/ai-schemas.tes
GREEN: pnpm exec tsc -p tsconfig.test.json exited 0. Focused compiled tests passed: node --test .test-output/tests/audit-generation-action-source.test.js .test-output/tests/ai-schemas.test.js .test-output/tests/audit-skills-schema.test.js .test-output/tests/audit-skill-registry-v3.test.js => 32/32 pass. Full pnpm test passed: 345/345. Self-review: no changes to convex/usageEvents.ts, no commit/staging; usedSkills optional fields are conditionally spread before persistence.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-33
title: Fix v3 live wiring quality issues
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 20:41'
updated_date: '2026-06-06 20:47'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -42,3 +42,9 @@ RED: tsc passed. node --test .test-output/tests/audit-evidence.test.js .test-out
GREEN: pnpm exec tsc -p tsconfig.test.json exited 0. Focused tests passed: node --test .test-output/tests/audit-evidence.test.js .test-output/tests/audit-generation-action-source.test.js => 23/23 pass. Full pnpm test passed: 347/347. Self-review: only touched audit-evidence skill selection, auditGenerationAction registry warning fallback, and focused tests; no staging/commit; no convex/usageEvents.ts changes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-34
title: Harden v3 selection and Convex payloads
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 20:54'
updated_date: '2026-06-06 21:03'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -40,3 +40,9 @@ Fix v3 quality review issues by removing explicit undefined values from Convex m
<!-- SECTION:NOTES:BEGIN -->
RED: tsc passed, focused node --test .test-output/tests/audit-evidence.test.js .test-output/tests/audit-generation-action-source.test.js failed as expected on registry-order v3 cap and explicit undefined stage payload contract. GREEN: tsc passed; focused tests passed 26/26; full pnpm test passed 350/350. Self-review: no commits/staging, no changes to convex/usageEvents.ts, no ScreenshotOne missing-key behavior changes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-35
title: Remove remaining undefined audit generation payloads
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 21:06'
updated_date: '2026-06-06 21:13'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -42,3 +42,9 @@ RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-
RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-source.test.js failed as expected on appendRunEvent details, success finishAuditGenerationRun errorSummary ternary, and qualityReview persistAuditStage errorSummary ternary. GREEN: focused source test passed 21/21; full pnpm test passed 353/353. Self-review: changed only convex/auditGenerationAction.ts and tests/audit-generation-action-source.test.ts in this turn; no commits/staging; no UsageEvents or ScreenshotOne behavior changes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-36
title: Remove optional helper undefined args
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 21:15'
updated_date: '2026-06-06 21:23'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -42,3 +42,9 @@ RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-
GREEN: pnpm exec tsc -p tsconfig.test.json passed. Focused node --test .test-output/tests/audit-generation-action-source.test.js passed 24/24. Full pnpm test passed 356/356. Implemented conditional auditId spreads at persist/helper callsites and stage usage builder for callsite usage objects.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-37
title: Prioritize v3 local audit skills
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 21:30'
updated_date: '2026-06-06 21:38'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -42,3 +42,9 @@ RED: pnpm exec tsc -p tsconfig.test.json passed. Focused node --test .test-outpu
GREEN: pnpm exec tsc -p tsconfig.test.json passed. Focused node --test .test-output/tests/audit-evidence.test.js passed 8/8. Full pnpm test passed 356/356. Added deterministic v3 local-audit priority before cap while preserving applicability/input gating and legacy category selection.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-38
title: Add ScreenshotOne missing-key run warning
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 21:41'
updated_date: '2026-06-06 21:46'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -42,3 +42,9 @@ RED verified: pnpm exec tsc -p tsconfig.test.json passed, then node --test .test
GREEN verified: focused node --test .test-output/tests/external-audit-pipeline-source.test.js passed 11/11 after implementation. Full pnpm test passed 357/357 with exit 0.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-39
title: Secure Convex operator APIs
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 21:52'
updated_date: '2026-06-06 22:00'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -32,3 +32,9 @@ Worker I audit slice: Added source-contract coverage for audit admin auth guards
Worker J RED/GREEN: Added leads/runs source contracts; initial pnpm test failed on missing lead/run requireOperator guards and missing internal lead/run action refs. Implemented operator auth for public leads/runs APIs, added internal lead get/review update and run append event mutations, and switched auditGenerationAction/pageSpeedAction/websiteEnrichmentAction to internal refs. GREEN: pnpm test passed (363/363). Did not touch convex/audits.ts and did not stage/commit.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-40
title: Behebe abschliessende Lint-Blocker
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 22:10'
updated_date: '2026-06-06 22:15'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -38,3 +38,9 @@ Fix the final lint blockers after the v2 pipeline implementation without changin
<!-- SECTION:NOTES:BEGIN -->
TASK-40 worker update: fixed final lint blockers by ignoring v2_elemente reference snippets in ESLint and removing an unused helper from tests/external-audit-pipeline-source.test.ts. Verification: pnpm lint exits 0 with only generated convex/betterAuth/_generated unused-disable warnings; pnpm test passes 363/363; git diff --check exits 0. Task intentionally left In Progress pending user confirmation.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-41
title: Repariere Convex-Typecheck fuer Usage Events
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 22:13'
updated_date: '2026-06-06 22:16'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -45,3 +45,9 @@ Verification/results:
- `pnpm exec tsc --noEmit` exits 2 only because of unrelated pre-existing v2_elemente/* errors (missing local generated modules/imports and implicit any issues); no TASK-41/convex/auditGenerationAction.ts errors remain. Per user instruction, v2_elemente fixes were not touched.
- `pnpm test` exits 0: 363 tests passed, 0 failed.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-42
title: Scope v2 Referenzdateien aus dem Typecheck
status: In Progress
status: Done
assignee: []
created_date: '2026-06-06 22:16'
updated_date: '2026-06-06 22:18'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -41,3 +41,9 @@ Reproduced pnpm exec tsc --noEmit failure: production tsconfig includes v2_eleme
Applied minimal scope fix: tsconfig.json now excludes v2_elemente/** from the production TypeScript program, matching the existing ESLint ignore for reference snippets. Verification passed: pnpm exec tsc --noEmit (exit 0), pnpm lint (exit 0 with two existing generated-file warnings), pnpm test (exit 0, 363 tests passed), git diff --check (exit 0). v2_elemente contents were not edited.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-43
title: Stabilisiere Website-Enrichment ohne Playwright-Abbruch
status: In Progress
status: Done
assignee: []
created_date: '2026-06-07 19:40'
updated_date: '2026-06-07 20:57'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -48,3 +48,9 @@ Follow-up fix: The live Convex run j9737mz0tkgdbg6mzjxjd1w7018878b1 failed becau
Final verification after robustness cleanup: pnpm test -- tests/website-enrichment-action.test.ts passed (392/392 in focused harness); pnpm exec tsc -p convex/tsconfig.json --pretty false passed; pnpm exec tsc -p tsconfig.json --pretty false passed; git diff --check passed; pnpm test passed (368/368); pnpm lint passed with the same two generated BetterAuth unused-disable warnings and 0 errors.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-44
title: Port audit pipeline fully into the MVP
status: In Progress
status: Done
assignee: []
created_date: '2026-06-07 21:16'
updated_date: '2026-06-07 21:34'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -44,3 +44,9 @@ Implemented GREEN slice after RED tests: added bundled MVP v3 audit skill regist
Masked Convex env check confirms SCREENSHOTONE_API_KEY is present in the dev deployment. AC #4 remains open until a fresh live audit run confirms no ScreenshotOne missing-key warning in Run Events.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-45
title: Show audit evidence on detail pages
status: In Progress
status: Done
assignee: []
created_date: '2026-06-07 21:50'
updated_date: '2026-06-07 22:01'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -41,3 +41,9 @@ Fix the audit detail view so stored checked pages and compact website-enrichment
<!-- SECTION:NOTES:BEGIN -->
Implemented getDetail sourceSummaries.checkedPages with latest successful website_enrichment evidence by lead, bounded crawl/technical/screenshot joins, storage URL resolution, and checkedPages fallback rows. AuditDetail now renders a compact Geprüfte Seiten card between overview and skills. Verification passed: focused tests, pnpm test, app tsc, lint, git diff --check, convex codegen dry-run/typecheck, and convex dev --once. Browser plugin reached login because its session is unauthenticated; Arc/local authenticated session should show the deployed query after reload.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-46
title: Add Convex specialist fan-out audit pipeline
status: In Progress
status: Done
assignee: []
created_date: '2026-06-08 09:04'
updated_date: '2026-06-08 09:19'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -46,3 +46,9 @@ RED: pnpm exec tsc -p tsconfig.test.json fails because AuditEvidenceInput has no
GREEN: Focused audit fan-out/source/UI tests passed 67/67. Full pnpm test passed 384/384. Implemented specialist fan-out stages, evidence ledger, auditFindings persistence, blocking model+guard QA, real skill summaries, and findings-first audit detail UI.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-47
title: Fix evidence verifier audit generation failure
status: In Progress
status: Done
assignee: []
created_date: '2026-06-08 09:35'
updated_date: '2026-06-08 10:07'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -46,3 +46,9 @@ Second live failure root cause: after the strict schema fix, specialist stages s
Fix: changed evidenceVerifier output to compact verifiedFindingIds plus small rejected decisions, then deterministically map accepted IDs back to original specialist findings in the action. This preserves strict evidence gates while removing verifier echoing/mutation of findings.
Verification: added RED schema regression for compact verifier IDs and many findings; focused schema/action tests pass; adjacent audit persistence/schema/UI/evidence tests pass; pnpm test passes 387/387; git diff --check passes; ESLint on touched files passes; npx convex dev --once synced the fix to dev deployment.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-48
title: Integrate impeccable critique into audit pipeline
status: In Progress
status: Done
assignee: []
created_date: '2026-06-08 12:02'
updated_date: '2026-06-08 12:10'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -41,3 +41,9 @@ Extend the evidence-first audit pipeline with design critique/impeccable-style v
<!-- SECTION:NOTES:BEGIN -->
Implemented the impeccable/critique integration as an evidence-bound audit extension. Inspected the local impeccable and critique skills; no project-specific .impeccable.md was present, so the product guidance was translated into bounded audit behavior instead of broad design taste claims. Added the V3 skill registry entry `impeccable-critique`, prioritized it in selected local audit skills, and wired a new Convex `critiqueSpecialist` stage between visual trust and performance/accessibility. The stage is instructed to produce only evidence-linked findings using skillId `impeccable-critique`; the existing compact verifier and German synthesis path remain the gate, so raw specialist output is not customer-facing. UI tests continue to cover evidence chips and real registry purpose text. Verification: focused specialist/evidence tests 45/45 passed; skill/UI tests 15/15 passed; full `pnpm test` 388/388 passed; `git diff --check` passed; targeted ESLint passed; `npx convex dev --once` synced successfully.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-49
title: Improve audit outreach email tone
status: In Progress
status: Done
assignee: []
created_date: '2026-06-08 19:30'
updated_date: '2026-06-08 19:48'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -41,3 +41,9 @@ Add evidence-first, collegial-direct tonal guidelines for generated outreach ema
<!-- SECTION:NOTES:BEGIN -->
Implemented the evidence-first outreach email tone pass. Added `lib/ai/customer-tone-guidelines.ts` with the selected collegial-direct sender posture, short first-contact email constraints, banned phrases, and prompt helper. Updated German copy generation to remove the old Ich-Ich instruction, include the shared tone section, pass normalized evidence context, and keep the existing generation call structure. Added hard deterministic email tone checks for subject length/pitch patterns, email length, sentence/paragraph count, formulaic Ich-habe/Ich-schlage-vor patterns, brochure language, mini-audit structure, informal address, and missing low-friction asks. Public audit hard guard behavior remains limited to the existing rules. Quality review now explicitly asks whether the email sounds like a real first email from Matthias, not AI sales copy, and whether concrete claims are backed by verified findings. Verification: focused tests 60/60 passed; full `pnpm test` 395/395 passed; targeted ESLint passed; `git diff --check` passed; `npx convex dev --once` synced successfully after fixing the Convex-only typecheck issue by passing `evidenceInput` instead of raw evidence.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-50
title: Refactor dashboard views into compact cards
status: In Progress
status: Done
assignee: []
created_date: '2026-06-08 19:56'
updated_date: '2026-06-08 20:21'
updated_date: '2026-06-10 19:27'
labels: []
dependencies: []
priority: high
@@ -50,3 +50,9 @@ Task remains In Progress pending user manual confirmation before Done.
Independent code-review agent found no correctness, hook-order, or broken-interaction blockers. Residual risk: current UI tests are source-regex based, so future work should consider render-level interaction tests for modal opening, filters, and selected detail behavior.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,49 @@
---
id: TASK-51
title: >-
Replace Google Places discovery with Local Business Data and manual audit
starts
status: In Progress
assignee: []
created_date: '2026-06-10 19:49'
updated_date: '2026-06-10 20:26'
labels: []
dependencies: []
priority: high
ordinal: 53000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the campaign lead discovery provider with RapidAPI Local Business Data, stop automatic enrichment/audit chaining after discovery, and add an explicit user-triggered audit start from lead review.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Campaign runs use Local Business Data via LOCAL_BUSINESS_DATA_API_KEY and no longer require Google Geocoding or Places keys.
- [x] #2 Campaign lead discovery persists direct contact emails from the provider and does not queue website enrichment.
- [x] #3 Website enrichment no longer auto-queues PageSpeed or audit generation.
- [x] #4 Operators can start an audit manually from a lead review row via an authenticated mutation.
- [x] #5 Operational readiness, env docs, usage providers, source assertions, and regression tests cover the new flow.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add tests for Local Business Data adapter, schema/provider changes, and removed auto-trigger source paths
2. Implement provider adapter and swap Convex lead discovery to single Local Business Data search
3. Entangle manual audit start from automatic discovery/enrichment chain
4. Add Lead Review UI action and update campaign/ops copy and env docs
5. Run focused tests, full pnpm test, and update acceptance criteria notes
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented Local Business Data lead discovery adapter and swapped campaign runs to LOCAL_BUSINESS_DATA_API_KEY/RapidAPI search. Removed automatic website enrichment queueing from discovery and automatic PageSpeed queueing from website enrichment. Added authenticated manual pageSpeed.requestLeadAudit mutation plus Lead Review Audit starten UI with disabled reasons. Updated schema/domain/source provider fields, Source Business ID blacklist matching, ops readiness/env docs/copy, and regression tests. Verification passed: pnpm test (407/407).
Bug follow-up: First Local Business Data campaign test showed no emails in Lead Review. Root cause investigation found provider emails under data[].emails_and_contacts.emails while the adapter only parsed top-level email fields. Planned fix: parse nested emails and update existing duplicate leads with missing email on campaign re-run, without automatic enrichment/audit queueing.
Bug fix implemented: Local Business Data adapter now parses data[].emails_and_contacts.emails, normalizes and dedupes email candidates, and campaign re-runs backfill missing email fields on duplicate leads when safe. Verified focused lead-discovery tests and full pnpm test (409/409). TASK-51 remains In Progress pending user confirmation.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-52
title: Make lead card contact fields clickable
status: In Progress
assignee: []
created_date: '2026-06-10 20:49'
updated_date: '2026-06-10 20:55'
labels: []
dependencies: []
priority: medium
ordinal: 54000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement clickable email, phone, and website links in the dashboard leads card view so operators can contact or inspect leads directly from each card.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Lead email values render as mailto links when present while the no-email fallback stays plain text
- [x] #2 Lead phone values render as tel links when present
- [x] #3 Lead website/domain values render as external website links when present
- [x] #4 Existing leads review source tests cover the clickable link behavior
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current leads card rendering and tests
2. Add safe href helpers for email, phone, and website/domain values
3. Render available contact fields as accessible links with existing overflow styling
4. Extend source tests for link behavior
5. Run focused verification
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented helper-backed contact links in components/leads/leads-review-table.tsx and extended tests/leads-review-table.test.ts source assertions.
Verified with pnpm test: TypeScript test compile and 409 node tests passed.
Verified with pnpm lint: exit 0 with 3 existing warnings outside this change. Browser navigation to /dashboard/leads redirected to /login without an authenticated in-app browser session, so clickable DOM could not be manually exercised there.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-53
title: Refine SaaS MVP visual identity
status: In Progress
assignee: []
created_date: '2026-06-11 11:29'
updated_date: '2026-06-12 18:54'
labels: []
dependencies: []
priority: high
ordinal: 55000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Review the current SaaS MVP interface with impeccable design principles and an agency-style subagent critique, then improve the generic shadcn UI feel while preserving existing product workflows.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Dashboard shell and core workspace screens have a more distinctive visual language than default shadcn styling
- [x] #2 Changes preserve existing responsive behavior and workflow semantics
- [x] #3 Relevant automated checks pass or any remaining blockers are documented
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Review product/design context and current dashboard surfaces
2. Run an agency-style critique across visual identity, workflow clarity, and implementation risk
3. Apply a focused UI polish pass to reduce generic shadcn feel
4. Verify with lint/tests and a local browser pass
5. Leave TASK-53 in progress until user confirms manual acceptance
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Initial low-risk visual pass applied to global theme tokens, shared Card/Button/Badge primitives, dashboard shell, sidebar, dashboard overview, and lead funnel styling. Kept Convex hooks, mutations, routes, and workflow logic untouched.
Verification: pnpm run lint completed with 0 errors and 3 pre-existing warnings outside this UI pass. pnpm test passed 409 tests. Browser opened http://localhost:3001/dashboard; route compiled, but unauthenticated browser session hit existing Convex operator auth error on leads:listFunnel, so authenticated visual inspection remains manual.
User feedback: first visual pass is not visibly transformative enough. Next step: run impeccable teach by extracting explicit target audience, use cases, brand tone, design direction, and visual principles from PRD into .impeccable.md before making a bolder UI pass.
Created .impeccable.md via impeccable teach context setup using PRD content: target audience, workflows, brand tone, visual direction, palette/typography guidance, UX priorities, and current design gaps. This should guide the next bolder craft pass.
Clarified .impeccable.md for the actual SaaS repo: primary audience is external web designers, small agencies, and local marketing providers. Matthias is now framed as seed user/domain expert, not the primary product audience. Updated brand, UX priorities, visual direction, and constraints to SaaS/customer-account framing.
User requested a full design rebuild using skills and agents. Scope expanded from context setup/theme polish to a bolder SaaS-facing redesign of dashboard shell and core workspace surfaces guided by .impeccable.md.
Full redesign pass applied after agent review: rebuilt SaaS visual identity around Agency Evidence Desk tokens, dark sidebar, dashboard approval queue, evidence pipeline, safety ledger, campaign control cards, audit evidence dossier cards, outreach approval bench, lead intake, and customer-facing workspace login. Preserved Convex query/mutation semantics and protected workflow boundaries. Verification: pnpm lint passed with 0 errors and the same 3 unrelated warnings in generated/Convex files; pnpm test passed 409 tests; Browser verified http://localhost:3001/login renders Agency Evidence Desk / Workspace Login, old MVP/Admin copy removed, and no console errors. Dashboard remains auth-protected and redirects unauthenticated sessions to login as expected.
<!-- SECTION:NOTES:END -->

View File

@@ -1,10 +1,10 @@
---
id: TASK-8
title: Implement Playwright website crawling and screenshot capture
status: In Progress
status: Done
assignee: []
created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 18:09'
updated_date: '2026-06-10 19:27'
labels:
- mvp
- audit
@@ -73,3 +73,9 @@ TASK-23 extractor improvement applied: website enrichment now extracts published
TASK-24 Bock Rechtsanwaelte follow-up: mailto candidates on real Impressum HTML were found but incorrectly marked non-business due index mismatch in context detection. Fixed mailto business-context detection and email-label contactPerson suppression; captured Bock HTML now yields usable chemnitz@bock-rechtsanwaelte.de.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Closed per explicit user request while switching project tracking to pitchfast.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Activity, Files, SquarePen } from "lucide-react";
import { Activity, Files, FileSearch, SquarePen } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
@@ -80,9 +80,9 @@ function getStageLabel(stage: string) {
function AuditsBoardLoading() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Evidence Dossier</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
<p className="text-sm text-muted-foreground">Audits werden geladen...</p>
</header>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@@ -149,12 +149,12 @@ export function AuditsBoard() {
if (rows.length === 0) {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Evidence Dossier</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<Card>
<Card className="agency-panel">
<CardHeader>
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<CardDescription>
@@ -169,16 +169,20 @@ export function AuditsBoard() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Audit-Übersicht</p>
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Evidence Dossier</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Audits</h1>
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
Laufende Generierungen, veröffentlichbare Audits und Fehlerzustände
als Prüfmappe statt lose Datensatzliste.
</p>
</header>
<div className="flex flex-wrap gap-2" aria-label="Audit-Filter">
{auditStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
className="agency-tab"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
@@ -199,14 +203,15 @@ export function AuditsBoard() {
return (
<Card
aria-labelledby={rowTitleId}
className="flex min-w-0 flex-col"
className="agency-panel flex min-w-0 flex-col overflow-hidden"
key={row.id}
>
<CardHeader className="gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<CardDescription>
{row.kind === "audit" ? "Audit" : "Pipeline"}
<CardDescription className="inline-flex items-center gap-2">
<FileSearch className="size-3.5" aria-hidden="true" />
{row.kind === "audit" ? "Audit Evidence" : "Pipeline Evidence"}
</CardDescription>
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
{row.title}
@@ -222,11 +227,11 @@ export function AuditsBoard() {
<CardContent className="flex flex-1 flex-col gap-4">
<div className="grid gap-3 text-sm">
<div className="min-w-0">
<div className="evidence-surface min-w-0 rounded-md px-3 py-2">
<p className="text-xs font-medium text-muted-foreground">Domain</p>
<p className="mt-1 break-all">{row.checkedDomain}</p>
</div>
<div className="min-w-0">
<div className="min-w-0 rounded-md border border-border/75 bg-background/60 p-3">
<p className="text-xs font-medium text-muted-foreground">
{row.kind === "audit" ? "Seiten" : "Phase"}
</p>
@@ -244,14 +249,14 @@ export function AuditsBoard() {
)}
</p>
</div>
<div className="min-w-0">
<div className="min-w-0 rounded-md bg-muted/45 p-3">
<p className="text-xs font-medium text-muted-foreground">Slug</p>
<p className="mt-1 break-words text-muted-foreground">
{row.kind === "generation" ? `Run ${row.runId}` : row.title}
</p>
</div>
{row.kind === "generation" && row.errorSummary ? (
<p className="break-words rounded-md border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<p className="break-words rounded-md border border-destructive/30 bg-[var(--danger-soft)] px-3 py-2 text-xs text-destructive">
{row.errorSummary}
</p>
) : null}
@@ -260,7 +265,7 @@ export function AuditsBoard() {
<div className="mt-auto flex justify-end">
{row.kind === "audit" ? (
<Link
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm text-primary hover:bg-muted"
className="inline-flex min-h-8 items-center gap-1 rounded-md px-2 text-sm font-semibold text-primary hover:bg-muted"
href={row.detailHref}
>
<SquarePen className="size-4" aria-hidden="true" />
@@ -277,7 +282,7 @@ export function AuditsBoard() {
);
})}
{visibleRows.length === 0 ? (
<Card className="sm:col-span-2 xl:col-span-3">
<Card className="agency-panel sm:col-span-2 xl:col-span-3">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>

View File

@@ -1,11 +1,40 @@
"use client"
"use client";
import { type FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, LockKeyhole } from "lucide-react";
import {
ArrowRight,
CheckCircle2,
FileSearch,
LockKeyhole,
ShieldCheck,
type LucideIcon,
} from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
const authSignals: Array<{
icon: LucideIcon;
label: string;
value: string;
}> = [
{
icon: FileSearch,
label: "Evidence",
value: "Audit-Aussagen bleiben belegbar.",
},
{
icon: ShieldCheck,
label: "Approval",
value: "Public Audit und Mail bleiben getrennt.",
},
{
icon: CheckCircle2,
label: "Safety",
value: "Versand erfolgt erst nach Finalbestätigung.",
},
];
export function AuthEntry() {
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
@@ -37,34 +66,42 @@ export function AuthEntry() {
}
return (
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-10">
<section className="grid w-full max-w-5xl overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm md:grid-cols-[1.05fr_0.95fr]">
<div className="flex min-h-[520px] flex-col justify-between border-b p-6 md:border-b-0 md:border-r lg:p-8">
<main className="dashboard-canvas flex min-h-dvh items-center justify-center px-6 py-10">
<section className="agency-panel grid w-full max-w-5xl overflow-hidden text-card-foreground md:grid-cols-[1.05fr_0.95fr]">
<div className="flex min-h-[520px] flex-col justify-between border-b border-border/75 p-6 md:border-b-0 md:border-r lg:p-8">
<div>
<div className="mb-8 inline-flex size-10 items-center justify-center rounded-lg border bg-background">
<LockKeyhole className="size-5" />
<div className="mb-8 inline-flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-md bg-primary font-heading text-sm font-black text-primary-foreground">
WP
</div>
<div>
<p className="font-heading text-sm font-semibold">
WebDev Pipeline
</p>
<p className="text-xs font-medium text-muted-foreground">
Agency Evidence Desk
</p>
</div>
</div>
<p className="text-sm font-medium text-muted-foreground">
WebDev Pipeline MVP
<p className="agency-kicker">
SaaS Workspace
</p>
<h1 className="mt-4 max-w-xl text-3xl font-semibold tracking-normal sm:text-4xl">
Lokale Webdesign-Leads recherchieren, auditieren und freigeben.
<h1 className="mt-4 max-w-xl font-heading text-3xl font-semibold tracking-normal sm:text-4xl">
Lokale Chancen belegen, prüfen und erst dann kontaktieren.
</h1>
<p className="mt-4 max-w-lg text-sm leading-6 text-muted-foreground sm:text-base">
Melde dich mit dem Admin-Konto an, um Kampagnen, Lead-Qualitaet,
Audit-Freigaben und Outreach-Schritte in einem Arbeitsbereich zu
steuern.
Melde dich an, um Kampagnen, Lead-Qualität, Audit-Evidence und
Outreach-Freigaben in einem geschützten Kunden-Workspace zu steuern.
</p>
</div>
<dl className="grid gap-4 pt-8 sm:grid-cols-3">
{[
["Recherche", "Google Places Quellen und Kontaktluecken."],
["Audit", "Website-Potenzial und Review-Status."],
["Outreach", "Manuelle Freigabe vor Versand."],
].map(([label, value]) => (
<div key={label}>
<dt className="text-sm font-medium">{label}</dt>
{authSignals.map(({ icon: Icon, label, value }) => (
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={label}>
<dt className="inline-flex items-center gap-2 text-sm font-semibold">
<Icon className="size-4 text-primary" />
{label}
</dt>
<dd className="mt-1 text-sm leading-5 text-muted-foreground">
{value}
</dd>
@@ -73,10 +110,10 @@ export function AuthEntry() {
</dl>
</div>
<div className="flex flex-col justify-center p-6 lg:p-8">
<div className="flex flex-col justify-center bg-background/45 p-6 lg:p-8">
<div className="mx-auto w-full max-w-sm">
<h2 className="text-2xl font-semibold tracking-normal">
Admin Login
<h2 className="font-heading text-2xl font-semibold tracking-normal">
Workspace Login
</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Melde dich mit E-Mail und Passwort an.
@@ -88,7 +125,7 @@ export function AuthEntry() {
<input
name="email"
type="email"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/35"
autoComplete="email"
required
placeholder="admin@firma.de"
@@ -99,7 +136,7 @@ export function AuthEntry() {
<input
name="password"
type="password"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/35"
autoComplete="current-password"
required
minLength={8}

View File

@@ -20,17 +20,22 @@ type BlacklistType =
| "email"
| "phone"
| "company"
| "google_place_id";
| "google_place_id"
| "source_business_id";
const blacklistTypeOptions: BlacklistType[] = [
"domain",
"email",
"phone",
"company",
"source_business_id",
"google_place_id",
];
function labelForType(type: BlacklistType): string {
if (type === "source_business_id") {
return "Source Business ID";
}
if (type === "google_place_id") {
return "Google Place ID";
}

View File

@@ -179,7 +179,7 @@ export function CampaignFormDialog({
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
Wähle Kategorie, PLZ, Radius und Lead-Limit je Kampagne.
</DialogDescription>
<DialogCloseButton />
</DialogHeader>
@@ -315,53 +315,28 @@ export function CampaignFormDialog({
)}
/>
<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="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}

View File

@@ -3,7 +3,7 @@
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 { Clock3, MapPin, Pencil, Play, RefreshCcw, Plus, ShieldCheck } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
@@ -256,12 +256,16 @@ export function CampaignsBoard() {
onSubmit={submitCampaign}
/>
<div className="flex flex-col gap-3 border-b pb-3 sm:flex-row sm:items-end sm:justify-between">
<div className="agency-panel flex flex-col gap-4 p-4 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">
<p className="agency-kicker">Controlled Sourcing</p>
<h1 className="mt-2 font-heading text-3xl font-semibold tracking-normal">
Kampagnen
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Plane Suchläufe mit festen Limits, sauberem Radius und manuellen
Prüfstationen vor Audit und Outreach.
</p>
</div>
<Button onClick={openCreateDialog} className="justify-start sm:w-auto">
@@ -275,7 +279,7 @@ export function CampaignsBoard() {
{actionLabel ? <p className="text-sm" role="status">{actionLabel}</p> : null}
{campaignsSorted.length === 0 ? (
<Card>
<Card className="agency-panel">
<CardHeader>
<CardTitle>Keine Kampagnen</CardTitle>
<CardDescription>
@@ -289,7 +293,11 @@ export function CampaignsBoard() {
const campaignTitleId = `campaign-title-${campaign._id}`;
return (
<Card aria-labelledby={campaignTitleId} key={campaign._id}>
<Card
aria-labelledby={campaignTitleId}
className="agency-panel overflow-hidden"
key={campaign._id}
>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
@@ -308,23 +316,26 @@ export function CampaignsBoard() {
</div>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<CardContent className="grid gap-3 text-sm">
<div className="evidence-surface flex flex-wrap items-center justify-between gap-3 rounded-md px-3 py-2">
<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>
<span className="font-semibold">{campaign.radiusKm} km</span>
</div>
<Separator className="bg-border" />
<div>
<p>Cadence: {recurrenceLabel[campaign.recurrence]}</p>
<p>
Limits: L {campaign.maxNewLeadsPerRun}, A{" "}
{campaign.maxAuditsPerRun}
<div className="grid gap-2 rounded-md border border-border/75 bg-background/60 p-3">
<p className="inline-flex items-center gap-2 font-medium">
<Clock3 className="size-3.5 text-primary" />
Cadence: {recurrenceLabel[campaign.recurrence]}
</p>
<p className="inline-flex items-center gap-2 font-medium">
<ShieldCheck className="size-3.5 text-primary" />
Lead-Limit: {campaign.maxNewLeadsPerRun}
</p>
</div>
<div>
<div className="grid gap-1 rounded-md bg-muted/45 p-3">
<p className="text-muted-foreground">
Letzter Lauf: {formatDateTime(campaign.lastRunAt)}
</p>
@@ -373,7 +384,7 @@ export function CampaignsBoard() {
</div>
)}
<Card>
<Card className="agency-panel">
<CardHeader>
<CardTitle>Aktuelle Run-Logs</CardTitle>
<CardDescription>
@@ -387,7 +398,7 @@ export function CampaignsBoard() {
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
) : (
visibleRuns.map((run) => (
<div className="rounded-md border p-3" key={run._id}>
<div className="rounded-md border border-border/75 bg-background/60 p-3" key={run._id}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">
{statusLabel[run.status] ?? run.status}

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LogOut } from "lucide-react";
import { CheckCircle2, LogOut, ShieldCheck } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
@@ -20,21 +20,41 @@ export function DashboardSidebar() {
const { data: session, isPending } = authClient.useSession();
return (
<aside className="flex w-full shrink-0 flex-col border-b bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-72 md:border-b-0 md:border-r">
<div className="flex h-16 items-center gap-3 border-b px-4">
<div className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
W
<aside className="flex w-full shrink-0 flex-col border-b border-sidebar-border bg-sidebar text-sidebar-foreground md:sticky md:top-0 md:min-h-dvh md:w-[19rem] md:border-b-0 md:border-r">
<div className="flex h-[5.25rem] items-center gap-3 border-b border-sidebar-border px-4">
<div className="flex size-11 items-center justify-center rounded-md bg-sidebar-primary font-heading text-sm font-black text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_75%)]">
WP
</div>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">WebDev Pipeline</p>
<p className="truncate text-xs text-muted-foreground">
Akquise Workspace
<p className="truncate font-heading text-sm font-semibold">
WebDev Pipeline
</p>
<p className="truncate text-xs font-medium text-muted-foreground">
Agency Evidence Desk
</p>
</div>
</div>
<div className="grid gap-2 border-b border-sidebar-border p-3">
<div className="rounded-md border border-sidebar-border bg-sidebar-accent p-3">
<p className="text-xs font-semibold text-sidebar-accent-foreground">
Workspace Safety
</p>
<div className="mt-2 grid gap-2 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-2">
<ShieldCheck className="size-3.5 text-sidebar-primary" />
Versand nur nach Freigabe
</span>
<span className="inline-flex items-center gap-2">
<CheckCircle2 className="size-3.5 text-sidebar-primary" />
Evidence vor Outreach
</span>
</div>
</div>
</div>
<nav
className="flex gap-1 overflow-x-auto p-3 md:grid md:overflow-visible"
className="flex gap-1 overflow-x-auto p-3 md:grid md:gap-1.5 md:overflow-visible"
aria-label="Dashboard navigation"
>
{dashboardNavigation.map((item) => {
@@ -48,9 +68,9 @@ 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 outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/50",
"flex h-10 shrink-0 items-center gap-2 rounded-md px-3 text-sm font-semibold outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/35",
isActive
? "bg-sidebar-primary text-sidebar-primary-foreground"
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--sidebar-primary-foreground),transparent_82%)]"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
href={item.href}
@@ -64,13 +84,13 @@ export function DashboardSidebar() {
})}
</nav>
<div className="border-t p-3 md:mt-auto">
<div className="mb-3 rounded-lg border bg-background p-3 md:block">
<p className="truncate text-sm font-medium">
{isPending ? "Lade..." : session?.user?.name ?? "Admin"}
<div className="border-t border-sidebar-border p-3 md:mt-auto">
<div className="mb-3 rounded-md border border-sidebar-border bg-sidebar-accent p-3 md:block">
<p className="truncate text-sm font-semibold">
{isPending ? "Lade..." : session?.user?.name ?? "Workspace"}
</p>
<p className="truncate text-xs text-muted-foreground">
{session?.user?.email ?? "admin@local"}
<p className="truncate text-xs font-medium text-muted-foreground">
{session?.user?.email ?? "team@workspace"}
</p>
</div>
<div className="mb-2">

View File

@@ -73,7 +73,7 @@ export function DashboardThemeProvider({ children }: { children: ReactNode }) {
<div
suppressHydrationWarning
className={cn(
"min-h-dvh bg-background text-foreground md:flex",
"dashboard-canvas min-h-dvh text-foreground md:flex",
theme === "dark" && "dark",
)}
>

View File

@@ -2,7 +2,16 @@
import { useQuery } from "convex/react";
import type { FunctionReturnType } from "convex/server";
import { ArrowRight, Building2, MapPin } from "lucide-react";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock3,
FileSearch,
MapPin,
ShieldAlert,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
@@ -24,6 +33,42 @@ const stageActionHref: Record<LeadFunnelStageId, string> = {
deferred: "/dashboard/leads",
};
const stageVisuals: Record<
LeadFunnelStageId,
{ surface: string; label: string; icon: LucideIcon }
> = {
missing_contact: {
surface: "review-surface",
label: "Kontakt klären",
icon: ShieldAlert,
},
audit_ready: {
surface: "evidence-surface",
label: "Evidence sammeln",
icon: FileSearch,
},
review_open: {
surface: "review-surface",
label: "Freigabe prüfen",
icon: Clock3,
},
contacted: {
surface: "safe-surface",
label: "Kontakt läuft",
icon: CheckCircle2,
},
follow_up: {
surface: "safe-surface",
label: "Follow-up",
icon: Clock3,
},
deferred: {
surface: "bg-[var(--danger-soft)] text-destructive",
label: "Zurückgestellt",
icon: ShieldAlert,
},
};
export function LeadFunnelBoard() {
const leads: LeadFunnelQueryResult | undefined = useQuery(
api.leads.listFunnel,
@@ -40,14 +85,14 @@ export function LeadFunnelBoard() {
if (totalCards === 0) {
return (
<section
className="rounded-lg border bg-card p-6 text-card-foreground"
className="rounded-lg border border-border/80 bg-card p-6 text-card-foreground"
aria-labelledby="lead-funnel-heading"
>
<p className="text-sm font-medium text-muted-foreground">
<p className="text-sm font-semibold text-muted-foreground">
Lead-Funnel
</p>
<h2
className="mt-2 text-xl font-semibold tracking-normal"
className="mt-2 font-heading text-xl font-semibold tracking-normal"
id="lead-funnel-heading"
>
Noch keine Leads im Arbeitsfluss
@@ -61,62 +106,85 @@ export function LeadFunnelBoard() {
}
return (
<section className="grid gap-3" aria-labelledby="lead-funnel-heading">
<section className="agency-panel p-4" aria-labelledby="lead-funnel-heading">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="agency-kicker">Lead Workflow</p>
<h2
className="text-xl font-semibold tracking-normal"
className="mt-1 font-heading text-xl font-semibold tracking-normal"
id="lead-funnel-heading"
>
Lead-Funnel
Evidence Pipeline
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{totalCards} Leads nach Kontaktlage, Audit-Stand und nächster
manueller Aktion.
{totalCards} Leads nach nächster Entscheidung, Beleglage und
Outreach-Sicherheit.
</p>
</div>
<p className="text-sm font-medium text-muted-foreground">
Kein automatischer Versand
<p className="rounded-md bg-[var(--surface-review)] px-2.5 py-1 text-sm font-bold text-secondary-foreground">
Human approval required
</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="mt-4 grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{groups.map((group) => (
<LeadFunnelStageView group={group} key={group.stage.id} />
))}
</div>
</section>
);
}
<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>
))}
function LeadFunnelStageView({
group,
}: {
group: ReturnType<typeof groupLeadFunnelCards>[number];
}) {
const visual = stageVisuals[group.stage.id];
const Icon = visual.icon;
return (
<section
className="rounded-md border border-border/80 bg-background/55"
aria-labelledby={`${group.stage.id}-heading`}
>
<div className="flex items-start justify-between gap-3 border-b border-border/75 p-3">
<div className="flex min-w-0 gap-3">
<span
className={`flex size-10 shrink-0 items-center justify-center rounded-md ${visual.surface}`}
>
<Icon className="size-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-bold uppercase text-muted-foreground">
{visual.label}
</p>
<h3
className="mt-1 text-sm font-semibold"
id={`${group.stage.id}-heading`}
>
{group.stage.title}
</h3>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{group.stage.description}
</p>
</div>
</div>
<span className="rounded-md bg-card px-2 py-1 text-sm font-bold">
{group.cards.length}
</span>
</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 border-border/80 bg-card/50 p-3 text-xs leading-5 text-muted-foreground">
Keine Leads in diesem Entscheidungsschritt.
</p>
)}
</div>
</section>
);
@@ -125,7 +193,7 @@ export function LeadFunnelBoard() {
function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
return (
<article
className="rounded-lg border bg-background p-3"
className="rounded-lg border border-border/80 bg-background/65 p-3"
aria-labelledby={`${card.id}-company`}
>
<div className="flex items-start justify-between gap-2">
@@ -143,7 +211,7 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
</div>
<span
className={cn(
"shrink-0 rounded-md px-2 py-1 text-xs font-medium",
"shrink-0 rounded-md px-2 py-1 text-xs font-semibold",
card.priorityLabel === "Hoch"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
@@ -159,16 +227,16 @@ function LeadFunnelCardView({ card }: { card: LeadFunnelCard }) {
</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">
<span className="rounded-md bg-secondary px-2 py-1 text-xs font-semibold text-secondary-foreground">
{card.contactStatusLabel}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium 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"
className="mt-3 inline-flex min-h-8 items-center gap-1 rounded-md text-sm font-semibold text-primary outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/35"
href={stageActionHref[card.stageId]}
prefetch={false}
>
@@ -189,7 +257,7 @@ function LeadFunnelSkeleton() {
<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"
className="min-h-[24rem] rounded-lg border border-border/80 bg-card p-3"
key={index}
>
<div className="h-5 w-28 rounded-md bg-muted" />

View File

@@ -3,7 +3,7 @@
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 { Building2, ExternalLink, Mail, MapPin, Phone, PlayCircle, ShieldAlert } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
@@ -39,6 +39,10 @@ import { Switch } from "@/components/ui/switch";
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
type LeadRow = NonNullable<LeadsListResult>[number];
type AuditStartStatesResult = FunctionReturnType<
typeof api.pageSpeed.getLeadAuditStartStates
>;
type AuditStartState = NonNullable<AuditStartStatesResult>[number];
type LeadReviewDraft = {
priority: LeadPriority;
@@ -80,6 +84,33 @@ function normalizeTextInput(value: string): string | undefined {
return next.length > 0 ? next : undefined;
}
function toEmailHref(email?: string | null): string | null {
const normalizedEmail = normalizeTextInput(email ?? "");
return normalizedEmail ? `mailto:${normalizedEmail}` : null;
}
function toPhoneHref(phone?: string | null): string | null {
const normalizedPhone = normalizeTextInput(phone ?? "");
const dialablePhone = normalizedPhone?.replace(/[^\d+]/g, "");
return dialablePhone ? `tel:${dialablePhone}` : null;
}
function toWebsiteHref(lead: Pick<LeadRow, "websiteDomain" | "websiteUrl">): string | null {
const website = normalizeTextInput(lead.websiteUrl ?? "") ?? normalizeTextInput(lead.websiteDomain ?? "");
if (!website) {
return null;
}
if (/^https?:\/\//i.test(website)) {
return website;
}
return `https://${website.replace(/^\/+/, "")}`;
}
function contactSourceLabel(lead: LeadRow): string {
if (lead.sourceProvider) {
return lead.sourceProvider;
@@ -139,6 +170,45 @@ function duplicateBadgeVariant(
return "outline";
}
function auditStartDisabledReason({
lead,
auditStartState,
isLoading,
isStarting,
}: {
lead: LeadRow;
auditStartState?: AuditStartState;
isLoading: boolean;
isStarting: boolean;
}) {
if (isStarting) {
return "Audit läuft";
}
if (!lead.websiteUrl) {
return "Keine Website hinterlegt.";
}
if (
lead.priority === "blocked" ||
lead.priority === "defer" ||
lead.blacklistStatus === "blocked" ||
lead.contactStatus === "do_not_contact"
) {
return "Lead ist gesperrt oder zurückgestellt.";
}
if (isLoading) {
return "Audit-Status wird geladen.";
}
if (auditStartState && !auditStartState.canStart) {
return auditStartState.reason ?? "Audit kann aktuell nicht gestartet werden.";
}
return null;
}
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
@@ -151,6 +221,21 @@ export function LeadsReviewTable() {
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
const auditStartStates = useQuery(
api.pageSpeed.getLeadAuditStartStates,
leads
? {
leadIds: sortedLeads.map((lead) => lead._id),
}
: "skip",
);
const auditStartStateByLeadId = useMemo(() => {
const next = new Map<string, AuditStartState>();
for (const state of auditStartStates ?? []) {
next.set(state.leadId, state);
}
return next;
}, [auditStartStates]);
const filteredLeads = useMemo(() => {
if (activeFilter === "high") {
return sortedLeads.filter((lead) => lead.priority === "high");
@@ -178,16 +263,20 @@ export function LeadsReviewTable() {
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 className="agency-panel mx-auto flex w-full max-w-7xl flex-col gap-2 p-4">
<p className="agency-kicker">Lead Intake</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Leads prüfen</h1>
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
Kontaktlage, Sperrlisten, Duplikate und Audit-Start bleiben vor jedem
Outreach als überprüfbare Entscheidungen sichtbar.
</p>
</div>
<div className="mx-auto flex w-full max-w-7xl flex-wrap gap-2" aria-label="Lead-Filter">
{leadStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
className="agency-tab"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
@@ -201,7 +290,7 @@ export function LeadsReviewTable() {
<div className="mx-auto grid w-full max-w-7xl gap-3">
{leads === undefined ? (
Array.from({ length: 4 }, (_, index) => (
<Card key={index}>
<Card className="agency-panel" key={index}>
<CardHeader>
<div className="h-5 w-2/3 rounded-md bg-muted" />
<div className="h-4 w-1/2 rounded-md bg-muted" />
@@ -210,7 +299,7 @@ export function LeadsReviewTable() {
</Card>
))
) : sortedLeads.length === 0 ? (
<Card>
<Card className="agency-panel">
<CardHeader>
<p className="text-sm font-medium">Keine Leads vorhanden</p>
<p className="text-sm text-muted-foreground">
@@ -219,7 +308,7 @@ export function LeadsReviewTable() {
</CardHeader>
</Card>
) : filteredLeads.length === 0 ? (
<Card>
<Card className="agency-panel">
<CardHeader>
<p className="text-sm font-medium">Keine Treffer</p>
<p className="text-sm text-muted-foreground">
@@ -232,6 +321,8 @@ export function LeadsReviewTable() {
<LeadReviewRow
key={lead._id}
lead={lead}
auditStartState={auditStartStateByLeadId.get(lead._id)}
auditStartStateLoading={auditStartStates === undefined}
onActionMessage={setActionMessage}
/>
))
@@ -249,9 +340,13 @@ export function LeadsReviewTable() {
function LeadReviewRow({
lead,
auditStartState,
auditStartStateLoading,
onActionMessage,
}: {
lead: LeadRow;
auditStartState?: AuditStartState;
auditStartStateLoading: boolean;
onActionMessage: (value: string) => void;
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -270,16 +365,28 @@ function LeadReviewRow({
}));
const [isSaving, setIsSaving] = useState(false);
const [isBlocking, setIsBlocking] = useState(false);
const [isStartingAudit, setIsStartingAudit] = useState(false);
const [rowMessage, setRowMessage] = useState<string | null>(null);
const reviewUpdate = useMutation(api.leads.reviewUpdate);
const requestLeadAudit = useMutation(api.pageSpeed.requestLeadAudit);
const location = formatLocation(lead);
const emailHref = toEmailHref(lead.email);
const phoneHref = toPhoneHref(lead.phone);
const websiteHref = toWebsiteHref(lead);
const websiteLabel = lead.websiteDomain ?? lead.websiteUrl;
const reasonParts = [
lead.priorityReason,
lead.contactStatusReason,
lead.duplicateReason,
lead.blacklistReason,
].filter((item): item is string => Boolean(item));
const manualAuditDisabledReason = auditStartDisabledReason({
lead,
auditStartState,
isLoading: auditStartStateLoading,
isStarting: isStartingAudit,
});
const update = async (
payload?: Omit<LeadReviewPayload, "id">,
@@ -342,6 +449,28 @@ function LeadReviewRow({
setIsBlocking(false);
};
const startAudit = async () => {
if (manualAuditDisabledReason) {
setRowMessage(manualAuditDisabledReason);
return;
}
setIsStartingAudit(true);
setRowMessage(null);
onActionMessage("");
try {
const result = await requestLeadAudit({ leadId: lead._id });
setRowMessage(result.message);
onActionMessage(result.message);
} catch {
setRowMessage("Audit-Start fehlgeschlagen");
} finally {
setIsStartingAudit(false);
setTimeout(() => setRowMessage(null), 1800);
}
};
const updateDraft = <T extends keyof LeadReviewDraft>(
field: T,
value: LeadReviewDraft[T],
@@ -364,7 +493,7 @@ function LeadReviewRow({
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
return (
<Card aria-labelledby={titleId}>
<Card aria-labelledby={titleId} className="agency-panel overflow-hidden">
<CardHeader className="pb-3">
<div className="grid min-w-0 gap-2">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
@@ -398,27 +527,62 @@ function LeadReviewRow({
<div className="grid min-w-0 gap-1 text-xs text-muted-foreground">
<p className="inline-flex min-w-0 items-center gap-1">
<Mail className="size-3 shrink-0" />
<span className="max-w-full min-w-0 break-all">
{lead.email || "Keine E-Mail"}
</span>
{emailHref ? (
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={emailHref}>
{lead.email}
</a>
) : (
<span className="max-w-full min-w-0 break-all">Keine E-Mail</span>
)}
</p>
{lead.phone ? (
{lead.phone && phoneHref ? (
<p className="inline-flex min-w-0 items-center gap-1">
<Phone className="size-3 shrink-0" />
<span className="max-w-full min-w-0 break-all">{lead.phone}</span>
<a className="max-w-full min-w-0 break-all hover:text-foreground hover:underline" href={phoneHref}>
{lead.phone}
</a>
</p>
) : null}
<p className="truncate max-w-full">
Quelle: {contactSourceLabel(lead)}
</p>
{lead.websiteDomain ? (
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
{websiteHref && websiteLabel ? (
<p className="inline-flex min-w-0 max-w-full items-center gap-1">
<ExternalLink className="size-3 shrink-0" />
<span className="shrink-0">Website:</span>
<a
className="min-w-0 truncate hover:text-foreground hover:underline"
href={websiteHref}
rel="noreferrer"
target="_blank"
>
{websiteLabel}
</a>
</p>
) : null}
</div>
</div>
</CardHeader>
<div className="border-t p-4 pt-3">
<div className="flex flex-wrap gap-2 border-t bg-muted/25 p-4 pt-3">
<div className="grid gap-1">
<Button
type="button"
variant="default"
onClick={startAudit}
disabled={manualAuditDisabledReason !== null}
size="sm"
title={manualAuditDisabledReason ?? "Audit manuell starten"}
>
<PlayCircle className="size-4" />
Audit starten
</Button>
{manualAuditDisabledReason ? (
<p className="max-w-56 text-xs text-muted-foreground">
{manualAuditDisabledReason}
</p>
) : null}
</div>
<Button
type="button"
variant="outline"

View File

@@ -4,7 +4,16 @@ import { useMemo, useState } from "react";
import { useAction, useMutation, useQuery } from "convex/react";
import type { FunctionReturnType } from "convex/server";
import { ChevronDown, ChevronRight, ExternalLink, MailCheck, Save } from "lucide-react";
import {
CheckCircle2,
ChevronDown,
ChevronRight,
ExternalLink,
FileSearch,
MailCheck,
Save,
ShieldCheck,
} from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
@@ -176,9 +185,9 @@ function FieldPair({ label, value }: { label: string; value?: string | null }) {
function WorkspaceLoading() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Approval Bench</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
</header>
<div className="space-y-3">
{Array.from({ length: 3 }, (_, index) => (
@@ -244,11 +253,11 @@ export function OutreachReviewWorkspace() {
if (rows.length === 0) {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Approval Bench</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
</header>
<Card>
<Card className="agency-panel">
<CardContent className="p-4">
<p className="text-sm font-medium">Keine offenen Reviews</p>
<p className="mt-1 text-sm text-muted-foreground">
@@ -482,9 +491,9 @@ export function OutreachReviewWorkspace() {
return (
<section className="space-y-4">
<header className="space-y-2">
<p className="text-sm text-muted-foreground">Interne Outreach-Prüfung</p>
<h1 className="text-2xl font-semibold tracking-normal">Review Workspace</h1>
<header className="agency-panel space-y-2 p-4">
<p className="agency-kicker">Approval Bench</p>
<h1 className="font-heading text-2xl font-semibold tracking-normal">Review Workspace</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Audits, E-Mail-Empfehlung und Telefonnotizen prüfen, bevor etwas öffentlich
wird oder eine Freigabe erhält.
@@ -492,7 +501,7 @@ export function OutreachReviewWorkspace() {
</header>
{notice ? (
<p className="rounded-md border bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
<p className="rounded-md border border-border/75 bg-muted/30 px-3 py-2 text-sm" role="status">{notice}</p>
) : null}
<Dialog
@@ -560,7 +569,7 @@ export function OutreachReviewWorkspace() {
size="sm"
type="button"
>
Senden
Final senden
</Button>
<Button onClick={closeEmailConfirmation} size="sm" type="button" variant="outline">
Abbrechen
@@ -570,14 +579,15 @@ export function OutreachReviewWorkspace() {
) : null}
</Dialog>
<section className="space-y-3" aria-label="Review-Queue">
<div className="grid gap-4 xl:grid-cols-[minmax(18rem,0.78fr)_minmax(0,1.22fr)]">
<section className="agency-panel space-y-3 p-3" aria-label="Review-Queue">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Review-Queue</h2>
<div className="flex flex-wrap gap-2" aria-label="Review-Filter">
{reviewStatusFilters.map((filter) => (
<button
aria-pressed={activeFilter === filter.value}
className="inline-flex min-h-8 items-center gap-2 rounded-md border px-3 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted aria-pressed:border-foreground aria-pressed:text-foreground"
className="agency-tab"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
@@ -589,7 +599,7 @@ export function OutreachReviewWorkspace() {
</div>
</div>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3">
{filteredRows.map((record) => {
const lead = record.lead;
const audit = record.audit;
@@ -602,8 +612,10 @@ export function OutreachReviewWorkspace() {
<Card
aria-labelledby={queueTitleId}
className={cn(
"flex min-w-0 flex-col",
selectedRecord?.id === record.id ? "border-foreground" : "",
"flex min-w-0 flex-col overflow-hidden",
selectedRecord?.id === record.id
? "border-primary bg-[var(--surface-evidence)]"
: "bg-background/60",
)}
key={record.id}
>
@@ -656,7 +668,7 @@ export function OutreachReviewWorkspace() {
);
})}
{filteredRows.length === 0 ? (
<Card className="lg:col-span-2 xl:col-span-3">
<Card className="agency-panel">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>
@@ -695,8 +707,8 @@ export function OutreachReviewWorkspace() {
const publicAuditHref = audit?.slug ? `/audit/${audit.slug}` : null;
return (
<Card className="overflow-hidden" key={record.id}>
<CardHeader className="gap-3 border-b bg-muted/20 p-4">
<Card className="agency-panel overflow-hidden" key={record.id}>
<CardHeader className="gap-4 border-b bg-muted/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-1">
<CardTitle className="break-words text-lg">
@@ -719,11 +731,49 @@ export function OutreachReviewWorkspace() {
</Badge>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-4">
<div className="evidence-surface rounded-md px-3 py-2">
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
<FileSearch className="size-3.5" />
Evidence
</span>
<p className="mt-1 text-sm font-semibold">
{audit ? "Audit vorhanden" : "Audit offen"}
</p>
</div>
<div className="review-surface rounded-md px-3 py-2">
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
<ShieldCheck className="size-3.5" />
Public Audit
</span>
<p className="mt-1 text-sm font-semibold">
{audit?.status === "published" ? "Veröffentlicht" : "Prüfung offen"}
</p>
</div>
<div className="safe-surface rounded-md px-3 py-2">
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
<MailCheck className="size-3.5" />
E-Mail
</span>
<p className="mt-1 text-sm font-semibold">
{isEmailDraftReady(record) ? "Bereit" : "Entwurf offen"}
</p>
</div>
<div className="rounded-md border border-border/75 bg-background/70 px-3 py-2">
<span className="inline-flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
<CheckCircle2 className="size-3.5 text-primary" />
Final Send
</span>
<p className="mt-1 text-sm font-semibold">
{isQueuedSend ? "Wird gesendet" : "Bestätigung nötig"}
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5 p-4">
<section className="grid gap-4 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="space-y-3">
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
<h2 className="text-sm font-semibold">Lead-Details</h2>
<dl className="grid gap-3 sm:grid-cols-2">
<FieldPair label="Nische" value={lead?.niche} />
@@ -752,7 +802,7 @@ export function OutreachReviewWorkspace() {
</div>
</div>
<div className="space-y-3">
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold">Audit-Zusammenfassung</h2>
{publicAuditHref ? (
@@ -828,7 +878,7 @@ export function OutreachReviewWorkspace() {
</section>
<section className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
<h2 className="text-sm font-semibold">Empfohlene E-Mail</h2>
<label className="block space-y-1">
<span className="text-xs font-medium text-muted-foreground">
@@ -877,12 +927,12 @@ export function OutreachReviewWorkspace() {
type="button"
>
<MailCheck className="size-3.5" />
E-Mail freigeben und senden
E-Mail freigeben
</Button>
</div>
</div>
<div className="space-y-3">
<div className="space-y-3 rounded-md border border-border/75 bg-background/60 p-3">
<h2 className="text-sm font-semibold">Telefon & Follow-up</h2>
{hasCallablePhone ? (
<label className="block space-y-1">
@@ -997,6 +1047,7 @@ export function OutreachReviewWorkspace() {
);
})() : null}
</div>
</div>
</section>
);
}

View File

@@ -3,14 +3,15 @@ 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",
"inline-flex h-6 items-center rounded-md border px-2 py-0.5 text-xs font-semibold",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
outline:
"text-foreground border-border bg-background hover:bg-muted/40",
"border-border/90 bg-background/70 text-foreground hover:bg-muted/50",
destructive:
"border-transparent bg-destructive text-destructive-foreground",
},

View File

@@ -5,13 +5,14 @@ import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/35 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
default:
"bg-primary text-primary-foreground shadow-[inset_0_1px_0_color-mix(in_oklch,var(--primary-foreground),transparent_82%)] hover:bg-[color-mix(in_oklch,var(--primary),var(--foreground)_10%)]",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"border-border/90 bg-background/70 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:

View File

@@ -8,7 +8,10 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground", className)}
className={cn(
"rounded-lg border border-border/80 bg-card text-card-foreground shadow-[0_1px_0_color-mix(in_oklch,var(--foreground),transparent_94%)]",
className,
)}
{...props}
/>
));
@@ -21,7 +24,7 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4", className)}
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
));
@@ -33,7 +36,10 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-base leading-none font-semibold tracking-normal", className)}
className={cn(
"font-heading text-base leading-none font-semibold tracking-normal",
className,
)}
{...props}
/>
));

View File

@@ -2,5 +2,5 @@
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
"agentsMdSectionHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
"claudeMdHash": "5934f676ea9a332e7cd4a4f64aa23b59d926e9faca026c758d4b1f87d2101cc3",
"agentSkillsSha": "294d4f05edb5e7b57f3c534b79dd00e8e3d7b60d"
"agentSkillsSha": "7a6fcc6882f344577a34365fdadbd0f8f8c467d7"
}

View File

@@ -205,8 +205,8 @@ const auditGenerationUsage = v.object({
const secretHints = [
"OPENROUTER_API_KEY",
"GOOGLE_PLACES_API_KEY",
"GOOGLE_GEOCODING_API_KEY",
"LOCAL_BUSINESS_DATA_API_KEY",
"RAPIDAPI_KEY",
"PAGESPEED_API_KEY",
"SMTP_PASSWORD",
"SMTP_HOST",

View File

@@ -98,8 +98,8 @@ function sanitizeAndCapString(value: string | undefined, maxBytes: number) {
const secretHints = [
"OPENROUTER_API_KEY",
"GOOGLE_PLACES_API_KEY",
"GOOGLE_GEOCODING_API_KEY",
"LOCAL_BUSINESS_DATA_API_KEY",
"RAPIDAPI_KEY",
"PAGESPEED_API_KEY",
"SMTP_PASSWORD",
"SMTP_HOST",

View File

@@ -17,6 +17,7 @@ const blacklistType = v.union(
v.literal("phone"),
v.literal("company"),
v.literal("google_place_id"),
v.literal("source_business_id"),
);
type BlacklistType =
@@ -24,7 +25,8 @@ type BlacklistType =
| "email"
| "phone"
| "company"
| "google_place_id";
| "google_place_id"
| "source_business_id";
const BLACKLIST_APPLY_BATCH_SIZE = 100;
const BLACKLIST_REVIEW_NOTE_PREFIX =
@@ -51,6 +53,7 @@ type LeadMatchingFieldsPatch = Partial<
| "normalizedCompanyName"
| "normalizedAddress"
| "normalizedGooglePlaceId"
| "normalizedSourceBusinessId"
>
> & {
updatedAt: number;
@@ -138,6 +141,13 @@ function getLeadMatchQuery(
.withIndex("by_normalizedGooglePlaceId", (q) =>
q.eq("normalizedGooglePlaceId", normalizedValue),
);
case "source_business_id":
return () =>
ctx.db
.query("leads")
.withIndex("by_normalizedSourceBusinessId", (q) =>
q.eq("normalizedSourceBusinessId", normalizedValue),
);
default:
return null;
}
@@ -152,6 +162,9 @@ function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
const normalizedCompanyName = normalizeText(lead.companyName);
const normalizedAddress = normalizeText(lead.address);
const normalizedGooglePlaceId = normalizeDomain(lead.googlePlaceId);
const normalizedSourceBusinessId = normalizeDomain(
lead.sourceBusinessId ?? lead.googlePlaceId,
);
if (!lead.normalizedEmail && normalizedEmail) {
patch.normalizedEmail = normalizedEmail;
@@ -168,6 +181,9 @@ function buildLeadMatchingFieldsPatch(lead: Doc<"leads">) {
if (!lead.normalizedGooglePlaceId && normalizedGooglePlaceId) {
patch.normalizedGooglePlaceId = normalizedGooglePlaceId;
}
if (!lead.normalizedSourceBusinessId && normalizedSourceBusinessId) {
patch.normalizedSourceBusinessId = normalizedSourceBusinessId;
}
return Object.keys(patch).length > 1 ? patch : null;
}
@@ -200,6 +216,7 @@ function normalizeBlacklistValue(type: BlacklistType, value: string) {
return normalizePhone(trimmed);
case "domain":
case "google_place_id":
case "source_business_id":
return normalizeDomain(trimmed);
case "company":
return normalizeText(trimmed);

View File

@@ -6,6 +6,8 @@ const SECRET_KEY_PATTERNS = [
/credential/i,
/smtp/i,
/openrouter/i,
/rapidapi/i,
/local[_-]?business[_-]?data/i,
/google[_-]?(geocoding|places)?/i,
/pagespeed/i,
/rybbit/i,
@@ -77,6 +79,7 @@ export const BLACKLIST_TYPES = [
"phone",
"company",
"google_place_id",
"source_business_id",
] as const;
export const RUN_TYPES = [
"campaign",
@@ -131,6 +134,7 @@ export const USAGE_EVENT_PROVIDERS = [
"jina",
"pagespeed",
"google_places",
"local_business_data",
] as const;
export const USAGE_EVENT_OPERATIONS = [
"audit_capture",

View File

@@ -1,23 +1,23 @@
import { v } from "convex/values";
import {
GOOGLE_PLACES_FIELD_MASK,
buildGeocodingUrl,
getBlacklistLookupValues,
getBlacklistMatches,
getCandidateEmailValues,
getPlacesSearchSpec,
getUsableContactEmail,
normalizeDomain,
normalizePhone,
normalizeText,
normalizePlacesResponse,
parseGeocodingResponse,
} from "../lib/lead-discovery-google";
import {
LOCAL_BUSINESS_DATA_HOST,
getLocalBusinessSearchSpec,
normalizeLocalBusinessSearchResponse,
} from "../lib/lead-discovery-local-business";
import {
buildLeadDiscoveryLeadRecord,
buildLeadDiscoveryCounters,
getLeadDiscoveryPriority,
shouldScheduleWebsiteEnrichment,
} from "../lib/lead-discovery-run";
import { calculateNextRunAt } from "../lib/campaign-scheduling";
@@ -26,12 +26,21 @@ import { Doc, Id } from "./_generated/dataModel";
import { internalAction, internalMutation } from "./_generated/server";
type CampaignDoc = Doc<"campaigns">;
type DuplicateEmailBackfillPatch = Partial<
Pick<
Doc<"leads">,
"normalizedEmail" | "email" | "emailSource" | "contactPerson" | "contactStatus" | "contactStatusReason"
>
> & {
updatedAt: number;
};
const nullableString = v.union(v.string(), v.null());
const nullableNumber = v.union(v.number(), v.null());
const candidateValidator = v.object({
placeId: v.string(),
sourceBusinessId: v.optional(nullableString),
businessName: v.string(),
address: v.string(),
websiteUrl: nullableString,
@@ -57,7 +66,10 @@ const candidateValidator = v.object({
}),
),
),
sourceProvider: v.literal("google_places"),
sourceProvider: v.union(
v.literal("google_places"),
v.literal("local_business_data"),
),
sourceFetchedAt: v.number(),
});
@@ -98,7 +110,7 @@ async function fetchJson(url: string, init?: RequestInit) {
if (!response.ok) {
const body = await response.text();
throw new Error(
`Google API request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
`Local Business Data request failed with HTTP ${response.status}: ${body.slice(0, 500)}`,
);
}
@@ -122,89 +134,54 @@ export const processCampaignRun = internalAction({
}
try {
const geocodingApiKey = getRequiredEnv("GOOGLE_GEOCODING_API_KEY");
const placesApiKey = getRequiredEnv("GOOGLE_PLACES_API_KEY");
const localBusinessDataApiKey = getRequiredEnv("LOCAL_BUSINESS_DATA_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({
const searchSpec = getLocalBusinessSearchSpec({
categoryMode: campaign.categoryMode,
category: campaign.category,
customSearchTerm: campaign.customSearchTerm,
postalCode: campaign.postalCode,
radiusKm: campaign.radiusKm,
latitude,
longitude,
maxNewLeads: campaign.maxNewLeadsPerRun,
});
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 localBusinessJson = await fetchJson(searchSpec.url, {
method: "GET",
headers: {
"X-RapidAPI-Key": localBusinessDataApiKey,
"X-RapidAPI-Host": LOCAL_BUSINESS_DATA_HOST,
},
});
const candidates = normalizeLocalBusinessSearchResponse(
localBusinessJson,
Date.now(),
);
const candidates = normalizePlacesResponse(placesJson, Date.now());
await ctx.runMutation(internal.usageEvents.recordUsageEvent, {
provider: "local_business_data",
operation: "lead_lookup",
runId: args.runId,
estimatedCostUsd: 0,
callCounts: {
requests: 1,
lookups: candidates.length,
},
});
if (candidates.length === 0) {
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "warning",
message: "Google Places lieferte keine Ergebnisse.",
message: "Local Business Data lieferte keine Ergebnisse.",
details: [
{ label: "Suchtyp", value: searchSpec.searchType, source: "google_places" },
{ label: "Kategorie", value: getCampaignNiche(campaign), source: "google_places" },
{
label: "Suchquery",
value: searchSpec.query,
source: "local_business_data",
},
{
label: "Kategorie",
value: getCampaignNiche(campaign),
source: "local_business_data",
},
],
});
}
@@ -215,11 +192,6 @@ export const processCampaignRun = internalAction({
skippedDuplicates: number;
skippedBlacklisted: number;
errors: number;
websiteEnrichmentQueue: Array<{
leadId: Id<"leads">;
companyName: string;
website: string;
}>;
} = await ctx.runMutation(internal.leadDiscovery.persistDiscoveredLeads, {
runId: args.runId,
campaignId: campaign._id,
@@ -229,31 +201,6 @@ export const processCampaignRun = internalAction({
candidates,
});
for (const enrichment of result.websiteEnrichmentQueue) {
await ctx.runMutation(internal.websiteEnrichment.queueLeadEnrichment, {
leadId: enrichment.leadId,
parentRunId: args.runId,
});
await ctx.runMutation(internal.leadDiscovery.appendRunEvent, {
runId: args.runId,
level: "info",
message: "Website-Kontaktanreicherung geplant.",
details: [
{
label: "Unternehmen",
value: enrichment.companyName,
source: "google_places",
},
{
label: "Website",
value: enrichment.website,
source: "google_places",
},
],
});
}
await ctx.runMutation(internal.leadDiscovery.finishCampaignRun, {
runId: args.runId,
status: "succeeded",
@@ -423,11 +370,6 @@ export const persistDiscoveredLeads = internalMutation({
let skippedDuplicates = 0;
let skippedBlacklisted = 0;
let errors = 0;
const websiteEnrichmentQueue: Array<{
leadId: Id<"leads">;
companyName: string;
website: string;
}> = [];
for (const candidate of args.candidates) {
if (leadsCreated >= args.maxNewLeads) {
@@ -446,7 +388,7 @@ export const persistDiscoveredLeads = internalMutation({
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "warning",
message: "Google-Places-Ergebnis ohne Unternehmensname übersprungen.",
message: "Lead-Recherche-Ergebnis ohne Unternehmensname übersprungen.",
details: [{ label: "Place ID", value: candidate.placeId }],
createdAt: Date.now(),
});
@@ -454,6 +396,9 @@ export const persistDiscoveredLeads = internalMutation({
}
const normalizedPlaceId = normalizeDomain(candidate.placeId);
const normalizedSourceBusinessId = normalizeDomain(
candidate.sourceBusinessId ?? candidate.placeId,
);
const normalizedDomain = normalizeDomain(candidate.websiteDomain);
const normalizedEmails = getCandidateEmailValues(candidate);
const normalizedPhone = normalizePhone(candidate.phone);
@@ -476,6 +421,15 @@ export const persistDiscoveredLeads = internalMutation({
.take(1)
: [];
const duplicateBySourceBusinessId = normalizedSourceBusinessId
? await ctx.db
.query("leads")
.withIndex("by_normalizedSourceBusinessId", (q) =>
q.eq("normalizedSourceBusinessId", normalizedSourceBusinessId),
)
.take(1)
: [];
const duplicateByEmailRows = [];
for (const email of normalizedEmails) {
const rows = await ctx.db
@@ -487,17 +441,84 @@ export const persistDiscoveredLeads = internalMutation({
if (
duplicateByPlaceId.length > 0 ||
duplicateBySourceBusinessId.length > 0 ||
duplicateByDomain.length > 0 ||
duplicateByEmailRows.length > 0
) {
skippedDuplicates += 1;
const duplicateLeadForEmailBackfill =
duplicateBySourceBusinessId[0] ??
duplicateByPlaceId[0] ??
duplicateByDomain[0] ??
null;
const usableEmail = getUsableContactEmail(candidate);
if (
duplicateLeadForEmailBackfill &&
usableEmail &&
!duplicateLeadForEmailBackfill.email &&
duplicateLeadForEmailBackfill.contactStatus !== "do_not_contact" &&
duplicateLeadForEmailBackfill.blacklistStatus !== "blocked" &&
duplicateLeadForEmailBackfill.priority !== "blocked" &&
duplicateByEmailRows.length === 0
) {
const emailBackfillPatch: DuplicateEmailBackfillPatch = {
normalizedEmail: usableEmail.email,
email: usableEmail.email,
updatedAt: now,
};
if (usableEmail.emailSource !== null) {
emailBackfillPatch.emailSource = usableEmail.emailSource;
}
if (usableEmail.contactPerson !== null) {
emailBackfillPatch.contactPerson = usableEmail.contactPerson;
}
if (duplicateLeadForEmailBackfill.contactStatus === "missing_contact") {
emailBackfillPatch.contactStatus = "new";
emailBackfillPatch.contactStatusReason =
"E-Mail bei erneutem Local-Business-Data-Lauf ergänzt.";
}
await ctx.db.patch(
duplicateLeadForEmailBackfill._id,
emailBackfillPatch,
);
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "E-Mail für bestehenden Lead ergänzt.",
details: [
{
label: "Unternehmen",
value: duplicateLeadForEmailBackfill.companyName,
source: candidate.sourceProvider,
},
{
label: "E-Mail-Quelle",
value: usableEmail.emailSource ?? "local_business_data",
source: candidate.sourceProvider,
},
],
createdAt: Date.now(),
});
}
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" },
{
label: "Unternehmen",
value: candidate.businessName,
source: candidate.sourceProvider,
},
{
label: "Source ID",
value: candidate.sourceBusinessId ?? candidate.placeId,
source: candidate.sourceProvider,
},
],
createdAt: Date.now(),
});
@@ -569,13 +590,16 @@ export const persistDiscoveredLeads = internalMutation({
const priorityResult = getLeadDiscoveryPriority({
isDuplicate: !!probableDuplicateLead,
hasWebsite,
hasWebsiteSignal: false, // plain Google-Places website hint maps to medium priority.
hasWebsiteSignal: false,
});
const isDuplicateCandidate = !!probableDuplicateLead;
if (normalizedPlaceId) {
lead.normalizedGooglePlaceId = normalizedPlaceId;
}
if (normalizedSourceBusinessId) {
lead.normalizedSourceBusinessId = normalizedSourceBusinessId;
}
if (normalizedPhone !== "") {
lead.normalizedPhone = normalizedPhone;
}
@@ -596,20 +620,21 @@ export const persistDiscoveredLeads = internalMutation({
const leadId = await ctx.db.insert("leads", lead);
leadsCreated += 1;
if (shouldScheduleWebsiteEnrichment(lead)) {
websiteEnrichmentQueue.push({
leadId,
companyName: lead.companyName,
website: lead.websiteDomain ?? lead.websiteUrl ?? "unbekannt",
});
}
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Lead aus Google Places gespeichert.",
message: "Lead aus Local Business Data gespeichert.",
details: [
{ label: "Unternehmen", value: candidate.businessName, source: "google_places" },
{ label: "Place ID", value: candidate.placeId, source: "google_places" },
{
label: "Unternehmen",
value: candidate.businessName,
source: candidate.sourceProvider,
},
{
label: "Source ID",
value: candidate.sourceBusinessId ?? candidate.placeId,
source: candidate.sourceProvider,
},
],
createdAt: Date.now(),
});
@@ -634,7 +659,6 @@ export const persistDiscoveredLeads = internalMutation({
skippedDuplicates,
skippedBlacklisted,
errors,
websiteEnrichmentQueue,
};
},
});

View File

@@ -263,7 +263,10 @@ export const create = mutation({
googleRating: v.optional(v.number()),
googleUserRatingCount: v.optional(v.number()),
googleBusinessStatus: v.optional(v.string()),
sourceProvider: v.optional(v.literal("google_places")),
sourceProvider: v.optional(
v.union(v.literal("google_places"), v.literal("local_business_data")),
),
sourceBusinessId: v.optional(v.string()),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
@@ -285,6 +288,7 @@ export const create = mutation({
duplicateOfLeadId: v.optional(v.id("leads")),
blacklistStatus: v.optional(leadBlacklistStatus),
normalizedGooglePlaceId: v.optional(v.string()),
normalizedSourceBusinessId: v.optional(v.string()),
notes: v.optional(v.string()),
},
handler: async (ctx, args) => {
@@ -298,6 +302,7 @@ export const create = mutation({
normalizedCompanyName: args.normalizedCompanyName,
normalizedAddress: args.normalizedAddress,
normalizedGooglePlaceId: args.normalizedGooglePlaceId,
normalizedSourceBusinessId: args.normalizedSourceBusinessId,
priority: args.priority ?? "medium",
contactStatus: args.contactStatus ?? "new",
duplicateStatus: args.duplicateStatus ?? "unchecked",

View File

@@ -1,6 +1,11 @@
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation } from "./_generated/server";
import {
internalMutation,
mutation,
query,
} from "./_generated/server";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { v } from "convex/values";
const PAGE_SPEED_COUNTER_TEMPLATE = {
@@ -17,6 +22,13 @@ type PageSpeedLead = Pick<
> & {
websiteUrl: string;
};
type AuditStartState = {
leadId: Id<"leads">;
canStart: boolean;
reason?: string;
activeRunId?: Id<"agentRuns">;
activeRunStatus?: Doc<"agentRuns">["status"];
};
const runStatus = v.union(
v.literal("pending"),
@@ -39,6 +51,231 @@ const pageSpeedErrorType = v.union(
v.literal("unknown"),
);
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
};
async function getActivePageSpeedAuditRun(
ctx: MutationCtx | QueryCtx,
leadId: Id<"leads">,
) {
const existingPending = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q.eq("type", "audit").eq("status", "pending").eq("leadId", leadId),
)
.take(1);
if (existingPending[0]) {
return existingPending[0];
}
const existingRunning = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q.eq("type", "audit").eq("status", "running").eq("leadId", leadId),
)
.take(1);
return existingRunning[0] ?? null;
}
async function getActiveAuditGenerationRun(
ctx: MutationCtx | QueryCtx,
leadId: Id<"leads">,
) {
const existingPending = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "audit_generation")
.eq("status", "pending")
.eq("leadId", leadId),
)
.take(1);
if (existingPending[0]) {
return existingPending[0];
}
const existingRunning = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "audit_generation")
.eq("status", "running")
.eq("leadId", leadId),
)
.take(1);
return existingRunning[0] ?? null;
}
async function getLeadAuditStartState(
ctx: MutationCtx | QueryCtx,
leadId: Id<"leads">,
): Promise<AuditStartState> {
const lead = await ctx.db.get(leadId);
if (!lead) {
return {
leadId,
canStart: false,
reason: "Lead nicht gefunden.",
};
}
if (
lead.priority === "blocked" ||
lead.priority === "defer" ||
lead.blacklistStatus === "blocked" ||
lead.contactStatus === "do_not_contact"
) {
return {
leadId,
canStart: false,
reason: "Lead ist gesperrt oder zurueckgestellt.",
};
}
if (!lead.websiteUrl) {
return {
leadId,
canStart: false,
reason: "Keine Website hinterlegt.",
};
}
const activeAuditRun =
(await getActivePageSpeedAuditRun(ctx, leadId)) ??
(await getActiveAuditGenerationRun(ctx, leadId));
if (activeAuditRun) {
return {
leadId,
canStart: false,
reason: "Audit laeuft bereits.",
activeRunId: activeAuditRun._id,
activeRunStatus: activeAuditRun.status,
};
}
return {
leadId,
canStart: true,
};
}
async function queueLeadPageSpeedAuditForLead(
ctx: MutationCtx,
args: {
leadId: Id<"leads">;
parentRunId?: Id<"agentRuns">;
triggeredBy: "internal" | "manual";
},
): Promise<Id<"agentRuns"> | null> {
const state = await getLeadAuditStartState(ctx, args.leadId);
if (!state.canStart) {
return state.activeRunId ?? null;
}
const now = Date.now();
const runId = await ctx.db.insert("agentRuns", {
type: "audit",
leadId: args.leadId,
status: "pending",
currentStep: "pagespeed_insights",
counters: PAGE_SPEED_COUNTER_TEMPLATE,
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId,
level: "info",
message:
args.triggeredBy === "manual"
? "Audit-Start wurde manuell angefordert."
: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
details: [
{ label: "Lead", value: args.leadId },
...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []),
],
createdAt: now,
});
await ctx.scheduler.runAfter(
0,
internal.pageSpeedAction.processPageSpeedAudit,
{
runId,
},
);
return runId;
}
export const getLeadAuditStartStates = query({
args: {
leadIds: v.array(v.id("leads")),
},
returns: v.array(
v.object({
leadId: v.id("leads"),
canStart: v.boolean(),
reason: v.optional(v.string()),
activeRunId: v.optional(v.id("agentRuns")),
activeRunStatus: v.optional(runStatus),
}),
),
handler: async (ctx, args): Promise<AuditStartState[]> => {
await requireOperator(ctx);
const states: AuditStartState[] = [];
for (const leadId of args.leadIds.slice(0, 120)) {
states.push(await getLeadAuditStartState(ctx, leadId));
}
return states;
},
});
export const requestLeadAudit = mutation({
args: {
leadId: v.id("leads"),
},
returns: v.object({
runId: v.union(v.id("agentRuns"), v.null()),
message: v.string(),
}),
handler: async (ctx, args): Promise<{ runId: Id<"agentRuns"> | null; message: string }> => {
await requireOperator(ctx);
const state = await getLeadAuditStartState(ctx, args.leadId);
if (!state.canStart) {
return {
runId: state.activeRunId ?? null,
message: state.reason ?? "Audit kann aktuell nicht gestartet werden.",
};
}
const runId = await queueLeadPageSpeedAuditForLead(ctx, {
leadId: args.leadId,
triggeredBy: "manual",
});
return {
runId,
message: "Audit-Start wurde manuell angefordert.",
};
},
});
export const queueLeadPageSpeedAudit = internalMutation({
args: {
leadId: v.id("leads"),
@@ -46,68 +283,11 @@ export const queueLeadPageSpeedAudit = internalMutation({
},
returns: v.union(v.id("agentRuns"), v.null()),
handler: async (ctx, args): Promise<Id<"agentRuns"> | null> => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead || lead.priority === "blocked" || lead.priority === "defer") {
return null;
}
if (!lead.websiteUrl) {
return null;
}
const existingPending = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q.eq("type", "audit").eq("status", "pending").eq("leadId", args.leadId),
)
.take(1);
const existingRunning = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q.eq("type", "audit").eq("status", "running").eq("leadId", args.leadId),
)
.take(1);
if (existingPending.length > 0) {
return existingPending[0]._id;
}
if (existingRunning.length > 0) {
return existingRunning[0]._id;
}
const runId = await ctx.db.insert("agentRuns", {
type: "audit",
return await queueLeadPageSpeedAuditForLead(ctx, {
leadId: args.leadId,
status: "pending",
currentStep: "pagespeed_insights",
counters: PAGE_SPEED_COUNTER_TEMPLATE,
createdAt: now,
updatedAt: now,
parentRunId: args.parentRunId,
triggeredBy: "internal",
});
await ctx.db.insert("agentRunEvents", {
runId,
level: "info",
message: "PageSpeed-Analyse wurde in die Warteschlange gesetzt.",
details: [
{ label: "Lead", value: args.leadId },
...(args.parentRunId ? [{ label: "Parent-Run", value: args.parentRunId }] : []),
],
createdAt: now,
});
await ctx.scheduler.runAfter(
0,
internal.pageSpeedAction.processPageSpeedAudit,
{
runId,
},
);
return runId;
},
});

View File

@@ -87,6 +87,7 @@ const blacklistType = v.union(
v.literal("phone"),
v.literal("company"),
v.literal("google_place_id"),
v.literal("source_business_id"),
);
const websiteEnrichmentPageKind = v.union(
v.literal("homepage"),
@@ -250,13 +251,17 @@ export default defineSchema({
postalCode: v.optional(v.string()),
googlePlaceId: v.optional(v.string()),
normalizedGooglePlaceId: v.optional(v.string()),
sourceBusinessId: v.optional(v.string()),
normalizedSourceBusinessId: 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")),
sourceProvider: v.optional(
v.union(v.literal("google_places"), v.literal("local_business_data")),
),
sourceFetchedAt: v.optional(v.number()),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
@@ -292,6 +297,7 @@ export default defineSchema({
"normalizedAddress",
])
.index("by_normalizedGooglePlaceId", ["normalizedGooglePlaceId"])
.index("by_normalizedSourceBusinessId", ["normalizedSourceBusinessId"])
.index("by_googlePlaceId", ["googlePlaceId"])
.index("by_websiteDomain", ["websiteDomain"])
.index("by_normalizedCompanyName", ["normalizedCompanyName"])

View File

@@ -951,27 +951,6 @@ async function processLeadEnrichmentWithoutBrowser(
});
}
try {
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
leadId: lead._id,
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
details: [
{ label: "Lead", value: lead._id },
{
label: "Fehler",
value: messageFromError(pageSpeedQueueError),
source: "pagespeed_queue",
},
],
});
}
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
runId,
status: "succeeded",
@@ -1012,27 +991,6 @@ export const processLeadEnrichment = internalAction({
const rootUrl = normalizeCrawlUrl(started.lead.websiteUrl);
if (!rootUrl) {
try {
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
leadId: started.lead._id,
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
details: [
{ label: "Lead", value: started.lead._id },
{
label: "Fehler",
value: messageFromError(pageSpeedQueueError),
source: "pagespeed_queue",
},
],
});
}
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
runId,
status: "failed",
@@ -1341,27 +1299,6 @@ export const processLeadEnrichment = internalAction({
});
}
try {
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
leadId: started.lead._id,
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
details: [
{ label: "Lead", value: started.lead._id },
{
label: "Fehler",
value: messageFromError(pageSpeedQueueError),
source: "pagespeed_queue",
},
],
});
}
await ctx.runMutation(internal.websiteEnrichment.finishLeadEnrichmentRun, {
runId,
status: "succeeded",
@@ -1400,26 +1337,6 @@ export const processLeadEnrichment = internalAction({
});
if (started) {
try {
await ctx.runMutation(internal.pageSpeed.queueLeadPageSpeedAudit, {
leadId: started.lead._id,
parentRunId: runId,
});
} catch (pageSpeedQueueError) {
await ctx.runMutation(internal.runs.appendEventInternal, {
runId,
level: "warning",
message: "PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden.",
details: [
{ label: "Lead", value: started.lead._id },
{
label: "Fehler",
value: messageFromError(pageSpeedQueueError),
source: "pagespeed_queue",
},
],
});
}
await ctx.runMutation(internal.websiteEnrichment.patchLeadFromWebsiteEnrichment, {
leadId: started.lead._id,
currentContactStatus: started.lead.contactStatus,

View File

@@ -10,8 +10,7 @@ Set production values in Coolify and Convex secrets, not in source code.
- `NEXT_PUBLIC_CONVEX_SITE_URL`
- `CONVEX_DEPLOYMENT`
- `BETTER_AUTH_SECRET`
- `GOOGLE_GEOCODING_API_KEY`
- `GOOGLE_PLACES_API_KEY`
- `LOCAL_BUSINESS_DATA_API_KEY`
- `PAGESPEED_API_KEY`
- `PAGESPEED_TIMEOUT_MS`
- `OPENROUTER_API_KEY`

View File

@@ -17,7 +17,7 @@ Diese Checkliste ist die wiederholbare manuelle Prüfung für die kritischen MVP
## Audit-Generierung
1. Lead mit Website durch externe Audit-Services laufen lassen.
2. Prüfen, dass Google, PageSpeed, OpenRouter und ScreenshotOne als serverseitig verwaltete Provider konfiguriert sind.
2. Prüfen, dass Local Business Data, PageSpeed, OpenRouter und ScreenshotOne als serverseitig verwaltete Provider konfiguriert sind.
3. Prüfen, dass fehlendes Jina keine Blockade auslöst.
4. Im Outreach Review Workspace prüfen, dass Audit-Text, Quellen und Skills sichtbar sind.

View File

@@ -363,9 +363,9 @@ export const pipelineStages: PipelineStage[] = [
},
{
title: "Lead-Recherche",
description: "Neue Places-Quellen, Kontaktluecken und Dubletten.",
description: "Neue Firmendaten, Kontaktquellen und Dubletten.",
count: 18,
meta: "5 Leads brauchen E-Mail-Quelle",
meta: "Audits starten nach Freigabe",
icon: UsersRound,
},
{

View File

@@ -235,6 +235,8 @@ type GooglePlaceContactEmailSource = {
isBusinessContactAddress?: boolean;
};
export type LeadDiscoverySourceProvider = "google_places" | "local_business_data";
type GooglePlaceApiPlace = {
id?: string;
displayName?: GooglePlaceDisplayName;
@@ -256,6 +258,7 @@ export type GooglePlacesApiResponse = {
export type GooglePlaceCandidate = {
placeId: string;
sourceBusinessId?: string | null;
businessName: string;
address: string;
websiteUrl: string | null;
@@ -272,7 +275,7 @@ export type GooglePlaceCandidate = {
googleTypes: string[];
googlePrimaryType: string | null;
googleMapsUrl: string | null;
sourceProvider: "google_places";
sourceProvider: LeadDiscoverySourceProvider;
sourceFetchedAt: number;
};
@@ -501,6 +504,7 @@ export function normalizePlacesResponse(
export type ExistingLeadLike = {
googlePlaceId?: string | null;
sourceBusinessId?: string | null;
websiteDomain?: string | null;
email?: string | null;
companyName?: string | null;
@@ -509,13 +513,13 @@ export type ExistingLeadLike = {
};
export type BlacklistRow = {
type: "domain" | "email" | "phone" | "company" | "google_place_id";
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
value: string;
normalizedValue: string;
};
export type BlacklistLookupValue = {
type: "domain" | "email" | "phone" | "company" | "google_place_id";
type: "domain" | "email" | "phone" | "company" | "google_place_id" | "source_business_id";
normalizedValue: string;
};
@@ -560,6 +564,10 @@ export function getBlacklistLookupValues(
type: "google_place_id",
normalizedValue: normalizeDomain(candidate.placeId),
},
{
type: "source_business_id",
normalizedValue: normalizeDomain(candidate.sourceBusinessId ?? candidate.placeId),
},
{
type: "domain",
normalizedValue: normalizeDomain(candidate.websiteDomain),
@@ -588,16 +596,22 @@ export function isDuplicateCandidate(
existing: ExistingLeadLike[],
): boolean {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateSourceBusinessId = normalizeDomain(
candidate.sourceBusinessId ?? candidate.placeId,
);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateEmails = getCandidateEmailValues(candidate);
return existing.some((entry) => {
const entryPlaceId = normalizeDomain(entry.googlePlaceId);
const entrySourceBusinessId = normalizeDomain(entry.sourceBusinessId);
const entryDomain = normalizeDomain(entry.websiteDomain);
const entryEmail = normalizeEmailAddress(entry.email);
return (
(candidatePlaceId && entryPlaceId === candidatePlaceId) ||
(candidateSourceBusinessId &&
entrySourceBusinessId === candidateSourceBusinessId) ||
(candidateDomain && entryDomain === candidateDomain) ||
candidateEmails.some(
(candidateEmail) => candidateEmail && entryEmail === candidateEmail,
@@ -638,6 +652,9 @@ export function getBlacklistMatches(
blacklistRows: BlacklistRow[],
) {
const candidatePlaceId = normalizeDomain(candidate.placeId);
const candidateSourceBusinessId = normalizeDomain(
candidate.sourceBusinessId ?? candidate.placeId,
);
const candidateDomain = normalizeDomain(candidate.websiteDomain);
const candidateCompany = normalizeText(candidate.businessName);
const candidatePhone = normalizePhone(candidate.phone);
@@ -650,6 +667,11 @@ export function getBlacklistMatches(
switch (row.type) {
case "google_place_id":
return candidatePlaceId !== "" && row.normalizedValue === candidatePlaceId;
case "source_business_id":
return (
candidateSourceBusinessId !== "" &&
row.normalizedValue === candidateSourceBusinessId
);
case "domain":
return candidateDomain !== "" && row.normalizedValue === candidateDomain;
case "company":

View File

@@ -0,0 +1,317 @@
import {
normalizeEmailAddress,
type GooglePlaceCandidate,
} from "./lead-discovery-google";
export const LOCAL_BUSINESS_DATA_HOST = "local-business-data.p.rapidapi.com";
export const LOCAL_BUSINESS_DATA_SEARCH_ENDPOINT =
`https://${LOCAL_BUSINESS_DATA_HOST}/search`;
export const LOCAL_BUSINESS_DATA_MAX_RESULTS = 500;
type CampaignLike = {
categoryMode?: "preset" | "custom";
category?: string | null;
customSearchTerm?: string | null;
postalCode: string;
maxNewLeads: number;
};
type LocalBusinessDataError = {
message?: string;
code?: number;
};
type LocalBusinessDataBusiness = Record<string, unknown>;
export type LocalBusinessDataResponse = {
status?: string;
request_id?: string;
error?: LocalBusinessDataError;
data?: unknown;
};
function normalizeSearchTerm(value?: string | null) {
return value?.trim() ?? "";
}
function clampSearchLimit(limit: number) {
if (!Number.isFinite(limit)) {
return 1;
}
return Math.min(Math.max(Math.floor(limit), 1), LOCAL_BUSINESS_DATA_MAX_RESULTS);
}
function stringValue(...values: unknown[]) {
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
function numberValue(...values: unknown[]) {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
}
return null;
}
function stringArrayValue(...values: unknown[]) {
const result: string[] = [];
for (const value of values) {
if (typeof value === "string" && value.trim().length > 0) {
result.push(value.trim());
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string" && item.trim().length > 0) {
result.push(item.trim());
} else if (item && typeof item === "object") {
const email = stringValue(
(item as Record<string, unknown>).email,
(item as Record<string, unknown>).value,
);
if (email) {
result.push(email);
}
}
}
}
}
return [...new Set(result)];
}
function objectValue(value: unknown) {
return value && typeof value === "object"
? (value as Record<string, unknown>)
: null;
}
function normalizeWebsiteDomain(input?: string | null) {
if (!input) {
return null;
}
try {
const url = new URL(input);
return url.hostname.toLowerCase().replace(/^www\./, "");
} catch {
try {
const url = new URL(`https://${input}`);
return url.hostname.toLowerCase().replace(/^www\./, "");
} catch {
return null;
}
}
}
function normalizeWebsiteUrl(value?: string | null) {
if (!value) {
return null;
}
try {
return new URL(value).toString();
} catch {
try {
return new URL(`https://${value}`).toString();
} catch {
return null;
}
}
}
function extractBusinesses(data: unknown): LocalBusinessDataBusiness[] {
if (Array.isArray(data)) {
return data.filter(
(item): item is LocalBusinessDataBusiness =>
item !== null && typeof item === "object",
);
}
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
for (const key of ["businesses", "results", "items"]) {
const value = record[key];
if (Array.isArray(value)) {
return value.filter(
(item): item is LocalBusinessDataBusiness =>
item !== null && typeof item === "object",
);
}
}
}
return [];
}
export function buildLocalBusinessSearchUrl({
query,
limit,
language = "de",
region = "de",
}: {
query: string;
limit: number;
language?: string;
region?: string;
}) {
const url = new URL(LOCAL_BUSINESS_DATA_SEARCH_ENDPOINT);
url.searchParams.set("query", query);
url.searchParams.set("limit", String(clampSearchLimit(limit)));
url.searchParams.set("language", language);
url.searchParams.set("region", region);
url.searchParams.set("extract_emails_and_contacts", "true");
return url.toString();
}
export function getLocalBusinessSearchSpec(campaignLike: CampaignLike) {
const category = normalizeSearchTerm(campaignLike.category);
const baseTerm =
campaignLike.categoryMode === "custom" || category === "Anderes"
? normalizeSearchTerm(campaignLike.customSearchTerm)
: category;
const query = `${baseTerm || "Unternehmen"} in ${campaignLike.postalCode} Deutschland`;
const limit = clampSearchLimit(campaignLike.maxNewLeads);
return {
query,
limit,
url: buildLocalBusinessSearchUrl({ query, limit }),
};
}
export function normalizeLocalBusinessSearchResponse(
response: LocalBusinessDataResponse,
fetchedAt: number,
): GooglePlaceCandidate[] {
if (!response || response.status === "ERROR") {
const code = response?.error?.code ?? "unknown";
const message = response?.error?.message ?? "Unknown Local Business Data error.";
throw new Error(`Local Business Data API error ${code}: ${message}`);
}
const businesses = extractBusinesses(response.data);
return businesses.flatMap((business) => {
const sourceBusinessId = stringValue(
business.business_id,
business.businessId,
business.id,
);
const placeId = stringValue(
business.place_id,
business.google_place_id,
business.google_id,
business.googleId,
sourceBusinessId,
);
const businessName = stringValue(
business.name,
business.business_name,
business.title,
);
if (!placeId || !businessName) {
return [];
}
const websiteUrl = normalizeWebsiteUrl(
stringValue(business.website, business.site, business.url),
);
const emailsAndContacts = objectValue(business.emails_and_contacts);
const directEmails = stringArrayValue(
business.email,
business.emails,
business.contact_emails,
business.contacts,
emailsAndContacts?.emails,
);
const seenEmails = new Set<string>();
const contactEmails = directEmails.flatMap((email) => {
const normalizedEmail = normalizeEmailAddress(email);
if (!normalizedEmail || seenEmails.has(normalizedEmail)) {
return [];
}
seenEmails.add(normalizedEmail);
return [
{
email: normalizedEmail,
emailSource: "local_business_data",
isBusinessContactAddress:
normalizedEmail.split("@")[0]?.includes(".") === false,
},
];
});
return [
{
placeId,
sourceBusinessId: sourceBusinessId ?? placeId,
businessName,
address:
stringValue(
business.full_address,
business.address,
business.formatted_address,
) ?? "",
websiteUrl,
websiteDomain: normalizeWebsiteDomain(websiteUrl),
phone:
stringValue(
business.phone_number,
business.phone,
business.telephone,
) ?? null,
email: contactEmails[0]?.email ?? null,
emailSource: contactEmails[0]?.emailSource ?? null,
contactEmails,
rating: numberValue(business.rating, business.google_rating),
userRatingCount: numberValue(
business.review_count,
business.reviews_count,
business.user_ratings_total,
),
businessStatus:
stringValue(business.business_status, business.status) ?? null,
googleTypes: stringArrayValue(
business.types,
business.subtypes,
business.category,
),
googlePrimaryType:
stringValue(business.type, business.primary_type, business.category) ??
null,
googleMapsUrl:
stringValue(
business.google_maps_url,
business.google_maps_link,
business.maps_url,
) ?? null,
sourceProvider: "local_business_data",
sourceFetchedAt: fetchedAt,
},
];
});
}

View File

@@ -142,12 +142,14 @@ export function buildLeadDiscoveryLeadRecord<
googleRating?: number;
googleUserRatingCount?: number;
googleBusinessStatus?: string;
sourceProvider: "google_places";
sourceProvider: "google_places" | "local_business_data";
sourceBusinessId?: string;
sourceFetchedAt: number;
websiteUrl?: string;
websiteDomain?: string;
phone?: string;
normalizedGooglePlaceId?: string;
normalizedSourceBusinessId?: string;
normalizedEmail?: string;
normalizedPhone?: string;
normalizedCompanyName?: string;
@@ -191,6 +193,7 @@ export function buildLeadDiscoveryLeadRecord<
const googleRating = optionalNumber(input.candidate.rating);
const googleUserRatingCount = optionalNumber(input.candidate.userRatingCount);
const googleBusinessStatus = optionalString(input.candidate.businessStatus);
const sourceBusinessId = optionalString(input.candidate.sourceBusinessId);
const websiteUrl = optionalString(input.candidate.websiteUrl);
const websiteDomain = optionalString(input.candidate.websiteDomain);
const phone = optionalString(input.candidate.phone);
@@ -225,6 +228,9 @@ export function buildLeadDiscoveryLeadRecord<
if (googleBusinessStatus !== undefined) {
lead.googleBusinessStatus = googleBusinessStatus;
}
if (sourceBusinessId !== undefined) {
lead.sourceBusinessId = sourceBusinessId;
}
if (websiteUrl !== undefined) {
lead.websiteUrl = websiteUrl;
}

View File

@@ -2,7 +2,7 @@ export type IntegrationReadinessStatus = "configured" | "missing";
export type IntegrationReadinessDefinition = {
id:
| "google"
| "local_business_data"
| "pagespeed"
| "openrouter"
| "screenshotone"
@@ -22,10 +22,11 @@ export type IntegrationReadinessRow = IntegrationReadinessDefinition & {
export const integrationReadinessDefinitions: IntegrationReadinessDefinition[] = [
{
id: "google",
label: "Google",
requiredEnv: ["GOOGLE_GEOCODING_API_KEY", "GOOGLE_PLACES_API_KEY"],
errorSurface: "Run-Events der Lead-Recherche zeigen Google-Fehler.",
id: "local_business_data",
label: "Local Business Data",
requiredEnv: ["LOCAL_BUSINESS_DATA_API_KEY"],
errorSurface:
"Run-Events der Lead-Recherche zeigen Local-Business-Data-Fehler.",
},
{
id: "pagespeed",

View File

@@ -11,13 +11,13 @@
"source": "get-convex/agent-skills",
"sourceType": "github",
"skillPath": "skills/convex-create-component/SKILL.md",
"computedHash": "25b6f56cc6afa4237aa191f5bfa5b86f68b70dc7f1195b86d027bd85346cff41"
"computedHash": "012acb639fccc22a47e89ef69941689f9328ac9ff5b872d77af6328407ec8876"
},
"convex-migration-helper": {
"source": "get-convex/agent-skills",
"sourceType": "github",
"skillPath": "skills/convex-migration-helper/SKILL.md",
"computedHash": "47ad936c1977eecca736211fe4efb171b53c412b49cffc42267095276a8df36d"
"computedHash": "1ed5ef230d484e9884d881ddbd15158f0070382f8c6097364cb8ed2ec458decd"
},
"convex-performance-audit": {
"source": "get-convex/agent-skills",

View File

@@ -0,0 +1,23 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const campaignFormDialogPath = join(
process.cwd(),
"components",
"campaigns",
"campaign-form-dialog.tsx",
);
test("CampaignFormDialog presents campaigns as lead discovery only", async () => {
const source = await readFile(campaignFormDialogPath, "utf8");
assert.match(source, /Max\. neue Leads/);
assert.doesNotMatch(source, /Max\. Audits/);
assert.match(
source,
/maxAuditsPerRun:\s*campaign\.maxAuditsPerRun\s*\?\?\s*campaignFormDefaults\.maxAuditsPerRun/,
);
assert.match(source, /maxAuditsPerRun:\s*values\.maxAuditsPerRun/);
});

View File

@@ -29,6 +29,8 @@ test("campaign board renders campaigns as responsive cards", async () => {
assert.match(source, /openEditDialog\(campaign\)/);
assert.match(source, /toggleCampaign\(campaign\)/);
assert.match(source, /runCampaign\(campaign\)/);
assert.match(source, /Lead-Limit:\s*\{campaign\.maxNewLeadsPerRun\}/);
assert.doesNotMatch(source, /Limits:\s*L/);
});
test("campaign board surfaces recent run logs", async () => {

View File

@@ -15,6 +15,8 @@ test("settings metadata rejects secret-like keys", () => {
"OPENROUTER_API_KEY",
"smtp.password",
"googlePlacesToken",
"LOCAL_BUSINESS_DATA_API_KEY",
"rapidapi.token",
"provider_secret",
"convex credential",
];

View File

@@ -211,6 +211,7 @@ test("duplicate detection uses placeId and websiteDomain", () => {
const existingLeads = [
{
googlePlaceId: "dup-1",
sourceBusinessId: "business-dup-1",
websiteDomain: "other.de",
email: "blocked@example.de",
},
@@ -233,6 +234,31 @@ test("duplicate detection uses placeId and websiteDomain", () => {
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "google_places",
sourceBusinessId: "business-other",
sourceFetchedAt: 0,
},
existingLeads,
),
true,
);
assert.equal(
isDuplicateCandidate(
{
placeId: "none",
businessName: "Test",
address: "A",
websiteUrl: null,
websiteDomain: null,
phone: null,
rating: null,
userRatingCount: null,
businessStatus: null,
googleTypes: [],
googlePrimaryType: null,
googleMapsUrl: null,
sourceProvider: "local_business_data",
sourceBusinessId: "business-dup-1",
sourceFetchedAt: 0,
},
existingLeads,
@@ -439,9 +465,10 @@ test("probable duplicates are detected by normalized company+address or normaliz
);
});
test("blacklist matches include google_place_id, domain, company and phone", () => {
test("blacklist matches include source ids, domain, company and phone", () => {
const candidate = {
placeId: "place-blacklisted",
sourceBusinessId: "business-blacklisted",
businessName: "Muster GmbH",
address: "A",
websiteUrl: "https://www.Blocked.de",
@@ -461,6 +488,7 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
assert.deepEqual(getBlacklistLookupValues(candidate), [
{ type: "google_place_id", normalizedValue: "place-blacklisted" },
{ type: "source_business_id", normalizedValue: "business-blacklisted" },
{ type: "domain", normalizedValue: "blocked.de" },
{ type: "company", normalizedValue: "muster gmbh" },
{ type: "phone", normalizedValue: "4930555123" },
@@ -477,6 +505,11 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
value: "place-blacklisted",
normalizedValue: "place-blacklisted",
},
{
type: "source_business_id",
value: "business-blacklisted",
normalizedValue: "business-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" },
@@ -498,7 +531,15 @@ test("blacklist matches include google_place_id, domain, company and phone", ()
const matchTypes = matches.map((match) => match.type).sort();
assert.deepEqual(
matchTypes,
["company", "domain", "google_place_id", "phone", "phone", "email"].sort(),
[
"company",
"domain",
"google_place_id",
"source_business_id",
"phone",
"phone",
"email",
].sort(),
);
});
@@ -522,6 +563,7 @@ test("company normalization for blacklist lookup uses text normalization", () =>
assert.deepEqual(getBlacklistLookupValues(candidate), [
{ type: "google_place_id", normalizedValue: "place-company-spaces" },
{ type: "source_business_id", normalizedValue: "place-company-spaces" },
{ type: "company", normalizedValue: "muster gmbh" },
{ type: "phone", normalizedValue: "4930555123" },
{ type: "phone", normalizedValue: "+49 30 555 123" },

View File

@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
LOCAL_BUSINESS_DATA_HOST,
buildLocalBusinessSearchUrl,
getLocalBusinessSearchSpec,
normalizeLocalBusinessSearchResponse,
} from "../lib/lead-discovery-local-business";
test("Local Business Data search URL uses RapidAPI host-compatible query params", () => {
const url = new URL(
buildLocalBusinessSearchUrl({
query: "Anwalt in 10115 Deutschland",
limit: 9999,
}),
);
assert.equal(url.origin, `https://${LOCAL_BUSINESS_DATA_HOST}`);
assert.equal(url.pathname, "/search");
assert.equal(url.searchParams.get("query"), "Anwalt in 10115 Deutschland");
assert.equal(url.searchParams.get("limit"), "500");
assert.equal(url.searchParams.get("language"), "de");
assert.equal(url.searchParams.get("region"), "de");
assert.equal(url.searchParams.get("extract_emails_and_contacts"), "true");
});
test("Local Business Data campaign spec builds the SaaS lead-discovery query from niche and PLZ", () => {
const spec = getLocalBusinessSearchSpec({
categoryMode: "custom",
category: "Anderes",
customSearchTerm: "Webdesigner fuer Restaurants",
postalCode: "79098",
maxNewLeads: 8,
});
assert.equal(spec.query, "Webdesigner fuer Restaurants in 79098 Deutschland");
assert.equal(spec.limit, 8);
assert.match(spec.url, /extract_emails_and_contacts=true/);
});
test("Local Business Data response normalizes direct business emails into lead candidates", () => {
const candidates = normalizeLocalBusinessSearchResponse(
{
status: "OK",
request_id: "req-123",
data: [
{
business_id: "biz-1",
place_id: "place-1",
name: "Kanzlei Beispiel",
full_address: "Musterstrasse 1, 10115 Berlin",
website: "https://www.beispiel-kanzlei.de/kontakt",
phone_number: "+49 30 123456",
rating: 4.7,
review_count: 31,
business_status: "OPERATIONAL",
types: ["lawyer"],
google_maps_url: "https://maps.google.com/?cid=biz-1",
emails: ["Herr.Bewerber@beispiel-kanzlei.de", "Info@Beispiel-Kanzlei.de"],
},
],
},
1717480000000,
);
assert.equal(candidates.length, 1);
assert.equal(candidates[0]?.sourceProvider, "local_business_data");
assert.equal(candidates[0]?.sourceBusinessId, "biz-1");
assert.equal(candidates[0]?.placeId, "place-1");
assert.equal(candidates[0]?.businessName, "Kanzlei Beispiel");
assert.equal(candidates[0]?.websiteDomain, "beispiel-kanzlei.de");
assert.equal(candidates[0]?.contactEmails?.length, 2);
assert.equal(candidates[0]?.contactEmails?.[1]?.email, "info@beispiel-kanzlei.de");
assert.equal(candidates[0]?.contactEmails?.[1]?.emailSource, "local_business_data");
});
test("Local Business Data response reads extracted emails from emails_and_contacts", () => {
const candidates = normalizeLocalBusinessSearchResponse(
{
status: "OK",
request_id: "req-nested-email",
data: [
{
business_id: "biz-nested",
name: "PORZIG Immobilien GmbH",
full_address: "Silberstrasse 18, 08451 Crimmitschau",
website: "https://porzig.info",
phone_number: "+49 3762 759775",
emails_and_contacts: {
emails: [
"info@porzig.info",
"Info@Porzig.Info",
"makler@porzig.info",
],
},
},
],
},
1717480000003,
);
assert.equal(candidates.length, 1);
assert.equal(candidates[0]?.contactEmails?.length, 2);
assert.equal(candidates[0]?.email, "info@porzig.info");
assert.equal(candidates[0]?.emailSource, "local_business_data");
assert.deepEqual(
candidates[0]?.contactEmails?.map((entry) => entry.email),
["info@porzig.info", "makler@porzig.info"],
);
});
test("Local Business Data response accepts object-wrapped business arrays", () => {
const candidates = normalizeLocalBusinessSearchResponse(
{
status: "OK",
request_id: "req-456",
data: {
businesses: [
{
business_id: "biz-2",
name: "Malerbetrieb Beispiel",
address: "Hauptstrasse 2, 79098 Freiburg",
site: "https://maler.example",
phone: "+49 761 123",
email: "kontakt@maler.example",
},
],
},
},
1717480000001,
);
assert.equal(candidates.length, 1);
assert.equal(candidates[0]?.placeId, "biz-2");
assert.equal(candidates[0]?.email, "kontakt@maler.example");
});
test("Local Business Data response throws provider error messages", () => {
assert.throws(
() =>
normalizeLocalBusinessSearchResponse(
{
status: "ERROR",
request_id: "req-error",
error: { message: "Missing query", code: 400 },
},
1717480000002,
),
/Local Business Data API error 400: Missing query/,
);
});

View File

@@ -61,24 +61,49 @@ test("persistDiscoveredLeads does not schedule website enrichment jobs directly"
);
});
test("processCampaignRun schedules website enrichment after lead persistence", () => {
test("persistDiscoveredLeads backfills missing emails on duplicate re-runs only", () => {
const source = extractExportSource("persistDiscoveredLeads");
assert.match(source, /getUsableContactEmail\(candidate\)/);
assert.match(source, /duplicateLeadForEmailBackfill/);
assert.match(source, /ctx\.db\.patch\(\s*duplicateLeadForEmailBackfill\._id/);
assert.match(source, /normalizedEmail:\s*usableEmail\.email/);
assert.match(source, /email:\s*usableEmail\.email/);
assert.match(source, /contactStatus\s*=\s*"new"/);
assert.match(source, /contactStatus\s*!==\s*"do_not_contact"/);
assert.match(source, /blacklistStatus\s*!==\s*"blocked"/);
assert.match(source, /duplicateByEmailRows\.length\s*===\s*0/);
assert.doesNotMatch(source, /internal\.websiteEnrichment\.queueLeadEnrichment/);
assert.doesNotMatch(source, /internal\.pageSpeed\.queueLeadPageSpeedAudit/);
});
test("processCampaignRun uses Local Business Data and does not schedule website enrichment", () => {
const source = extractExportSource("processCampaignRun");
const persistIndex = source.indexOf(
"internal.leadDiscovery.persistDiscoveredLeads",
);
const queueCall = source.indexOf("internal.websiteEnrichment.queueLeadEnrichment");
const eventMessageIndex = source.indexOf("Website-Kontaktanreicherung geplant.");
assert.notEqual(persistIndex, -1, "processCampaignRun should persist discovered leads");
assert.notEqual(queueCall, -1, "processCampaignRun should schedule website enrichment");
assert.notEqual(eventMessageIndex, -1, "processCampaignRun should append enrichment schedule events");
assert.ok(
persistIndex < queueCall,
"processCampaignRun should schedule enrichment after persistence succeeds",
assert.equal(
queueCall,
-1,
"Campaign discovery must not schedule website enrichment in the SaaS flow",
);
assert.ok(
queueCall < eventMessageIndex,
"processCampaignRun should append enrichment event after scheduling",
assert.equal(
source.includes("GOOGLE_GEOCODING_API_KEY") || source.includes("GOOGLE_PLACES_API_KEY"),
false,
"Campaign discovery should no longer require Google lead-discovery keys",
);
assert.match(
source,
/LOCAL_BUSINESS_DATA_API_KEY/,
"Campaign discovery should read the Local Business Data API key",
);
assert.match(
source,
/local_business_data/,
"Campaign discovery should record Local Business Data as the source",
);
});

View File

@@ -80,25 +80,34 @@ test("LeadsReviewTable uses compact card summaries with modal review details", a
"Location should use overflow-safe text classes in compact card.",
);
const emailSpanMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.email \|\| "Keine E-Mail"\}\s*<\/span>/,
const emailAnchorMatch = source.match(
/<a className="([^"]+)" href=\{emailHref\}>\s*\{lead\.email\}\s*<\/a>/,
);
assert.ok(
emailSpanMatch !== null &&
emailAnchorMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(
emailSpanMatch[1],
emailAnchorMatch[1],
),
"Lead email should use overflow-safe text classes in compact card.",
"Lead email should use an overflow-safe mailto link in compact card.",
);
assert.match(source, /<span className="[^"]*">\s*Keine E-Mail\s*<\/span>/);
const phoneSpanMatch = source.match(
/<span className="([^"]+)">\s*\{lead\.phone\}\s*<\/span>/,
const phoneAnchorMatch = source.match(
/<a className="([^"]+)" href=\{phoneHref\}>\s*\{lead\.phone\}\s*<\/a>/,
);
assert.ok(
phoneSpanMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneSpanMatch[1]),
"Lead phone should use overflow-safe text classes in compact card.",
phoneAnchorMatch !== null &&
/(?:^|\s)(break-all|max-w-full|min-w-0)(?:\s|$)/.test(phoneAnchorMatch[1]),
"Lead phone should use an overflow-safe tel link in compact card.",
);
assert.match(source, /toEmailHref/);
assert.match(source, /mailto:\$\{normalizedEmail\}/);
assert.match(source, /toPhoneHref/);
assert.match(source, /tel:\$\{dialablePhone\}/);
assert.match(source, /toWebsiteHref/);
assert.match(source, /href=\{websiteHref\}/);
assert.match(source, /target="_blank"/);
assert.match(source, /rel="noreferrer"/);
assert.match(source, /Kontaktstatus/);
assert.match(source, /Review-E-Mail/);
@@ -122,3 +131,15 @@ test("LeadsReviewTable exposes count filters and live status feedback", async ()
assert.match(source, /role="status"/);
assert.match(source, /role="alert"/);
});
test("LeadsReviewTable exposes explicit manual audit start action", async () => {
const source = await readFile(leadsReviewPath, "utf8");
assert.match(source, /api\.pageSpeed\.requestLeadAudit/);
assert.match(source, /api\.pageSpeed\.getLeadAuditStartStates/);
assert.match(source, /Audit starten/);
assert.match(source, /auditStartDisabledReason/);
assert.match(source, /Website fehlt|Keine Website/);
assert.match(source, /Audit l(?:ä|ae)uft|Audit läuft/);
assert.doesNotMatch(source, /api\.websiteEnrichment\.queueLeadEnrichment/);
});

View File

@@ -10,7 +10,7 @@ test("integration readiness covers all MVP providers", () => {
assert.deepEqual(
integrationReadinessDefinitions.map((definition) => definition.id),
[
"google",
"local_business_data",
"pagespeed",
"openrouter",
"screenshotone",
@@ -24,24 +24,21 @@ test("integration readiness covers all MVP providers", () => {
test("integration readiness reports missing configuration without leaking values", () => {
const rows = getIntegrationReadiness({
GOOGLE_GEOCODING_API_KEY: "secret-google",
GOOGLE_PLACES_API_KEY: "secret-places",
LOCAL_BUSINESS_DATA_API_KEY: "secret-local-business",
PAGESPEED_API_KEY: "",
});
const google = rows.find((row) => row.id === "google");
const localBusinessData = rows.find((row) => row.id === "local_business_data");
const pageSpeed = rows.find((row) => row.id === "pagespeed");
assert.equal(google?.status, "configured");
assert.equal(localBusinessData?.status, "configured");
assert.equal(pageSpeed?.status, "missing");
assert.equal(JSON.stringify(rows).includes("secret-google"), false);
assert.equal(JSON.stringify(rows).includes("secret-places"), false);
assert.equal(JSON.stringify(rows).includes("secret-local-business"), false);
});
test("integration readiness treats ScreenshotOne as required and Jina as optional", () => {
const rows = getIntegrationReadiness({
GOOGLE_GEOCODING_API_KEY: "secret-google",
GOOGLE_PLACES_API_KEY: "secret-places",
LOCAL_BUSINESS_DATA_API_KEY: "secret-local-business",
PAGESPEED_API_KEY: "secret-pagespeed",
PAGESPEED_TIMEOUT_MS: "60000",
OPENROUTER_API_KEY: "secret-openrouter",

View File

@@ -16,7 +16,7 @@ test("settings page surfaces integration status instead of a placeholder", () =>
assert.match(pageSource, /OperationsReadiness/);
for (const label of [
"Google",
"Local Business Data",
"PageSpeed",
"OpenRouter",
"ScreenshotOne",

View File

@@ -112,13 +112,14 @@ test("OutreachReviewWorkspace separates audit publication from email approval",
assert.match(source, /Audit veröffentlichen/);
assert.match(source, /Änderungen speichern/);
assert.match(source, /E-Mail freigeben und senden/);
assert.match(source, /E-Mail freigeben/);
assert.match(source, /Final senden/);
assert.match(source, /useAction/);
assert.match(source, /outreachSendAction[\s\S]*sendApprovedEmail/);
const auditPublishIndex = source.indexOf("Audit veröffentlichen");
const auditSaveIndex = source.indexOf("Änderungen speichern");
const emailApprovalIndex = source.indexOf("E-Mail freigeben und senden");
const emailApprovalIndex = source.indexOf("E-Mail freigeben");
assert.ok(auditPublishIndex >= 0);
assert.ok(auditSaveIndex >= 0);

View File

@@ -84,6 +84,8 @@ test("pageSpeed module exports mutation contracts", () => {
assert.equal(existsSync(pageSpeedPath), true, "pageSpeed.ts should be present");
const exports = getExportedConstNames(sourceFile);
const required = [
"getLeadAuditStartStates",
"requestLeadAudit",
"queueLeadPageSpeedAudit",
"startPageSpeedAuditRun",
"persistPageSpeedResult",
@@ -110,12 +112,40 @@ test("pageSpeed module uses internalMutation for queue/start/persist/finish", ()
}
});
test("requestLeadAudit is a public authenticated mutation that queues PageSpeed only after user intent", () => {
const source = extractExportSource("requestLeadAudit");
assert.equal(
hasPattern(pageSpeedSource, /export const requestLeadAudit = mutation\s*\(/),
true,
"requestLeadAudit should be a public mutation for UI-triggered audit starts.",
);
assert.match(source, /requireOperator\(ctx\)/);
assert.match(source, /queueLeadPageSpeedAuditForLead/);
assert.match(source, /triggeredBy:\s*"manual"/);
assert.match(source, /Audit-Start wurde manuell angefordert\./);
});
test("getLeadAuditStartStates exposes active audit run status for lead review buttons", () => {
const source = extractExportSource("getLeadAuditStartStates");
assert.equal(
hasPattern(pageSpeedSource, /export const getLeadAuditStartStates = query\s*\(/),
true,
"getLeadAuditStartStates should be a public query.",
);
assert.match(source, /requireOperator\(ctx\)/);
assert.match(source, /leadIds:\s*v\.array\(v\.id\("leads"\)\)/);
assert.match(pageSpeedSource, /by_type_and_status_and_leadId/);
assert.match(source, /canStart/);
});
test("queueLeadPageSpeedAudit dedupes per lead and schedules pagespeed action", () => {
const queueSource = extractExportSource("queueLeadPageSpeedAudit");
const queueSource = pageSpeedSource;
assert.equal(
hasPattern(
queueSource,
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"pending"\)[\s\S]*?eq\("leadId",\s*(?:args\.)?leadId\)/,
),
true,
"Queue should dedupe pending audit runs by type+status+leadId.",
@@ -123,7 +153,7 @@ test("queueLeadPageSpeedAudit dedupes per lead and schedules pagespeed action",
assert.equal(
hasPattern(
queueSource,
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*args\.leadId\)/,
/withIndex\(\s*"by_type_and_status_and_leadId"[\s\S]*?eq\("type",\s*"audit"\)[\s\S]*?eq\("status",\s*"running"\)[\s\S]*?eq\("leadId",\s*(?:args\.)?leadId\)/,
),
true,
"Queue should dedupe running audit runs by type+status+leadId.",

View File

@@ -151,7 +151,7 @@ const usageReadQueries = [
test("usage domain constants declare supported providers and operations", () => {
assertHas(
/USAGE_EVENT_PROVIDERS\s*=\s*\[[\s\S]*"openrouter"[\s\S]*"screenshotone"[\s\S]*"jina"[\s\S]*"pagespeed"[\s\S]*"google_places"[\s\S]*\]\s*as const/,
/USAGE_EVENT_PROVIDERS\s*=\s*\[[\s\S]*"openrouter"[\s\S]*"screenshotone"[\s\S]*"jina"[\s\S]*"pagespeed"[\s\S]*"google_places"[\s\S]*"local_business_data"[\s\S]*\]\s*as const/,
domainSource,
"Domain should declare usage providers for all managed external services.",
);

View File

@@ -336,8 +336,8 @@ test("browserless website enrichment persists crawl evidence without screenshots
);
assert.equal(
hasPattern(fallbackSource, /internal\.pageSpeed\.queueLeadPageSpeedAudit/),
true,
"Browserless enrichment should keep the downstream PageSpeed handoff.",
false,
"Browserless enrichment must not automatically queue PageSpeed or audit runs.",
);
});
@@ -760,7 +760,7 @@ test("website enrichment guards long browser work before Convex action runtime a
);
});
test("processLeadEnrichment schedules PageSpeed audit jobs after successful enrichment", () => {
test("processLeadEnrichment does not schedule PageSpeed audit jobs after enrichment", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const persistIndex = processBody.indexOf(
"internal.websiteEnrichment.persistLeadEnrichmentResult",
@@ -769,49 +769,16 @@ test("processLeadEnrichment schedules PageSpeed audit jobs after successful enri
"internal.pageSpeed.queueLeadPageSpeedAudit",
persistIndex,
);
const finishIndex = processBody.indexOf(
"internal.websiteEnrichment.finishLeadEnrichmentRun",
persistIndex,
);
assert.notEqual(queueIndex, -1, "processLeadEnrichment should queue PageSpeed audits");
assert.notEqual(persistIndex, -1, "processLeadEnrichment should persist website enrichment result");
assert.notEqual(finishIndex, -1, "processLeadEnrichment should finish enrichment run");
assert.equal(
hasPattern(
processBody,
/runMutation\(\s*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*leadId:\s*started\.lead\._id[\s\S]*parentRunId:\s*runId[\s\S]*\)/,
),
true,
"Queue call should pass lead ID and parent run ID",
);
assert.equal(queueIndex > persistIndex, true, "PageSpeed queueing should happen after persistence");
assert.equal(queueIndex < finishIndex, true, "PageSpeed queueing should happen before success finish");
});
test("processLeadEnrichment records warning on PageSpeed queue failure and continues", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
assert.equal(
hasPattern(
processBody,
/try\s*\{[\s\S]*internal\.pageSpeed\.queueLeadPageSpeedAudit[\s\S]*\}\s*catch\s*\([^)]*\)\s*\{[\s\S]*internal\.runs\.appendEventInternal[\s\S]*level:\s*"warning"/,
),
true,
"Queueing PageSpeed should be wrapped in warning-safe try/catch",
);
assert.equal(
hasPattern(
processBody,
/PageSpeed-Analyse konnte nicht in die Warteschlange gesetzt werden\./,
),
true,
"Warning event should describe queue failure",
queueIndex,
-1,
"Website enrichment must not automatically queue PageSpeed or audit runs",
);
});
test("processLeadEnrichment regression: queue PageSpeed on invalid URL failure when started lead exists", () => {
test("processLeadEnrichment regression: invalid URL failure does not queue PageSpeed", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const invalidUrlStart = processBody.indexOf("if (!rootUrl)");
assert.notEqual(invalidUrlStart, -1, "Invalid URL guard should exist");
@@ -823,27 +790,16 @@ test("processLeadEnrichment regression: queue PageSpeed on invalid URL failure w
"Invalid URL branch should return null",
);
const queueCallInInvalidUrl = processBody.indexOf(
"internal.pageSpeed.queueLeadPageSpeedAudit",
invalidUrlStart,
);
assert.equal(
queueCallInInvalidUrl > invalidUrlStart && queueCallInInvalidUrl < invalidUrlReturnNull,
true,
"Invalid URL failure path should queue PageSpeed before returning.",
);
const invalidUrlBranch = processBody.slice(invalidUrlStart, invalidUrlReturnNull);
assert.equal(
hasPattern(
invalidUrlBranch,
/leadId:\s*started\.lead\._id[\s\S]*?parentRunId:\s*runId/,
processBody.slice(invalidUrlStart, invalidUrlReturnNull).includes(
"internal.pageSpeed.queueLeadPageSpeedAudit",
),
true,
"Invalid URL queue payload should use started.lead._id and parentRunId runId.",
false,
"Invalid URL branch should not queue PageSpeed automatically.",
);
});
test("processLeadEnrichment regression: queue PageSpeed in fatal catch path with started lead", () => {
test("processLeadEnrichment regression: fatal catch path does not queue PageSpeed", () => {
const processBody = extractExportSource(actionSource, "processLeadEnrichment");
const outerCatchStart = processBody.lastIndexOf("catch (error)");
assert.notEqual(outerCatchStart, -1, "Outer catch block should exist");
@@ -858,24 +814,11 @@ test("processLeadEnrichment regression: queue PageSpeed in fatal catch path with
"Outer catch should return null on unrecoverable errors.",
);
const queueCallInCatch = processBody.indexOf(
"internal.pageSpeed.queueLeadPageSpeedAudit",
outerCatchStart,
);
assert.equal(
queueCallInCatch > outerCatchStart &&
queueCallInCatch > startedGuard &&
queueCallInCatch < catchReturnNull,
true,
"Fatal catch path should queue PageSpeed before returning, while started lead exists.",
);
const catchBlock = processBody.slice(outerCatchStart, catchReturnNull);
assert.equal(
hasPattern(
catchBlock,
/leadId:\s*started\.lead\._id[\s\S]*?parentRunId:\s*runId/,
processBody.slice(outerCatchStart, catchReturnNull).includes(
"internal.pageSpeed.queueLeadPageSpeedAudit",
),
true,
"Catch-path PageSpeed queue payload should use started.lead._id and parentRunId runId.",
false,
"Fatal catch path should not queue PageSpeed automatically.",
);
});