Compare commits

..

30 Commits

Author SHA1 Message Date
f00c5a3193 Record UX card refactor task verification 2026-06-08 22:22:39 +02:00
1695110e0a Improve audit pipeline and outreach review 2026-06-08 22:16:32 +02:00
ff18fc202e Fix MVP audit evidence pipeline 2026-06-08 08:33:15 +02:00
a45b92ea0a Externalize audit pipeline services 2026-06-07 23:06:31 +02:00
470fb0f348 Fix audit generation and enrichment fallback 2026-06-07 23:03:57 +02:00
e9463e8ef2 Surface audit generations on dashboard audits 2026-06-06 18:14:27 +02:00
3efbc06e40 Complete Rybbit campaign aggregation 2026-06-05 21:51:39 +02:00
f069b74b08 Finalize metrics verification and backlog updates 2026-06-05 21:49:57 +02:00
d3928d61c4 Add MVP operational readiness checks 2026-06-05 21:46:16 +02:00
df8ca1f049 Add audit analytics and campaign metrics 2026-06-05 21:43:43 +02:00
70951789d2 Add campaign scheduling lifecycle jobs 2026-06-05 21:38:34 +02:00
3f148bcec2 Add follow-up status tracking slice 2026-06-05 21:35:55 +02:00
Matthias
807532a0a4 Merge branch 'codex-task-14-stalwart-smtp' 2026-06-05 21:12:55 +02:00
Matthias
2ac74dfde2 chore: mark task 14 done 2026-06-05 21:12:32 +02:00
Matthias
b2f7348ef0 Add SMTP send flow for approved outreach 2026-06-05 21:05:59 +02:00
Matthias
42a3ea64a5 Merge branch 'codex-task-13-review-workspace' 2026-06-05 17:04:49 +02:00
Matthias
5352893a47 fix: cache Convex JWT in server auth 2026-06-05 17:04:03 +02:00
Matthias
5a42c637c6 feat: build audit outreach review workspace 2026-06-05 16:47:22 +02:00
Matthias
1feccb9bdf Merge public audit pages 2026-06-05 14:14:17 +02:00
Matthias
47ee2c2d51 Implement public audit pages 2026-06-05 14:14:07 +02:00
03cb65fde4 feat: add OpenRouter audit generation pipeline 2026-06-05 11:06:01 +02:00
370aeec2a0 feat: build local skills registry 2026-06-05 09:30:00 +02:00
f0a948aec9 Integrate PageSpeed Insights audits 2026-06-04 22:12:59 +02:00
99d61ac736 merge: website enrichment crawler 2026-06-04 20:29:48 +02:00
1f6e31c01c feat: add website enrichment crawler 2026-06-04 20:29:23 +02:00
ca42c8d5a6 feat: convert campaign and lead views to cards 2026-06-04 17:11:39 +02:00
59824b7336 feat: add lead qualification workflow 2026-06-04 16:09:47 +02:00
15d8bfeb66 feat: integrate google lead discovery 2026-06-04 15:25:01 +02:00
585c4eeb2a feat: add campaign configuration controls 2026-06-04 14:45:47 +02:00
07841aea0f feat: build dashboard lead funnel 2026-06-04 12:35:34 +02:00
231 changed files with 40258 additions and 414 deletions

View File

@@ -1,6 +1,18 @@
# App / Coolify # App / Coolify
APP_ENV=development APP_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=https://audit.matthias-meister-webdesign.de
# Personal deployment scope
# This repo currently targets audit.matthias-meister-webdesign.de with managed
# server-side provider keys. SaaS BYO keys, billing, and team roles come later.
# Legacy TASK-8 Playwright enrichment (not required for the new external pipeline)
TASK8_CRAWL_TIMEOUT_MS=60000
TASK8_CRAWL_MAX_PAGES=20
TASK8_BROWSER_ASSET_URL=
# Legacy aliases (optional fallback, prefer TASK8_BROWSER_ASSET_URL):
# TASK8_CHROMIUM_EXECUTABLE_URL=
# TASK8_CHROMIUM_EXECUTABLE=
# Convex # Convex
NEXT_PUBLIC_CONVEX_URL= NEXT_PUBLIC_CONVEX_URL=
@@ -12,9 +24,22 @@ BETTER_AUTH_SECRET=
GOOGLE_GEOCODING_API_KEY= GOOGLE_GEOCODING_API_KEY=
GOOGLE_PLACES_API_KEY= GOOGLE_PLACES_API_KEY=
PAGESPEED_API_KEY= PAGESPEED_API_KEY=
PAGESPEED_TIMEOUT_MS=60000
# OpenRouter # OpenRouter
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL_CLASSIFICATION=
OPENROUTER_MODEL_MULTIMODAL_AUDIT=
OPENROUTER_MODEL_GERMAN_COPY=
OPENROUTER_MODEL_QUALITY_REVIEW=
OPENROUTER_APP_NAME=
OPENROUTER_APP_URL=
# ScreenshotOne
SCREENSHOTONE_API_KEY=
# Jina (optional fallback; no key required for current readiness)
JINA_API_KEY=
# SMTP / Stalwart # SMTP / Stalwart
SMTP_HOST= SMTP_HOST=

View File

@@ -1,6 +1,8 @@
# WebDev Pipeline # WebDev Pipeline
Interner Akquise-Agent fuer lokale Webdesign-Leads. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten. Persoenlicher Akquise-Agent fuer lokale Webdesign-Leads auf `audit.matthias-meister-webdesign.de`. Das MVP startet mit Next.js App Router, TypeScript, Tailwind CSS, shadcn/ui und Platzhalter-Routen fuer Dashboard, Login und oeffentliche Audit-Seiten.
Der aktuelle Scope ist bewusst persoenlich: Google, PageSpeed, OpenRouter, ScreenshotOne und optional Jina laufen ueber serverseitig verwaltete Keys. BYO-Keys, Billing und Teamrollen gehoeren zur spaeteren SaaS-Readiness, aber nicht zu dieser Welle.
## Getting Started ## Getting Started
@@ -23,8 +25,10 @@ Copy `.env.example` to `.env.local` for local development. Keep real secrets out
- **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL` - **App / Coolify:** `APP_ENV`, `NEXT_PUBLIC_APP_URL`
- **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT` - **Convex:** `NEXT_PUBLIC_CONVEX_URL`, `NEXT_PUBLIC_CONVEX_SITE_URL`, `CONVEX_DEPLOYMENT`
- **Google:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY` - **Google / PageSpeed:** `GOOGLE_GEOCODING_API_KEY`, `GOOGLE_PLACES_API_KEY`, `PAGESPEED_API_KEY`, `PAGESPEED_TIMEOUT_MS`
- **OpenRouter:** `OPENROUTER_API_KEY` - **OpenRouter:** `OPENROUTER_API_KEY`, `OPENROUTER_MODEL_CLASSIFICATION`, `OPENROUTER_MODEL_MULTIMODAL_AUDIT`, `OPENROUTER_MODEL_GERMAN_COPY`, `OPENROUTER_MODEL_QUALITY_REVIEW`, optional: `OPENROUTER_APP_NAME`, `OPENROUTER_APP_URL`
- **ScreenshotOne:** `SCREENSHOTONE_API_KEY`
- **Jina:** optional `JINA_API_KEY` for future authenticated fallback usage; not required for current readiness.
- **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` - **SMTP / Stalwart:** `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
- **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID` - **Rybbit:** `RYBBIT_API_URL`, `RYBBIT_API_KEY`, `NEXT_PUBLIC_RYBBIT_SITE_ID`
- **Auth:** `BETTER_AUTH_SECRET` - **Auth:** `BETTER_AUTH_SECRET`
@@ -48,3 +52,12 @@ Only variables prefixed with `NEXT_PUBLIC_` are intended for browser exposure. A
## Deployment Notes ## Deployment Notes
Coolify should run `pnpm install`, `pnpm build`, and `pnpm start`. The current font setup uses `next/font/google`, so production builds need outbound access to Google Fonts unless fonts are later self-hosted. Coolify should run `pnpm install`, `pnpm build`, and `pnpm start`. The current font setup uses `next/font/google`, so production builds need outbound access to Google Fonts unless fonts are later self-hosted.
The new audit pipeline expects managed server-side provider configuration for Google, PageSpeed, OpenRouter, ScreenshotOne, and optional Jina. Do not expose provider secrets in browser-prefixed variables.
Playwright/TASK-8 is legacy enrichment context, not a required integration for the new external audit pipeline. Local `npx playwright install` remains a browser-testing helper only and does not affect the managed external-service readiness check.
For Convex deployment updates, run restart/deploy after code changes:
- Local: `pnpm exec convex dev`
- Remote: `pnpm exec convex deploy`

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { revalidatePublicAudit } from "@/lib/audits/public-audit-revalidation";
import { parsePublicAuditSlug } from "@/lib/audits/slugs";
export async function POST(request: Request) {
const secret = process.env.PUBLIC_AUDIT_REVALIDATION_SECRET;
const authorization = request.headers.get("authorization");
if (!secret || authorization !== `Bearer ${secret}`) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const body = (await request.json().catch(() => null)) as { slug?: unknown } | null;
const normalizedSlug =
typeof body?.slug === "string" ? parsePublicAuditSlug(body.slug) : null;
if (!normalizedSlug) {
return NextResponse.json({ ok: false, error: "Invalid slug" }, { status: 400 });
}
revalidatePublicAudit(normalizedSlug);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,29 @@
import { fetchRybbitAuditAnalytics } from "@/lib/rybbit-analytics";
export async function GET(request: Request) {
const url = new URL(request.url);
const auditPath = url.searchParams.get("path") ?? "";
if (!auditPath.startsWith("/audit/")) {
return Response.json({
ok: false,
error: "Audit-Pfad fehlt.",
data: null,
}, { status: 400 });
}
const result = await fetchRybbitAuditAnalytics({
apiUrl: process.env.RYBBIT_API_URL,
apiKey: process.env.RYBBIT_API_KEY,
siteId: process.env.NEXT_PUBLIC_RYBBIT_SITE_ID,
auditPath,
startDate: url.searchParams.get("startDate") ?? undefined,
endDate: url.searchParams.get("endDate") ?? undefined,
});
if (!result.ok) {
return Response.json({ ok: false, error: result.error, data: result.data });
}
return Response.json({ ok: true, data: result.data });
}

View File

@@ -0,0 +1,18 @@
import { fetchRybbitCampaignAnalytics } from "@/lib/rybbit-analytics";
export async function GET(request: Request) {
const url = new URL(request.url);
const result = await fetchRybbitCampaignAnalytics({
apiUrl: process.env.RYBBIT_API_URL,
apiKey: process.env.RYBBIT_API_KEY,
siteId: process.env.NEXT_PUBLIC_RYBBIT_SITE_ID,
startDate: url.searchParams.get("startDate") ?? undefined,
endDate: url.searchParams.get("endDate") ?? undefined,
});
if (!result.ok) {
return Response.json({ ok: false, error: result.error, data: result.data });
}
return Response.json({ ok: true, data: result.data });
}

View File

@@ -1,27 +1,66 @@
import { FileText } from "lucide-react"; import type { Metadata } from "next";
import { Suspense } from "react";
import { cacheLife, cacheTag } from "next/cache";
import { fetchQuery } from "convex/nextjs";
export default async function PublicAuditPage({ import { PublicAuditPage } from "@/components/public-audit/public-audit-page";
params, import { PublicAuditStatus } from "@/components/public-audit/public-audit-status";
}: { import { api } from "@/convex/_generated/api";
import { publicAuditCacheTag } from "@/lib/audits/public-audit-cache";
import { toPublicAuditRenderState } from "@/lib/audits/public-audit-presenter";
import type { PublicAuditLookupResult } from "@/lib/audits/public-audit-types";
import { parsePublicAuditSlug } from "@/lib/audits/slugs";
export const metadata: Metadata = {
title: "Website-Audit",
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
};
type PublicAuditRouteProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}) { };
const { slug } = await params;
async function getCachedPublicAudit(slug: string): Promise<PublicAuditLookupResult> {
"use cache";
const normalizedSlug = parsePublicAuditSlug(slug);
if (!normalizedSlug) {
return null;
}
cacheTag(publicAuditCacheTag(normalizedSlug));
cacheLife("days");
return await fetchQuery(api.audits.getPublicBySlug, { slug: normalizedSlug });
}
async function PublicAuditContent({ params }: PublicAuditRouteProps) {
const { slug } = await params;
const result = await getCachedPublicAudit(slug);
const renderState = toPublicAuditRenderState(result);
if (renderState.kind === "pending") {
return <PublicAuditStatus status="pending" />;
}
if (renderState.kind === "unavailable") {
return <PublicAuditStatus status="unavailable" />;
}
return <PublicAuditPage audit={renderState.audit} />;
}
export default function PublicAuditRoute({ params }: PublicAuditRouteProps) {
return ( return (
<main className="flex min-h-dvh items-center justify-center bg-background px-6 py-12"> <Suspense fallback={<PublicAuditStatus status="pending" />}>
<section className="w-full max-w-2xl rounded-lg border bg-card p-6 text-card-foreground"> <PublicAuditContent params={params} />
<FileText className="mb-5 size-6 text-muted-foreground" /> </Suspense>
<p className="text-sm font-medium text-muted-foreground">
Audit: {slug}
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-normal">
Dieser Audit ist noch nicht freigegeben
</h1>
<p className="mt-4 text-sm leading-6 text-muted-foreground">
Sobald der Bericht manuell geprueft und veroeffentlicht wurde,
erscheinen hier die freigegebenen Beobachtungen und Empfehlungen.
</p>
</section>
</main>
); );
} }

7
app/audit/layout.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function AuditLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <div className="bg-slate-50 text-slate-950">{children}</div>;
}

View File

@@ -1,10 +1,5 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { AnalyticsDashboard } from "@/components/analytics/analytics-dashboard";
export default function AnalyticsPage() { export default function AnalyticsPage() {
return ( return <AnalyticsDashboard />;
<DashboardPlaceholderPage
description="Kampagnenmetriken und Rybbit-Daten folgen in TASK-17 und TASK-19."
title="Analytics"
/>
);
} }

View File

@@ -0,0 +1,17 @@
import { AuditDetail } from "@/components/audits/audit-detail";
export default async function AuditDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<main className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl">
<AuditDetail id={id as unknown as string} />
</div>
</main>
);
}

View File

@@ -1,10 +1,11 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { AuditsBoard } from "@/components/audits/audits-board";
export default function AuditsPage() { export default function AuditsPage() {
return ( return (
<DashboardPlaceholderPage <main className="px-4 py-5 sm:px-6 lg:px-8">
description="Audit-Review, Screenshots und oeffentliche Freigaben folgen in TASK-12 und TASK-13." <div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
title="Audits" <AuditsBoard />
/> </div>
</main>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { OutreachReviewWorkspace } from "@/components/outreach/outreach-review-workspace";
export default function OutreachPage() { export default function OutreachPage() {
return ( return (
<DashboardPlaceholderPage <main className="px-4 py-5 sm:px-6 lg:px-8">
description="E-Mail-Entwuerfe, Telefon-Skripte und manuelle Versandfreigaben folgen in TASK-13 und TASK-14." <div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
title="Outreach" <OutreachReviewWorkspace />
/> </div>
</main>
); );
} }

View File

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

View File

@@ -1,10 +1,6 @@
import { DashboardPlaceholderPage } from "@/components/dashboard-placeholder-page"; import { OperationsReadiness } from "@/components/settings/operations-readiness";
import { getIntegrationReadiness } from "@/lib/operational-readiness";
export default function SettingsPage() { export default function SettingsPage() {
return ( return <OperationsReadiness rows={getIntegrationReadiness(process.env)} />;
<DashboardPlaceholderPage
description="Provider-Status, Secrets-Hinweise und Workspace-Einstellungen folgen mit den Integrationen."
title="Settings"
/>
);
} }

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import { ConvexClientProvider } from "@/components/convex-client-provider"; import { ConvexClientProvider } from "@/components/convex-client-provider";
import { getToken } from "@/lib/auth-server"; import { getToken } from "@/lib/auth-server";
import "./globals.css"; import "./globals.css";
@@ -19,20 +20,30 @@ export const metadata: Metadata = {
description: "Interner Akquise-Agent fuer lokale Webdesign-Leads", description: "Interner Akquise-Agent fuer lokale Webdesign-Leads",
}; };
export default async function RootLayout({ async function AuthenticatedConvexProvider({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const token = await getToken(); const token = await getToken();
return <ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider>;
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html <html
lang="de" lang="de"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<ConvexClientProvider initialToken={token}>{children}</ConvexClientProvider> <Suspense fallback={null}>
<AuthenticatedConvexProvider>{children}</AuthenticatedConvexProvider>
</Suspense>
</body> </body>
</html> </html>
); );

14
app/sitemap.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: siteUrl,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
];
}

View File

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

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-10 id: TASK-10
title: Build the local skills registry title: Build the local skills registry
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:13' created_date: '2026-06-03 19:13'
updated_date: '2026-06-05 07:28'
labels: labels:
- mvp - mvp
- agent - agent
@@ -24,19 +25,34 @@ Create the local skills registry concept for the agent. Design and marketing ski
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 A project-local skills directory or convention exists for imported design and marketing skills - [x] #1 A project-local skills directory or convention exists for imported design and marketing skills
- [ ] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category - [x] #2 skills.md lists each skill with name, purpose, when to use, when not to use, required input, expected output, and category
- [ ] #3 Agent code can load and parse the skills registry into structured skill metadata - [x] #3 Agent code can load and parse the skills registry into structured skill metadata
- [ ] #4 Audit records store the list of used skills, including skill name/category and version or source where available - [x] #4 Audit records store the list of used skills, including skill name/category and version or source where available
- [ ] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not - [x] #5 Dashboard audit detail shows a compact Verwendete Skills overview, but public audit pages do not
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Define project-local skill storage conventions. 1. Worker A uses TDD to add project-local skills conventions, seed skills.md, skills source files, and a strict skills registry parser/loader.
2. Create the initial skills.md registry format and seed entries for design, UX, marketing, copy, SEO, and offer-writing skills. 2. Worker B uses TDD to extend Convex audit persistence so audit records can store used skill metadata with name, category, version, and source.
3. Add parser/loader for registry metadata. 3. Worker C uses TDD to add the internal dashboard audit detail/list UI and compact Verwendete Skills overview while keeping public audit pages free of skill metadata.
4. Store selected skill metadata with each audit. 4. Orchestrator reviews subagent outputs, resolves integration issues through focused subagents, runs full verification, and checks TASK-10 acceptance criteria without marking Done until user confirmation.
5. Show used skills in the internal audit detail UI only.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation using subagent-driven and test-driven workflow with parallel agents where write scopes are independent. Orchestrator will not hand-code feature changes; workers own implementation patches and tests.
Worker C: implemented audits dashboard internals for TASK-10. Added new tests (tests/audit-skills-ui.test.ts), new components/audits/{audits-board,audit-detail}.tsx and routes app/dashboard/audits/page.tsx + app/dashboard/audits/[id]/page.tsx. Internal detail route still passes raw id from params Promise; public audit page unchanged and remains skill-free.
Implementation completed through parallel subagent-driven TDD slices. Worker scopes: registry/parser, Convex audit persistence, dashboard audit UI. Review findings addressed by follow-up workers for getDetail result shape/useQuery FunctionReference and indented skills.md field parsing. Fresh orchestrator verification: pnpm test passed with 179/179 tests; pnpm lint passed with 0 errors and 2 existing generated BetterAuth warnings; pnpm exec convex codegen --dry-run --typecheck enable passed after network escalation; pnpm build passed after network escalation. Sandbox-only failures before escalation were DNS/Sentry for Convex and Google Fonts for Next build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped the local skills registry with project-local skills.md and skills/ source files, parser/loader tests, Convex audit usedSkills persistence, and internal dashboard audit skill overview. Verified with pnpm test; task remains public-audit safe because used skills are only shown in the dashboard detail route.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-11 id: TASK-11
title: Create the OpenRouter AI audit pipeline title: Create the OpenRouter AI audit pipeline
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:13' created_date: '2026-06-03 19:13'
updated_date: '2026-06-05 09:04'
labels: labels:
- mvp - mvp
- agent - agent
@@ -26,19 +27,44 @@ Implement the LLM-powered audit generation pipeline using Vercel AI SDK and Open
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Vercel AI SDK is configured with OpenRouter and environment/Convex secrets - [x] #1 Vercel AI SDK is configured with OpenRouter and environment/Convex secrets
- [ ] #2 Model profiles exist for classification, multimodal audit analysis, German text generation, and final quality review - [x] #2 Model profiles exist for classification, multimodal audit analysis, German text generation, and final quality review
- [ ] #3 Structured audit outputs use Zod schemas and are stored in Convex with raw prompts/responses and model metadata - [x] #3 Structured audit outputs use Zod schemas and are stored in Convex with raw prompts/responses and model metadata
- [ ] #4 Screenshots can be passed to multimodal-capable models where supported - [x] #4 Screenshots can be passed to multimodal-capable models where supported
- [ ] #5 Generated customer-facing text follows Ich-Form, German language, no scores, no prices, no generic KI-Slop, and factual observation plus suggestion style - [x] #5 Generated customer-facing text follows Ich-Form, German language, no scores, no prices, no generic KI-Slop, and factual observation plus suggestion style
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Add OpenRouter provider setup through Vercel AI SDK. 1. Worker A: add OpenRouter/Vercel AI SDK dependencies, provider config, model profiles, and schema helpers with RED/GREEN tests.
2. Define Zod schemas for internal findings, audit summary, email draft, subject, call script, follow-up, and quality review. 2. Worker B: add Convex schema and persistence contracts for structured LLM generations with RED/GREEN source/type tests.
3. Build model-profile configuration for fast classification, multimodal analysis, and German copy generation. 3. Worker C: add evidence/prompt input builder combining lead, crawl, screenshots, PageSpeed, and local skills with RED/GREEN tests.
4. Combine lead, crawl, screenshot, PageSpeed, and selected skills into prompt inputs. 4. Worker D: add Node audit-generation action queue/process flow with screenshots, AI SDK structured outputs, audit/outreach persistence, and failure recording with RED/GREEN tests.
5. Persist all prompts, model responses, normalized findings, final texts, and generation errors in Convex. 5. Worker E: add German copy quality guard tests/helpers for Ich-Form, no scores, no prices, no generic KI-Slop, and observation-plus-suggestion style.
6. Orchestrator: review worker patches, resolve integration gaps through Spark follow-up workers, run full verification, and check acceptance criteria without marking Done.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-06-05: Started TASK-11 implementation on branch codex-task-11-openrouter-audit-pipeline using subagent-driven and test-driven workflow. Existing TASK-25 worktree changes were present and will not be reverted or touched unless required.
Wave 1 dispatched with gpt-5.3-codex-spark: Worker A owns AI SDK/OpenRouter dependencies, model profiles, and Zod schemas; Worker B owns Convex auditGenerations schema/persistence; Worker C owns pure audit evidence builder; Worker E owns German customer-copy guard. Orchestrator remains integration/review only and is not hand-coding feature patches.
Implemented Worker-E German copy guard slice in pure deterministic helpers (lib/ai/german-copy-guard.ts) plus TDD tests (tests/german-copy-guard.test.ts). Added issue coverage for language quality, Ich-Form, score/page-speed artifacts, Preise, KI-Slop, anklagende Sprache, technische Artefakte, Beobachtung+Vorschlag. Keinen Fremdscope verändert.
Wave 1 review complete. Spec/code-quality reviewers found expected blocker: auditGenerationAction is not implemented yet and queue currently uses a temporary any reference. Follow-up scope: Worker D will add Node action, typed scheduler reference, screenshot multimodal handoff, AI SDK calls, audit/outreach persistence, and prompt/response size/sanitization guards. Worker F will harden German short-text detection, document model override env vars, and remove generated JS artifacts.
Wave 2 dispatched with gpt-5.3-codex-spark: Worker D owns auditGenerationAction, typed scheduler reference, multimodal screenshot handoff, AI SDK structured stages, audit/outreach persistence, and prompt/response persistence hardening. Worker F owns German short-text guard hardening, OpenRouter override env docs, and removal of generated JS artifacts. Orchestrator remains review/verification only.
Final review before closure: spec reviewer passed all five TASK-11 acceptance criteria, but code-quality reviewer found P1 risks in auditGenerationAction error handling and lead status patching, plus P2 hardening around UTF-8 byte capping/secret redaction. Worker H dispatched with gpt-5.3-codex-spark to address those findings before acceptance criteria are checked.
Implementation complete pending user confirmation. Built OpenRouter/Vercel AI SDK audit-generation pipeline with model profiles, Zod structured outputs, evidence builder, multimodal screenshot handoff, Convex auditGenerations persistence with prompt/response/model metadata, German copy guard, audit/outreach upserts, guarded lead status transition, action-level failure handling, UTF-8 byte-safe truncation, env-secret redaction, and model-profile driven generation parameters. Verification passed: pnpm test (235/235); pnpm exec tsc -p tsconfig.json --pretty false; pnpm lint (0 errors, existing BetterAuth generated warnings only); pnpm exec convex codegen --dry-run --typecheck enable; pnpm build. Final Spark review found no blocking/important issues; residual P3: PageSpeed evidence freshness on re-runs may need future runtime coverage.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented the OpenRouter/Vercel AI SDK audit-generation pipeline end to end: model profiles, Zod structured outputs, Convex audit generation persistence, evidence builder, multimodal screenshots, German copy guard, audit/outreach draft persistence, guarded lead transition, and hardening for failure handling/secret redaction. Verified with pnpm test, TypeScript, lint, Convex codegen/typecheck, build, and final Spark review.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-12 id: TASK-12
title: Publish customer audit pages with manual approval title: Publish customer audit pages with manual approval
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 12:13'
labels: labels:
- mvp - mvp
- audit - audit
@@ -24,11 +25,11 @@ Build the public customer-facing audit page system under the audit domain. Pages
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Public audit pages render approved audit content with company name, domain, screenshots, observations, impact, suggestions, and final offer/CTA - [x] #1 Public audit pages render approved audit content with company name, domain, screenshots, observations, impact, suggestions, and final offer/CTA
- [ ] #2 Unapproved audit URLs show Dieser Audit ist noch nicht freigegeben without leaking company details - [x] #2 Unapproved audit URLs show Dieser Audit ist noch nicht freigegeben without leaking company details
- [ ] #3 Deactivated audit URLs show a neutral unavailable message without exposing audit content - [x] #3 Deactivated audit URLs show a neutral unavailable message without exposing audit content
- [ ] #4 Audit pages are noindex, excluded from sitemap/public listing, and use a calm fixed light design - [x] #4 Audit pages are noindex, excluded from sitemap/public listing, and use a calm fixed light design
- [ ] #5 Approved pages are cached and cache is invalidated when the audit is edited and re-approved - [x] #5 Approved pages are cached and cache is invalidated when the audit is edited and re-approved
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -40,3 +41,17 @@ Build the public customer-facing audit page system under the audit domain. Pages
4. Add noindex metadata and ensure audit routes are not listed in sitemap/navigation. 4. Add noindex metadata and ensure audit routes are not listed in sitemap/navigation.
5. Add cache/revalidation behavior tied to approval and update actions. 5. Add cache/revalidation behavior tied to approval and update actions.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Reapplying TASK-12 changes after failed pull lost previous implementation. Upstream TASK-1 through TASK-11 code is now present locally; implementation will adapt to current Convex/generated API and existing app structure.
Reapplied TASK-12 public audit implementation after pull-loss recovery. Verified with pnpm test (244/244), pnpm exec tsc --noEmit, pnpm lint (0 errors, 2 existing generated warnings), and pnpm build using the updated .env.local.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Public audit pages were reapplied and verified: approved public pages render public audit content with screenshots, observations, suggestions and CTA; hidden/deactivated states do not leak details; pages are noindex and excluded from sitemap; cache/revalidation hooks are in place. Verified with pnpm test, tsc, lint, and build.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-13 id: TASK-13
title: Build the audit and outreach review workspace title: Build the audit and outreach review workspace
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 14:21'
labels: labels:
- mvp - mvp
- review - review
@@ -25,19 +26,33 @@ Create the internal review workspace where Matthias can inspect and edit the fin
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Review workspace shows lead details, contact sources, priority reason, contact strategy, audit summary, used skills, and raw/source detail toggles - [x] #1 Review workspace shows lead details, contact sources, priority reason, contact strategy, audit summary, used skills, and raw/source detail toggles
- [ ] #2 Audit content can be edited and manually approved before the public page shows customer-facing content - [x] #2 Audit content can be edited and manually approved before the public page shows customer-facing content
- [ ] #3 Email subject and body are editable and generated as exactly one recommended version by default - [x] #3 Email subject and body are editable and generated as exactly one recommended version by default
- [ ] #4 Phone script is available for Erst anrufen and Kontakt fehlt leads when a phone number exists - [x] #4 Phone script is available for Erst anrufen and Kontakt fehlt leads when a phone number exists
- [ ] #5 Freigabe offen state clearly separates Audit veröffentlichen from E-Mail freigeben und senden - [x] #5 Freigabe offen state clearly separates Audit veröffentlichen from E-Mail freigeben und senden
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Build review route/detail UI with tabs for Audit, E-Mail, Telefon, Quellen, Rohdaten, and Skills. 1. Orchestrator updates TASK-13 plan and coordinates only; no direct feature coding.
2. Add edit forms for audit text, email subject/body, phone script, and follow-up. 2. Worker A (gpt-5.5 medium) uses TDD to add Convex outreach review contracts: listReviewWorkspace, saveReviewDraft, approveEmailDraft.
3. Add approval actions for audit publication and separate email sending readiness. 3. Worker B (gpt-5.5 medium) uses TDD to replace /dashboard/outreach placeholder with the review workspace UI using the new contracts.
4. Show source/contact confidence without exposing unnecessary raw noise by default. 4. Worker C (gpt-5.5 medium) uses TDD to separate Audit veröffentlichen from E-Mail freigeben and keep sending out of TASK-13.
5. Verify state transitions back into the Kanban/Funnel. 5. Worker D (gpt-5.5 medium) uses TDD to cover phone-script visibility and funnel/review state regressions.
6. Spec and code-quality reviewer agents review each worker output before the next dependent slice proceeds.
7. Orchestrator runs final verification: pnpm test, pnpm exec tsc --noEmit, pnpm lint, pnpm build; then updates Backlog notes and checked ACs without marking Done.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Starting TASK-13 with the missing PageSpeed-to-audit-generation handoff so generated audit content exists for the review workspace.
Implemented first TASK-13 prerequisite: PageSpeed completion now queues audit_generation for the same lead via internal.auditGeneration.queueLeadAuditGeneration. Queue failures are logged as warnings and do not fail the PageSpeed run. Verified with pnpm test (245/245), pnpm exec tsc --noEmit, pnpm lint (0 errors, existing generated warnings), and pnpm build using .env.local.
2026-06-05: Expanded TASK-13 into subagent-driven, test-driven execution plan on branch codex-task-13-review-workspace. Orchestrator will not hand-code feature patches; workers use gpt-5.5 medium and RED/GREEN tests.
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 -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-14 id: TASK-14
title: Send approved outreach through Stalwart SMTP title: Send approved outreach through Stalwart SMTP
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:06'
labels: labels:
- mvp - mvp
- email - email
@@ -24,19 +25,30 @@ Implement approved email sending through the self-hosted Stalwart mail server us
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Nodemailer is configured for Stalwart SMTP/SMTPS using environment or Convex secrets - [x] #1 Nodemailer is configured for Stalwart SMTP/SMTPS using environment or Convex secrets
- [ ] #2 E-Mail freigeben und senden sends only the currently approved/editable email draft to the visible recipient - [x] #2 E-Mail freigeben und senden sends only the currently approved/editable email draft to the visible recipient
- [ ] #3 A final send action shows recipient, subject, sender, and audit link before sending - [x] #3 A final send action shows recipient, subject, sender, and audit link before sending
- [ ] #4 Convex records sent timestamp, recipient, subject, audit link, SMTP result, and any error details - [x] #4 Convex records sent timestamp, recipient, subject, audit link, SMTP result, and any error details
- [ ] #5 SMTP failures keep the lead in a retryable review state and do not mark the lead as contacted - [x] #5 SMTP failures keep the lead in a retryable review state and do not mark the lead as contacted
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Add SMTP transport configuration from secrets. 1. Analyse und TDD-Testergänzung
2. Add server-side send function that accepts only approved outreach IDs. 2. Implementierung backend Claims/Record + Guard-Fixes
3. Add final confirmation UI with recipient, subject, sender, and audit link. 3. Typing/Actions straffen + package lock
4. Store SMTP success/error outcomes and update lead/outreach status. 4. Typechecks lokal ausführen
5. Test success and failure paths with safe non-production recipients before real use.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented TASK-14 via subagent-driven TDD. Verification passed: targeted outreach tests (27/27), pnpm test (278/278), pnpm exec tsc -p tsconfig.json --noEmit, pnpm lint (0 errors, 2 generated BetterAuth warnings), pnpm build (passed with network-enabled run for Google Fonts). Task remains In Progress until explicit user confirmation after manual SMTP testing.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped approved outreach sending through Stalwart SMTP/SMTPS with Nodemailer, final confirmation UI, Convex send-attempt logging, retryable failure handling, and verification coverage. Verified with targeted outreach tests, full pnpm test, strict TypeScript, lint, and production build.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-15 id: TASK-15
title: Add follow-up and manual sales status tracking title: Add follow-up and manual sales status tracking
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- sales - sales
@@ -24,11 +25,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 After an initial send, a single follow-up draft and suggested due date are created - [x] #1 After an initial send, a single follow-up draft and suggested due date are created
- [ ] #2 Follow-up sending requires manual review and approval, just like the first email - [x] #2 Follow-up sending requires manual review and approval, just like the first email
- [ ] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet - [x] #3 Manual statuses exist for Antwort erhalten, Kein Interesse, Später wieder melden, Gespräch vereinbart, Angebot angefragt, Angebot gesendet, Auftrag gewonnen, Auftrag verloren, Nicht weiter verfolgen, Follow-up geplant, and Follow-up gesendet
- [ ] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts - [x] #4 Marking Antwort erhalten or Kein Interesse stops pending follow-up prompts
- [ ] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen - [x] #5 Nicht erneut kontaktieren blocks outreach for 12 months and then reappears only as Erneut prüfen
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -40,3 +41,11 @@ Add the lightweight CRM layer needed after first contact. The MVP does not parse
4. Add rules to stop follow-ups when manually marked answered or not interested. 4. Add rules to stop follow-ups when manually marked answered or not interested.
5. Add 12-month recheck behavior for Nicht erneut kontaktieren. 5. Add 12-month recheck behavior for Nicht erneut kontaktieren.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for tasks 15-19 and 27. TASK-27 note says it is superseded by TASK-13, so this pass will verify the existing PageSpeed-to-audit-generation handoff rather than implement it separately.
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 -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-16 id: TASK-16
title: Orchestrate recurring Convex agent jobs and audit lifecycle title: Orchestrate recurring Convex agent jobs and audit lifecycle
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- convex - convex
@@ -26,11 +27,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Convex cron or scheduled functions trigger active campaigns according to cadence - [x] #1 Convex cron or scheduled functions trigger active campaigns according to cadence
- [ ] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active - [x] #2 Jetzt ausführen starts a campaign run immediately only when no other agent run is active
- [ ] #3 Cron skips or queues safely when an agent run is already active, with visible run logs - [x] #3 Cron skips or queues safely when an agent run is already active, with visible run logs
- [ ] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active - [x] #4 Published audits older than 30 days create dashboard notifications asking whether to keep active
- [ ] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated - [x] #5 Published audits older than 60 days auto-deactivate unless manually extended or later reactivated
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -42,3 +43,11 @@ Implement the scheduled and manual background workflow using Convex. The MVP per
4. Add run logs and dashboard-visible status updates. 4. Add run logs and dashboard-visible status updates.
5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation. 5. Add audit lifecycle checks for 30-day notification, 60-day deactivation, and reactivation.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for recurring Convex agent jobs, run locking, logs, and audit lifecycle.
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 -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-17 id: TASK-17
title: Add Rybbit audit analytics dashboard title: Add Rybbit audit analytics dashboard
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:14' created_date: '2026-06-03 19:14'
updated_date: '2026-06-05 19:50'
labels: labels:
- mvp - mvp
- analytics - analytics
@@ -24,11 +25,11 @@ Display anonymous analytics for generated public audit pages inside the internal
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes - [x] #1 Rybbit tracking runs only on public audit pages, not on internal dashboard routes
- [ ] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages - [x] #2 Dashboard can fetch Rybbit API data for pageviews, custom events, and outbound link clicks for audit pages
- [ ] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available - [x] #3 Per-audit analytics show opened yes/no, view count, last view, CTA clicks, website-link clicks, and device type where available
- [ ] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe - [x] #4 Campaign analytics aggregate audit opens and CTA activity by campaign, niche, region, and timeframe
- [ ] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard - [x] #5 Rybbit API failures are shown gracefully and do not break the rest of the dashboard
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -40,3 +41,13 @@ Display anonymous analytics for generated public audit pages inside the internal
4. Build campaign-level analytics summaries. 4. Build campaign-level analytics summaries.
5. Add graceful loading, caching if useful, and error states for API failures. 5. Add graceful loading, caching if useful, and error states for API failures.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for Rybbit public-audit tracking and dashboard analytics surfaces.
Implemented public-audit-only Rybbit tracking, on-demand Rybbit API routes for audit/campaign activity, per-audit summary helper, dashboard Rybbit error handling, and campaign-level overall Rybbit signals. AC4 remains open for full grouping by campaign/niche/region/timeframe because Rybbit events still need a stronger audit-to-campaign join model. Verification: pnpm test 305/305; pnpm lint 0 errors.
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 -->

View File

@@ -1,10 +1,10 @@
--- ---
id: TASK-18 id: TASK-18
title: Add MVP quality gates and operational polish title: Add MVP quality gates and operational polish
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:15' created_date: '2026-06-03 19:15'
updated_date: '2026-06-03 19:15' updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- quality - quality
@@ -27,11 +27,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Core UI text is German and organized so future i18n is feasible - [x] #1 Core UI text is German and organized so future i18n is feasible
- [ ] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history - [x] #2 No secrets are stored in source code, dashboard-editable records, logs, prompts, or raw LLM history
- [ ] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit - [x] #3 Dashboard surfaces integration errors for Google, PageSpeed, OpenRouter, Playwright, SMTP, Convex jobs, and Rybbit
- [ ] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics - [x] #4 Critical user flows have basic tests or repeatable verification notes: login, campaign run, audit generation, approval, send, follow-up, analytics
- [ ] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions - [x] #5 Coolify deployment notes cover required environment variables, Playwright browser dependencies, exposed port, and domain assumptions
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -43,3 +43,11 @@ Add the final MVP quality layer: German UI consistency, i18n preparation, access
4. Add smoke tests or documented verification flows for critical MVP paths. 4. Add smoke tests or documented verification flows for critical MVP paths.
5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats. 5. Document Coolify deployment requirements, env vars, Playwright dependencies, and operational caveats.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Started implementation pass for MVP quality gates, error observability, verification notes, and deployment readiness.
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 -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-19 id: TASK-19
title: Add campaign performance metrics title: Add campaign performance metrics
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:15' created_date: '2026-06-03 19:15'
updated_date: '2026-06-05 19:49'
labels: labels:
- mvp - mvp
- analytics - analytics
@@ -26,11 +27,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses - [x] #1 Campaign dashboard shows found leads, leads with contact, Kontakt fehlt, audits created, approvals open, emails sent, follow-ups planned/sent, responses, conversations, offers, wins, and losses
- [ ] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe - [x] #2 Metrics can be filtered by campaign, niche/category, PLZ/region, radius, priority, status, and timeframe
- [ ] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated - [x] #3 Campaign run detail shows new leads, skipped duplicates, blacklisted/skipped leads, errors, and audits generated
- [ ] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics - [x] #4 Rybbit-derived audit opens and CTA clicks are shown alongside Convex sales funnel metrics
- [ ] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard - [x] #5 Metrics remain readable and lightweight, without becoming a full enterprise CRM dashboard
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -42,3 +43,11 @@ Build the campaign metrics layer that summarizes acquisition progress from Conve
4. Merge Rybbit API-derived audit activity into the visible analytics where available. 4. Merge Rybbit API-derived audit activity into the visible analytics where available.
5. Add empty/error states and verify metrics update after lead, audit, send, and status changes. 5. Add empty/error states and verify metrics update after lead, audit, send, and status changes.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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 -->

View File

@@ -0,0 +1,51 @@
---
id: TASK-20
title: Convert campaigns and leads to compact cards
status: Done
assignee: []
created_date: '2026-06-04 15:01'
updated_date: '2026-06-04 15:10'
labels: []
dependencies: []
priority: high
ordinal: 22000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update the dashboard campaign and lead review UI so campaigns render as individual cards and leads render as compact expandable cards while preserving existing Convex behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Campaigns page renders each campaign as its own responsive card instead of a desktop table.
- [x] #2 Leads page renders compact cards showing company/name, contact data, and priority while hiding review fields behind Mehr anzeigen.
- [x] #3 Expanded lead cards preserve all existing review fields and save/block actions.
- [x] #4 UI remains responsive without horizontal table overflow on desktop and mobile.
- [x] #5 Lint and test verification are run and results are documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add/adjust tests or static checks that fail for table-based Campaigns/Leads layouts before production edits.
2. Convert CampaignsBoard from desktop table plus mobile cards to one responsive card list.
3. Convert LeadsReviewTable from table rows to compact expandable cards.
4. Run lint, tests, and browser/responsive verification.
5. Record verification notes in Backlog; wait for user confirmation before Done.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented via subagent-driven TDD. Campaigns and Leads converted from table layouts to compact cards. Added static layout regression tests for campaign cards and lead expandable cards. Verification: pnpm lint exits 0 with 2 pre-existing generated Better Auth warnings; pnpm test passes 107/107; pnpm build passes after rerun with network access for Google Fonts. Browser automation could launch only outside sandbox, but authenticated dashboard routes redirected to /login in the fresh Playwright context, so final visual validation should be done in the existing logged-in browser session.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Campaigns now render as responsive cards on all breakpoints. Leads now render as compact expandable cards showing company/contact/priority by default and revealing review fields/actions through Mehr anzeigen. Added regression tests for both card layouts. Verified with pnpm lint, pnpm test, and pnpm build; browser automation reached login due fresh unauthenticated context, while user confirmed the authenticated UI manually.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,45 @@
---
id: TASK-21
title: Replace oversized Convex browser runtime dependency
status: In Progress
assignee: []
created_date: '2026-06-04 15:30'
updated_date: '2026-06-04 16:41'
labels: []
dependencies: []
priority: high
ordinal: 23000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Reduce Convex function module size by replacing @sparticuz/chromium with a minimal serverless Chromium strategy for websiteEnrichmentAction while keeping screenshot/crawl functionality.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Action no longer imports @sparticuz/chromium
- [x] #2 Convex external package list reflects the replacement
- [x] #3 Deployment guidance includes required env var and failure mode for missing browser URL
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Verify existing oversized browser dependency path in Convex action and env strategy
2. Replace @sparticuz/chromium with chromium-min + runtime executable source env var
3. Validate by TS/typecheck
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Durchgeführt: Dependency-Swap auf @sparticuz/chromium-min und Nutzung von runtime executableSource aus ENV in convex/websiteEnrichmentAction.ts. convex.json ExternalPackages auf Chromium-Min aktualisiert. Konfigurierter Fehlerpfad bei fehlender Chromium-Variable.
Final verification passed after switching to @sparticuz/chromium-min with TASK8_BROWSER_ASSET_URL as primary runtime browser asset source. Convex codegen dry-run/typecheck now uploads functions successfully; previous ModulesTooLarge error is resolved.
Follow-up for repeated /tmp/chromium cannot execute binary file: Context7 confirmed chromium-min remote pack usage; local package code reuses existing /tmp/chromium. Added marker-based /tmp cache invalidation keyed by TASK8_BROWSER_ASSET_URL so architecture/source changes remove stale /tmp/chromium and /tmp/chromium-pack before executablePath(). Verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (108/108); pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
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 -->

View File

@@ -0,0 +1,40 @@
---
id: TASK-22
title: Add source assertions for Convex AL2023 Chromium lib setup
status: In Progress
assignee: []
created_date: '2026-06-04 16:37'
updated_date: '2026-06-04 16:41'
labels: []
dependencies: []
priority: high
ordinal: 24000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add tests that fail until websiteEnrichmentAction explicitly handles AL2023 shared libs for chromium-min packaging in Convex.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Test asserts chromium-min dynamic import exposes inflate/setupLambdaEnvironment or explicit LD_LIBRARY_PATH handling for /tmp/al2023/lib.
- [x] #2 Assertion checks that runtime setup runs before Playwright launch and after executablePath resolution.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add source assertions for AL2023 runtime setup and launch ordering
2. Run focused website-enrichment action test
3. Confirm failing output and report
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Added source-only assertion in tests/website-enrichment-action.test.ts for AL2023 lib setup. Targeted run `pnpm tsc -p tsconfig.test.json && node --test .test-output/tests/website-enrichment-action.test.js` currently fails as expected on current action source (missing setup/LD_LIBRARY_PATH/al2023 archive handling).
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 -->

View File

@@ -0,0 +1,35 @@
---
id: TASK-23
title: Improve website email extraction
status: In Progress
assignee: []
created_date: '2026-06-04 17:28'
updated_date: '2026-06-04 17:34'
labels: []
dependencies: []
priority: high
ordinal: 25000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix TASK-8 website enrichment so Playwright crawls contact/imprint/footer email patterns that are visible on crawled pages but currently missed by the extractor.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Extract mailto href emails even with query parameters and labels
- [x] #2 Extract common obfuscated German website email patterns such as [at], (at), at, and spaced @/dot forms
- [x] #3 Treat emails found on Kontakt/Impressum pages or footer contact context as business contact candidates without guessing addresses
- [x] #4 Keep TASK-7 rules intact: no generated emails, named emails require explicit business context
- [x] #5 Verify with focused RED/GREEN tests and full suite
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated website-crawler extractor to support mailto query stripping/decoding, HTML entity decoding for email separators, obfuscated [at]/(at)/dot/punkt and spaced @/dot forms, and expanded business-context detection for footer/impressum/contact regions. Limited to lib/website-crawler.ts only.
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 -->

View File

@@ -0,0 +1,50 @@
---
id: TASK-24
title: Improve crawler handling for Bock Rechtsanwaelte edge cases
status: In Progress
assignee: []
created_date: '2026-06-04 18:04'
updated_date: '2026-06-04 18:09'
labels: []
dependencies: []
priority: high
ordinal: 26000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate the remaining TASK-8 case where bock-rechtsanwaelte.de/impressum contains a visible email but website enrichment misses it, and address the same-domain timeout separately if reproducible.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Reproduce the missing email against the public impressum page or captured HTML
- [x] #2 Add RED tests for the missed email/link pattern
- [x] #3 Keep no-guessing email rules intact
- [ ] #4 Add focused timeout mitigation only if root cause is identified
- [x] #5 Verify focused tests and full suite
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect existing website crawler tests
2. Add failing regression tests for Bock Impressum
3. Keep no-context named-email rejection test unchanged
4. Run focused crawler test and confirm RED
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Working on adding focused RED tests for Bock Rechtsanwaelte email extraction failure; limiting changes to tests/website-crawler.test.ts
Added 2 RED coverage tests in tests/website-crawler.test.ts. Focused run of .test-output/tests/website-crawler.test.js fails on 2 assertions: Bock Impressum candidate business-context false due expected mismatch behavior, and email-labeled mailto contactPerson currently equals the email string.
Running minimal fix for Bock Impressum email context/labeling in lib/website-crawler.ts. Next: implement anchor-indexing fix and email-label guard, then run focused tests.
Minimal scoped fix applied in lib/website-crawler.ts: mailto business-context now evaluates against raw input using anchor indices, and email-like labels matching normalized email do not become contactPerson. Verified via focused command: pnpm exec tsc -p tsconfig.test.json && node --test .test-output/tests/website-crawler.test.js (19/19 passing).
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 -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-25
title: Harden website enrichment against Convex action runtime aborts
status: In Progress
assignee: []
created_date: '2026-06-05 06:59'
updated_date: '2026-06-05 07:04'
labels: []
dependencies: []
priority: high
ordinal: 27000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Website enrichment actions can be killed by Convex with a transient invalid environment error before the JS catch block runs, leaving runs without normal failure finalization or PageSpeed queueing. Add an internal action runtime budget so long browser/bootstrap/crawl work fails inside the action before the platform aborts it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Website enrichment has an action-level runtime budget below the Convex runtime abort window
- [x] #2 Long Chromium bootstrap, browser launch, crawl, link checks, and screenshots are bounded by remaining action time
- [x] #3 When the runtime budget is exceeded, the existing catch path finalizes the enrichment run and queues PageSpeed for the lead
- [x] #4 Regression tests cover the runtime budget guard and full verification passes
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add RED source regression for action runtime budget and bounded browser/crawl steps
2. Implement minimal runtime budget helper in websiteEnrichmentAction
3. Run tests/type/lint and deploy Convex dev
4. Record findings and leave task open pending manual retest
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-06-05: Investigation found latest website_enrichment run was manually set to failed, but Convex logs show the underlying action ended with "Transient error while executing action" and environment "invalid" before app-level catch/finalization ran. This explains missing finishedAt/errorSummary/PageSpeed follow-up.
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 -->

View File

@@ -0,0 +1,50 @@
---
id: TASK-26
title: Finalize audit generation hardening and catch-all failure handling
status: Done
assignee: []
created_date: '2026-06-05 08:37'
updated_date: '2026-06-05 09:04'
labels: []
dependencies: []
priority: high
ordinal: 28000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement P1/P2/P3 audit-generation code-quality fixes with regression-safe behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 processAuditGeneration catches all late failures and marks run failed
- [x] #2 outreach_ready patch is guarded by terminal contact status
- [x] #3 truncateWithMarker is byte-safe and source tests cover byte behavior
- [x] #4 action/persistence sanitizer masks env-backed secret values
- [x] #5 model profile flags are used for model params and supportsImages
- [x] #6 reachability to deterministic outreach upsert behaviour for empty values
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add source-level regression tests for P1/P2/P3 points
2. Implement action-level robust failure handling and guarded lead status transition
3. Fix byte-aware truncation and shared sanitization paths in action/persistence
4. Rework model-profile driven generation config and multimodal gating
5. Add deterministic outreach upsert behavior and run full checks
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Verified as TASK-11 final hardening follow-up. Fixed action-level catch/failure finish, terminal-status guard for outreach_ready, UTF-8 byte-safe truncation, env-backed secret redaction, model-profile params/supportsImages usage, and deterministic outreach upsert for explicit empty values. Verification passed with TASK-11 final checks; task remains In Progress pending user confirmation.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Shipped final audit-generation hardening: catch-all post-start failure handling, terminal lead-status guard, byte-safe truncation, env-backed secret redaction, model-profile driven parameters/supportsImages, and deterministic outreach upsert behavior. Verified together with TASK-11 final checks.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,36 @@
---
id: TASK-27
title: Trigger audit generation after PageSpeed audit
status: In Progress
assignee: []
created_date: '2026-06-05 12:10'
updated_date: '2026-06-05 19:49'
labels: []
dependencies: []
priority: high
ordinal: 29000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Wire the existing AI audit generation queue into the current automated flow so completed PageSpeed audit runs schedule audit_generation for the same lead.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Successful PageSpeed audit runs queue audit generation for the lead
- [x] #2 Failed PageSpeed audit runs still queue audit generation when a lead was started so partial evidence can produce an audit
- [x] #3 Existing dedupe in queueLeadAuditGeneration prevents duplicate audit_generation runs
- [x] #4 Regression tests cover the PageSpeed-to-audit-generation handoff
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Created accidentally while implementing the PageSpeed-to-audit-generation handoff. Superseded by TASK-13 because the handoff is a prerequisite for the audit/outreach review workspace. Do not implement separately.
Started verification pass. Implementation notes say TASK-27 is superseded by TASK-13, so only regression coverage and existing handoff will be checked.
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 -->

View File

@@ -0,0 +1,98 @@
---
id: TASK-28
title: Diagnose dashboard initial-load retry loop
status: Done
assignee: []
created_date: '2026-06-05 13:46'
updated_date: '2026-06-05 15:03'
labels: []
dependencies: []
priority: high
ordinal: 30000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Find the root cause of the repeated dashboard requests on initial load, especially the repeated GET /dashboard/leads entries, and implement a targeted fix only after reproducing and tracing the loop.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Root cause is identified with evidence from the relevant dashboard/auth/navigation code
- [x] #2 A minimal fix prevents repeated dashboard/leads requests on initial load
- [x] #3 Relevant tests or verification commands are run
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused regression test proving server auth config enables Convex JWT cookie reuse.
2. Verify the test fails against the current auth-server configuration.
3. Enable the documented jwtCache option in convexBetterAuthNextJs with a scoped auth-error predicate.
4. Run the focused test, full test suite, and lint.
5. Record verification and leave TASK-28 open for user Firefox/Zen confirmation.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Evidence gathered:
- User-provided log repeatedly shows successful GET /dashboard/leads during dashboard use.
- Existing Next dev log shows a hydration failure in components/dashboard-theme.tsx:88 inside DashboardThemeToggle during /dashboard rendering: server rendered Moon/aria-pressed=false while client rendered Sun/aria-pressed=true.
- Next local docs confirm client/server render differences during hydration cause the tree to be regenerated.
- Separate WIP issue observed: /dashboard/outreach imports a missing component, which can also produce repeated dev overlay errors, but the initial dashboard hydration error is the targeted root cause for this task.
Implemented targeted fix:
- DashboardThemeProvider now uses useSyncExternalStore with a stable server snapshot of light, preventing the server/client icon and aria-pressed mismatch on initial dashboard hydration.
- Added tests/dashboard-theme.test.ts to guard against reintroducing localStorage reads in the initial render path.
Verification:
- node --test .test-output/tests/dashboard-theme.test.js passes.
- pnpm test compiles and includes the new dashboard theme test as passing, but the full run still fails in existing TASK-13 outreach WIP test OutreachReviewWorkspace uses the review workspace API and required controls.
- pnpm lint no longer reports components/dashboard-theme.tsx; it still fails in existing components/outreach/outreach-review-workspace.tsx WIP.
Additional verification note:
- pnpm exec tsc --noEmit fails in existing components/outreach/outreach-review-workspace.tsx WIP with type mismatches and missing fields; this is separate from the dashboard theme hydration fix and was already part of unrelated TASK-13 worktree changes.
User retest on 2026-06-05 falsified the first hydration-only fix. New evidence: pnpm dev still logs repeated GET /dashboard/leads every roughly 300-400ms with 200 responses, with proxy.ts taking ~165-522ms each time, followed by one get-session and two convex token requests. Re-entering systematic debugging; no more fixes until request initiator is identified.
Added temporary development-only proxy instrumentation for /dashboard/leads request classification. It logs non-sensitive request headers: accept, rsc, next-router-prefetch, next-router-segment-prefetch, next-hmr-refresh, next-url, sec-fetch-mode, purpose, referer, state-tree presence, and user-agent. Remove after confirming requester.
Corrected root cause after user retest and header instrumentation:
- First hydration hypothesis was incomplete and did not stop the request fan-out.
- Development-only proxy header instrumentation showed real browser /dashboard/leads requests were same-origin CORS fetches with next-url set to the current dashboard route, not document reloads, HMR refreshes, or server redirect loops.
- Code search showed the repeated target originates from visible Next Link surfaces: dashboard sidebar nav plus many LeadFunnelCard action links that can share href /dashboard/leads. Next App Router prefetches visible links, and each protected prefetch crosses proxy.ts and isAuthenticated(), producing many 200 GET /dashboard/leads entries.
Implemented fix:
- Set prefetch={false} on DashboardSidebar nav links and LeadFunnelCard action links to keep click navigation but stop automatic protected-route prefetch fan-out.
- Removed temporary proxy/fetch diagnostics.
- Added tests/dashboard-prefetch.test.ts to lock this behavior.
Verification:
- pnpm exec tsc -p tsconfig.test.json passes.
- node --test .test-output/tests/dashboard-prefetch.test.js .test-output/tests/dashboard-theme.test.js passes.
- pnpm test passes 260/260.
- pnpm lint passes with existing generated/unused warnings only, no errors.
2026-06-05 Firefox/Zen HAR follow-up:
- User confirmed the reload loop reproduces in Firefox/Zen but not Chrome.
- HAR shows repeated top-level document navigations to /dashboard/audits, not XHR retries or Link prefetch.
- Requests already include better-auth.convex_jwt, but SSR responses embed fresh initialToken values and /api/auth/convex/token later sets better-auth.convex_jwt again.
- Local @convex-dev/better-auth source shows getToken() fetches /convex/token unless jwtCache.enabled is configured.
Next implementation hypothesis: enable jwtCache so server getToken() reuses a valid Convex JWT cookie instead of minting a new token during each root layout render.
Implemented Firefox/Zen token-churn fix:
- Added jwtCache.enabled to lib/auth-server.ts for convexBetterAuthNextJs, matching the Convex Better Auth Next.js server utilities docs.
- Added a scoped isConvexAuthError predicate so recognized application auth failures still surface, while stale cached-token failures can trigger the library refresh path.
- Added tests/auth-server-jwt-cache.test.ts to guard the server auth cache configuration.
Verification after fix:
- pnpm exec tsc -p tsconfig.test.json passes.
- node --test .test-output/tests/auth-server-jwt-cache.test.js passes after failing before the implementation.
- pnpm test passes 265/265.
- pnpm lint passes with two existing generated-file warnings and no errors.
Manual confirmation still needed in Firefox/Zen before closing TASK-28 as Done.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Firefox/Zen reload loop fixed by enabling Convex Better Auth JWT caching in Next.js server auth utilities; regression test added and full tests/lint passed. User confirmed dashboard now loads reliably without loops.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,42 @@
---
id: TASK-29
title: Surface audit generations on dashboard audits
status: In Progress
assignee: []
created_date: '2026-06-05 20:30'
updated_date: '2026-06-05 22:45'
labels: []
dependencies: []
priority: high
ordinal: 31000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Show audit-generation pipeline data on /dashboard/audits when final audits rows do not exist yet, so local Convex auditGenerations are visible instead of an empty dashboard.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Dashboard query returns finalized audit rows and audit-generation pipeline rows
- [x] #2 Generation rows are suppressed when a finalized audit exists for the same run or lead
- [x] #3 AuditsBoard renders German labels for finalized audits and generation states
- [x] #4 Regression tests cover mixed dashboard data source and duplicate suppression
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add red regression tests for dashboard query and UI source contract
2. Implement Convex dashboard row query with audit + generation union
3. Update AuditsBoard to consume and render dashboard rows
4. Run focused tests, then full test suite
5. Record verified acceptance criteria in Backlog notes
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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 -->

View File

@@ -0,0 +1,76 @@
---
id: TASK-30
title: Externalisiere die persönliche Audit-Pipeline
status: In Progress
assignee: []
created_date: '2026-06-06 18:44'
updated_date: '2026-06-07 20:27'
labels: []
dependencies: []
priority: high
ordinal: 32000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Baue die Pipeline für audit.matthias-meister-webdesign.de so um, dass ressourcenintensive Website-Erfassung über externe API-Services statt Playwright läuft, während die Codebase später SaaS-fähig bleibt.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Neue Audit-Pipeline nutzt Jina/ScreenshotOne/PageSpeed/OpenRouter über serverseitige Managed-Konfiguration und schreibt bestehende Audit-Artefakte weiter.
- [x] #2 Usage- und Kostenereignisse werden pro Lauf/Provider persistiert und im Settings-/Readiness-Kontext sichtbar gemacht.
- [x] #3 Die v3-Skill-Registry wird geparst und in Audit-Generierung sowie Tests über das neue Finding-Schema genutzt.
- [x] #4 Outreach bleibt persönlicher SMTP-Dogfood-Kanal; bestehende Freigabe-Gates bleiben intakt und SaaS-Mailbox-Onboarding wird nicht eingeführt.
- [x] #5 Bestehende Tests plus neue TDD-Tests für Service-Adapter, Usage-Logging und Skill-Registry laufen erfolgreich.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Baseline und Arbeitsbranch sichern
2. Service-Adapter und Usage-Logging TDD implementieren
3. v3-Skill-Registry und Audit-Schema TDD implementieren
4. Pipeline-Orchestrierung auf externe Services umstellen
5. Settings/Readiness und Dokumentation aktualisieren
6. Reviews, Integration und vollständige Verifikation
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Worker B: Start TDD-Slice fuer v3 Skill-Registry und Finding-Schemas. Write-Set: lib/skills-registry.ts, lib/ai/schemas.ts, Skill-/Schema-Tests.
Baseline vor Umsetzung: `pnpm test` grün mit 307/307 Tests auf Branch `codex/pipeline-first-external-services`. Drei parallele Worker gestartet: Service-Adapter/Usage, v3-Skill-Registry/Schema, Operations-Readiness/Doku.
Worker B: GREEN fuer v3 Registry/Schemas. parseSkillsRegistry erkennt v3 YAML-Metablocks aus v2_elemente/skills.md und bleibt legacy-kompatibel; AI-Schemas enthalten v3 Finding-Items plus Audit-Aggregate. Gezielte Worker-B-Tests: 17/17 gruen. Gesamtes pnpm test weiterhin durch parallele fremde Tests blockiert (external-audit-services, operational-readiness).
Worker B: Final fokussierte Verifikation nach Mischformat-Test: 18/18 gruen fuer audit-skill-registry-v3, ai-schemas und skills-registry.
Worker B Quality Review: Start TDD-Fix fuer strengere v3 Audit-Schemas und keine heuristischen v3-Kategorien.
Worker B Quality Review: GREEN. v3 Audit-Schema rejected blank text/empty arrays, ctaType auf anruf|termin|rueckruf begrenzt; v3 Registry gibt ohne explizite Kategorie keine category mehr aus. Fokussierte Tests: 21/21 gruen.
Worker B Quality Review: Erweiterte fokussierte Verifikation inkl. audit-evidence: 27/27 gruen.
Grundslices reviewt: A Service-Adapter/Usage approved, B v3 Skill-Registry/Schemas approved, C Operations-Readiness/Doku approved. Reviewer-Verifikation: C `pnpm test` 321/321; B fokussiert 21/21; A fokussiert 7/7.
Worker D: GREEN fuer Convex Usage-/Kostenpersistenz-Slice. Added usageEvents schema with provider/operation/runId/leadId/auditId/estimatedCostUsd/tokens/callCounts/createdAt, bounded indexes, internal recordUsageEvent mutation, and bounded usage queries by latest/run/lead/audit/provider. RED confirmed via failing usage-events-source contract before implementation; final verification `pnpm test -- tests/usage-events-source.test.ts` passed with tsc and 332/332 tests. Task intentionally remains In Progress pending orchestrator/user confirmation.
Worker D Quality Review: GREEN fuer UsageEvents numeric guardrails. RED bestaetigt durch neuen Source-Contract fuer assertValidUsageEventNumbers vor ctx.db.insert. recordUsageEvent validiert jetzt estimatedCostUsd als finite non-negative number und alle token/callCounts-Felder als finite non-negative integers, um negative Werte, NaN, Infinity und Bruchwerte vor Persistenz zu blockieren. Final verification `pnpm test -- tests/usage-events-source.test.ts` passed with tsc and 334/334 tests. Task bleibt In Progress.
UsageEvents-Slice approved: schema/module/tests mit Guardrails fuer finite non-negative Kosten und integer Tokens/CallCounts; D Spec+Quality approved.
Worker E: RED/GREEN fuer externe Audit-Orchestrierung abgeschlossen. RED bestaetigt mit neuem tests/external-audit-pipeline-source.test.ts: fehlende externe Helper, UsageEvents und Jina-Markdown-Anbindung. GREEN: auditGenerationAction bereitet ScreenshotOne/Jina-Capture aus started.lead.websiteUrl/websiteDomain vor, guardet ScreenshotOne ueber SCREENSHOTONE_API_KEY, nutzt optional JINA_API_KEY, persistiert erfolgreiche ScreenshotOne-Bilder via ctx.storage.store + internal.auditGeneration.persistExternalCaptureScreenshot in websiteCrawlScreenshots, gibt Jina-Markdown in buildAuditEvidenceInput/Prompts und protokolliert usageEvents fuer screenshotone/jina audit_capture sowie openrouter audit_generation. Fokussierte Verifikation: pnpm test -- tests/external-audit-pipeline-source.test.ts gruen mit 335/335 Tests.
Worker E Quality Review: RED/GREEN fuer drei Review-Issues abgeschlossen. RED: tests/external-audit-pipeline-source.test.ts fiel auf fehlende Capture-Timeouts/Body-Limits, unsichere Error-Pfade und fehlende German-Copy-Usage-Aggregation. GREEN: auditGenerationAction nutzt EXTERNAL_CAPTURE_TIMEOUT_MS mit AbortController, MAX_SCREENSHOT_BYTES, MAX_JINA_MARKDOWN_BYTES und MAX_JINA_MARKDOWN_CHARS; Screenshot/Jina Bodies werden stream-basiert begrenzt statt response.blob()/response.text(); messageFromError sanitizt ueber sanitizeSecretCandidates inkl. SCREENSHOTONE_API_KEY/JINA_API_KEY und alle Error-Pfade nutzen safeErrorSummary; German-Copy UsageEvent aggregiert alle sechs OpenRouter-Aufrufe der Stufe. Verifikation: pnpm test -- tests/external-audit-pipeline-source.test.ts gruen mit 341/341 Tests.
Orchestrator final verification: AC #1 checked after external Capture/Generation pipeline uses ScreenshotOne/Jina/PageSpeed/OpenRouter server-side configuration, persists screenshots to existing websiteCrawlScreenshots/artifacts, and records provider usage. AC #4 checked because outreach remains the personal SMTP dogfood flow with existing review gates; no SaaS mailbox onboarding was introduced. Final review found no P0/P1 blockers. Task remains In Progress pending Matthias manual confirmation before Done.
2026-06-07: Investigating user report that audit runs fail and Convex table rows mention Azure. Repository search found no azure/Azure/AZURE string in code or backlog, so initial hypothesis is that Azure comes from an external provider/model error surfaced through OpenRouter/AI SDK or persisted raw error details from a live Convex run, not from application code.
2026-06-07: Root cause for failed auditGenerations confirmed from live error: OpenRouter routed an OpenAI-compatible request through an Azure-backed provider path using strict structured outputs. AI SDK 6/OpenAI strictJsonSchema rejects response_format JSON schemas where an object property exists but is omitted from required; Zod .optional() generated exactly that for auditClassificationSchema.usedSkills. Classification failed before any audit could complete. Applied TDD fix: changed generated-output schemas used by generateObject from optional top-level fields to nullable fields for auditClassificationSchema.usedSkills, followUpDraftSchema.followInDays/goals, and qualityReviewSchema.notes; updated prompt/action null handling. RED confirmed focused schema test failed on missing usedSkills; GREEN verification passed: focused ai-schemas test 11/11, pnpm test 365/365, pnpm exec tsc -p tsconfig.json --pretty false, pnpm lint 0 errors with two pre-existing BetterAuth generated warnings, pnpm exec tsc -p convex/tsconfig.json --pretty false. Convex SaaS typecheck could not be completed because sandbox network failed and escalation was rejected due external code/metadata upload risk; user approval is required for that exact command.
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 -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-31
title: Require auth for usage event reads
status: In Progress
assignee: []
created_date: '2026-06-06 20:27'
updated_date: '2026-06-06 20:31'
labels: []
dependencies: []
priority: high
ordinal: 33000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Protect public Convex usageEvents read queries from unauthenticated access while preserving validators, bounded reads, and index usage.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Source contracts assert every public usageEvents read query requires requireOperator auth
- [x] #2 usageEvents read queries call requireOperator before reading sensitive telemetry
- [x] #3 Focused usage-events source tests pass after the implementation
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect usageEvents source tests and local auth patterns
2. Add RED source contracts for authenticated read queries
3. Run focused test and capture RED
4. Add minimal requireOperator guard to usageEvents reads
5. Run focused GREEN verification and self-review
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: pnpm test -- tests/usage-events-source.test.ts is blocked by pre-existing tests/ai-schemas.test.ts missing exports. Focused node --test tests/usage-events-source.test.ts fails as expected on missing usageEvents requireOperator auth guard.
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 -->

View File

@@ -0,0 +1,44 @@
---
id: TASK-32
title: Wire v3 skill registry into audit generation
status: In Progress
assignee: []
created_date: '2026-06-06 20:27'
updated_date: '2026-06-06 20:36'
labels: []
dependencies: []
priority: high
ordinal: 34000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the final review finding by using the v3 skills registry and v3 finding validation in the live audit generation path while preserving best-effort fallback behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 auditGenerationAction loads and passes a non-empty v3 skill registry from v2_elemente/skills.md/loadSkillsRegistry when available
- [x] #2 Classification uses a v3 findings schema live instead of legacy-only internalFindingsSchema
- [x] #3 Audit persistence validators accept v3 usedSkills with id and optional category without forcing undefined category fields
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Read current audit generation, schemas, validators, and focused tests
2. Add RED source-contract/schema tests for v3 registry, v3 classification, and optional usedSkill category
3. Run focused tests and record failures
4. Implement minimal wiring and validator/schema changes
5. Run focused tests green plus relevant verification
6. Self-review scope and update task notes without closing
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: pnpm test tests/audit-generation-action-source.test.ts tests/ai-schemas.test.ts tests/audit-skills-schema.test.ts tests/audit-skill-registry-v3.test.ts failed in tsc because auditClassificationSchema and AuditClassification are not exported yet. This confirms the v3 classification schema is not wired.
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 -->

View File

@@ -0,0 +1,44 @@
---
id: TASK-33
title: Fix v3 live wiring quality issues
status: In Progress
assignee: []
created_date: '2026-06-06 20:41'
updated_date: '2026-06-06 20:47'
labels: []
dependencies: []
priority: high
ordinal: 35000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Address the two v3 live wiring review quality issues: select category-less v3 skills from the real registry and keep registry-load warning logging best-effort.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Real v3 skills from v2_elemente/skills.md are selected from realistic audit evidence without fabricated categories
- [x] #2 Legacy category-based skill registry selection continues to work
- [x] #3 Registry load fallback returns an empty registry even when warning event logging fails
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current skill selection and action warning fallback
2. Add RED tests for real v3 registry selection and isolated warning logging
3. Run focused tests and record RED failures
4. Implement minimal selection and warning isolation fixes
5. Run focused tests green plus typecheck/relevant suite
6. Self-review scope and leave task In Progress
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: tsc passed. node --test .test-output/tests/audit-evidence.test.js .test-output/tests/audit-generation-action-source.test.js failed with 2 expected failures: real v3 registry selectedSkills was empty/missing ids, and loadAuditSkillRegistry warning logging lacked isolated try/catch fallback.
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 -->

View File

@@ -0,0 +1,42 @@
---
id: TASK-34
title: Harden v3 selection and Convex payloads
status: In Progress
assignee: []
created_date: '2026-06-06 20:54'
updated_date: '2026-06-06 21:03'
labels: []
dependencies: []
priority: high
ordinal: 36000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix v3 quality review issues by removing explicit undefined values from Convex mutation payloads and making v3 skill selection registry-driven with negative applicability tests.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Convex mutation payloads in auditGenerationAction omit undefined top-level and nested fields
- [x] #2 v3 skill selection is registry-driven by applies_when and declared inputs with deterministic capped output
- [x] #3 Negative v3 input/applicability tests and legacy category tests pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current Convex mutation payload construction and v3 selection
2. Add RED tests for no undefined payload patterns, negative v3 gating, and deterministic cap
3. Run focused tests and record RED failures
4. Implement minimal payload omission and registry-driven v3 selection
5. Run focused tests green plus pnpm test if fast
6. Self-review scope and leave task In Progress
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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 -->

View File

@@ -0,0 +1,44 @@
---
id: TASK-35
title: Remove remaining undefined audit generation payloads
status: In Progress
assignee: []
created_date: '2026-06-06 21:06'
updated_date: '2026-06-06 21:13'
labels: []
dependencies: []
priority: high
ordinal: 37000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix TASK-34 spec-review issues by preventing appendRunEvent, success finish, and quality stage calls from sending explicit undefined optional fields.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 appendRunEvent only sends details when defined
- [x] #2 success finishAuditGenerationRun omits errorSummary instead of sending undefined
- [x] #3 quality-stage persistAuditStage callsite does not pass explicit undefined optional fields
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect appendRunEvent, quality persist stage, and success finish call
2. Add RED source contracts for remaining explicit undefined patterns
3. Run focused tests and record RED
4. Implement minimal conditional spreads
5. Run focused tests green and full pnpm test if fast
6. Self-review scope and leave task In Progress
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-source.test.js failed as expected on three contracts: appendRunEvent details sent as args.details, success finishAuditGenerationRun ternary errorSummary undefined, and qualityReview persistAuditStage callsite ternary errorSummary undefined.
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 -->

View File

@@ -0,0 +1,44 @@
---
id: TASK-36
title: Remove optional helper undefined args
status: In Progress
assignee: []
created_date: '2026-06-06 21:15'
updated_date: '2026-06-06 21:23'
labels: []
dependencies: []
priority: high
ordinal: 38000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix remaining spec-review issues in auditGenerationAction by avoiding explicit undefined auditId and nested usage fields in helper call arguments.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 persistAuditStage callsites include auditId only by conditional spread
- [x] #2 recordOpenRouterUsage/recordAuditUsageEvent/capture helper callsites include optional auditId only by conditional spread
- [x] #3 stage usage helper args are built without explicit undefined token fields
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect auditId and usage helper callsites
2. Add RED source contracts for optional auditId and nested usage args
3. Run focused test and record RED
4. Implement minimal conditional spreads and usage arg helper
5. Run focused tests green and full pnpm test if fast
6. Self-review scope and leave task In Progress
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: tsc passed. Focused node --test .test-output/tests/audit-generation-action-source.test.js failed as expected on persistAuditStage auditId callsites, helper auditId callsites, and inline nested usage objects.
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 -->

View File

@@ -0,0 +1,44 @@
---
id: TASK-37
title: Prioritize v3 local audit skills
status: In Progress
assignee: []
created_date: '2026-06-06 21:30'
updated_date: '2026-06-06 21:38'
labels: []
dependencies: []
priority: high
ordinal: 39000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a deterministic local-audit relevance rule before the v3 skill selection cap so core applicable skills are not displaced by registry order.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Full-evidence v3 selection includes local-seo-basics and performance-experience within the cap
- [x] #2 v3 input/applicability gating remains enforced
- [x] #3 Legacy category-based skill selection remains supported
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current v3 selection and existing audit-evidence tests
2. Add RED tests against real v2_elemente/skills.md for full-evidence core skill inclusion and missing-input gating
3. Run focused test and record RED
4. Implement minimal deterministic local-audit relevance sort before cap
5. Run focused tests green and full pnpm test if fast
6. Self-review scope and leave task In Progress
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: pnpm exec tsc -p tsconfig.test.json passed. Focused node --test .test-output/tests/audit-evidence.test.js failed as expected: full-evidence v3 selection returned registry-order ids visual-design, first-impression-clarity, contact-conversion, mobile-usability, trust-signals, conversion-copy instead of including local-seo-basics and performance-experience before the cap.
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 -->

View File

@@ -0,0 +1,44 @@
---
id: TASK-38
title: Add ScreenshotOne missing-key run warning
status: In Progress
assignee: []
created_date: '2026-06-06 21:41'
updated_date: '2026-06-06 21:46'
labels: []
dependencies: []
priority: high
ordinal: 40000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Emit a best-effort warning run event when an external audit needs screenshots but SCREENSHOTONE_API_KEY is not configured, while keeping audit classification and AI stages running.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 needsScreenshots with missing SCREENSHOTONE_API_KEY writes a warning run event through appendRunEvent
- [x] #2 warning logging is best-effort and cannot fail the audit run
- [x] #3 needsScreenshots false does not emit the missing-key warning
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect current ScreenshotOne skip path and source-contract style
2. Add RED source-contract for warning event and best-effort guard
3. Run focused test to capture RED
4. Implement minimal runtime warning inside needsScreenshots missing-key branch
5. Run focused tests green and broader tests if practical
6. Self-review and report without staging or commits
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED verified: pnpm exec tsc -p tsconfig.test.json passed, then node --test .test-output/tests/external-audit-pipeline-source.test.js failed only on missing ScreenshotOne config warning message (actual index -1).
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 -->

View File

@@ -0,0 +1,34 @@
---
id: TASK-39
title: Secure Convex operator APIs
status: In Progress
assignee: []
created_date: '2026-06-06 21:52'
updated_date: '2026-06-06 22:00'
labels: []
dependencies: []
priority: high
ordinal: 41000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Guard non-public Convex audit, lead, and run APIs so sensitive operational data is not exposed or mutated without authentication while preserving internal pipeline calls.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Audit admin reads and writes require operator auth while getPublicBySlug remains public
- [x] #2 Lead admin reads and review mutations require operator auth while internal audit-generation calls use internal functions
- [x] #3 Run admin reads/writes require operator auth while internal actions can append run events safely
- [x] #4 Source contracts and full tests pass
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Worker I audit slice: Added source-contract coverage for audit admin auth guards and preserved public getPublicBySlug. RED: node --test .test-output/tests/audits-auth-source.test.js failed on create missing requireOperator before ctx.db. GREEN: pnpm exec tsc -p tsconfig.test.json passed; node --test .test-output/tests/audits-auth-source.test.js passed (2/2).
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 -->

View File

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

View File

@@ -0,0 +1,40 @@
---
id: TASK-40
title: Behebe abschliessende Lint-Blocker
status: In Progress
assignee: []
created_date: '2026-06-06 22:10'
updated_date: '2026-06-06 22:15'
labels: []
dependencies: []
priority: high
ordinal: 42000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the final lint blockers after the v2 pipeline implementation without changing runtime behavior. Keep v2_elemente as planning/reference material unless production imports require otherwise.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 pnpm lint exits 0 or only documents unrelated pre-existing generated warnings with a scoped suppression decision
- [x] #2 pnpm test remains green
- [x] #3 git diff --check remains green
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce pnpm lint failures
2. Apply scoped minimal lint policy or test-file cleanup
3. Re-run pnpm lint, pnpm test, git diff --check
4. Leave task In Progress until Matthias confirms Done
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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 -->

View File

@@ -0,0 +1,47 @@
---
id: TASK-41
title: Repariere Convex-Typecheck fuer Usage Events
status: In Progress
assignee: []
created_date: '2026-06-06 22:13'
updated_date: '2026-06-06 22:16'
labels: []
dependencies: []
priority: high
ordinal: 43000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix final Convex typecheck blockers after adding usageEvents and external screenshot persistence. This includes updating generated Convex API references if required and making screenshot blob storage type-valid without changing runtime behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 pnpm exec convex codegen --dry-run --typecheck enable exits 0
- [x] #2 pnpm exec tsc --noEmit exits 0 or reports only documented unrelated pre-existing issues
- [x] #3 pnpm test remains green
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce Convex typecheck/codegen failures
2. Regenerate Convex API if required
3. Fix screenshot Blob typing with minimal runtime-neutral change
4. Re-run Convex typecheck, tsc, pnpm test
5. Leave task In Progress until Matthias confirms Done
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Verification/results:
- Reproduced with `pnpm exec convex codegen --dry-run --typecheck enable` outside sandbox after pnpm sandbox DB failure; initial result failed with TS2339 `internal.usageEvents` missing and TS2322 `Uint8Array<ArrayBufferLike>` not assignable to `BlobPart` in convex/auditGenerationAction.ts.
- Ran `pnpm exec convex codegen` outside sandbox; generated convex/_generated/api.d.ts now includes usageEvents.
- Applied minimal ownership-scoped Blob typing fix in convex/auditGenerationAction.ts by wrapping screenshotBytes with `new Uint8Array(screenshotBytes)` before Blob storage.
- `pnpm exec convex codegen --dry-run --typecheck enable` exits 0.
- `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 -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-42
title: Scope v2 Referenzdateien aus dem Typecheck
status: In Progress
assignee: []
created_date: '2026-06-06 22:16'
updated_date: '2026-06-06 22:18'
labels: []
dependencies: []
priority: high
ordinal: 44000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Keep v2_elemente as PRD/reference snippets while ensuring the production TypeScript check is not broken by those exploratory files.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 pnpm exec tsc --noEmit exits 0
- [x] #2 pnpm lint remains green
- [x] #3 pnpm test remains green
- [x] #4 v2_elemente content remains available as planning/reference material
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce tsc failures from v2_elemente snippets
2. Apply minimal production TypeScript scope fix
3. Re-run tsc, lint, tests, diff check
4. Leave task In Progress until Matthias confirms Done
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Reproduced pnpm exec tsc --noEmit failure: production tsconfig includes v2_elemente reference snippets via **/*.ts, while eslint already scopes them out as non-runtime material.
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 -->

View File

@@ -0,0 +1,50 @@
---
id: TASK-43
title: Stabilisiere Website-Enrichment ohne Playwright-Abbruch
status: In Progress
assignee: []
created_date: '2026-06-07 19:40'
updated_date: '2026-06-07 20:57'
labels: []
dependencies: []
priority: high
ordinal: 45000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix the Convex websiteEnrichmentAction crash where Playwright/Chromium closes during lead enrichment after a new lead is created. The action should not fail the lead pipeline when browser-based enrichment crashes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The root cause and affected call path are documented in task notes
- [x] #2 Lead enrichment degrades gracefully when browser/page/context is closed
- [x] #3 Regression tests cover the browser-closed failure path or removal of Playwright dependency
- [x] #4 Relevant verification commands pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce and trace the browser-closed failure path in websiteEnrichmentAction
2. Compare with existing graceful-failure paths and Convex action constraints
3. Add a RED regression test for page/context/browser closed during page capture
4. Delegate a minimal fix that degrades enrichment instead of crashing
5. Run focused and full verification; leave task In Progress until Matthias confirms Done
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root-cause investigation: The reported Convex log is from internal action websiteEnrichmentAction:processLeadEnrichment, not auditGenerationAction. The action still launches Playwright/Chromium for legacy lead website enrichment. The log shows navigation reached the target page multiple times, then Playwright threw `Target page, context or browser has been closed`. Current code has an outer catch, but the outer finally closes desktopContext/mobileContext/browser without protection; if a resource is already closed, cleanup can throw after the catch and surface as Convex Uncaught Error. Helper-level page.close() calls are also unprotected and can obscure the original browser failure. Hypothesis: cleanup must be best-effort and browser/page instability should finish the run as failed/degraded, queue PageSpeed if possible, and patch lead reason instead of crashing the action runtime.
TASK-43 Worker update: Website-Enrichment-only fix. RED test added in tests/website-enrichment-action.test.ts for best-effort Playwright cleanup; initial focused run failed on missing isPlaywrightTargetClosedError/closePlaywrightResourceSafely contract. Minimal fix in convex/websiteEnrichmentAction.ts adds isPlaywrightTargetClosedError and closePlaywrightResourceSafely; page.close(), desktopContext.close(), mobileContext.close(), and browser.close() now run through the safe helper. Target/page/context/browser closed cleanup errors are swallowed so the existing action catch/failure path can persist failed runs, queue PageSpeed when possible, and patch lead reason. Unexpected cleanup close failures are swallowed with console.warn. No AuditGeneration, ScreenshotOne, or Jina slices touched by this TASK-43 change. Verification: pnpm test -- tests/website-enrichment-action.test.ts passed after RED/GREEN (386 pass, 0 fail); pnpm exec tsc --noEmit passed; pnpm lint passed with 2 existing generated-file warnings in convex/betterAuth/_generated; pnpm test passed (364 pass, 0 fail); git diff --check passed.
Live follow-up 2026-06-07 22:34 CEST: Audit generation now succeeds, but website_enrichment still fails before useful extraction when TASK8_BROWSER_ASSET_URL / Chromium source is not configured. New objective for this task slice: remove the Chromium/Playwright hard requirement by adding a no-browser enrichment path, or otherwise prevent the website_enrichment run from failing solely because no browser asset is configured.
Follow-up fix: The live Convex run j9737mz0tkgdbg6mzjxjd1w7018878b1 failed because processLeadEnrichment still treated missing TASK8_BROWSER_ASSET_URL / Chromium source as a fatal Playwright bootstrap error. Added a browserless fetch fallback in convex/websiteEnrichmentAction.ts: when no Chromium source is configured, the action records a warning, fetches homepage/relevant static subpages directly with bounded response reads, extracts metadata/links/contact candidates via the existing website-crawler helpers, persists websiteCrawlPages/websiteCrawlLinks/websiteEmailCandidates/websiteTechnicalChecks with screenshots=[], patches the lead, queues PageSpeed, and finishes website_enrichment as succeeded if direct crawl succeeds. Existing Playwright path remains available when Chromium is configured. Regression source tests now cover the no-Chromium branch and browserless persistence. Verification: pnpm test -- tests/website-enrichment-action.test.ts passed; pnpm exec tsc -p convex/tsconfig.json --pretty false passed; pnpm exec tsc -p tsconfig.json --pretty false passed; pnpm test passed (368/368); pnpm lint passed with 2 existing generated BetterAuth warnings; git diff --check passed.
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 -->

View File

@@ -0,0 +1,46 @@
---
id: TASK-44
title: Port audit pipeline fully into the MVP
status: In Progress
assignee: []
created_date: '2026-06-07 21:16'
updated_date: '2026-06-07 21:34'
labels: []
dependencies: []
priority: high
ordinal: 46000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove runtime dependencies on v2 reference files, bundle the v3 audit skill registry into the MVP, and ensure audit generation consumes website enrichment evidence from the lead's latest successful enrichment run.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Audit generation no longer reads or imports v2_elemente at runtime
- [x] #2 MVP v3 audit skills are bundled through a production-owned source and selected during audit evidence building
- [x] #3 Audit generation evidence includes crawl pages, technical checks, and screenshots from the latest successful website_enrichment run for the same lead
- [ ] #4 ScreenshotOne remains optional only until configured and no missing-key warning appears after the corrected Convex env is present
- [x] #5 Regression tests and local verification commands pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add RED tests for bundled MVP skill registry and no v2 runtime dependency
2. Add RED tests for audit evidence loading latest successful website enrichment data by lead
3. Implement bundled MVP v3 skill registry and wire audit generation action to it
4. Implement lead-based enrichment evidence lookup with audit-run screenshot fallback
5. Clarify readiness copy for Next vs Convex env scope
6. Run focused and full verification without closing the backlog task
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented GREEN slice after RED tests: added bundled MVP v3 audit skill registry, rewired auditGenerationAction away from v2_elemente runtime file reads, loaded latest successful website_enrichment evidence by lead in getAuditGenerationEvidence, preserved audit-run ScreenshotOne captures, and clarified settings readiness copy for Next.js vs Convex Action env scope. Focused tests passed for registry, audit evidence, action source, persistence source, and ops quality.
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 -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-45
title: Show audit evidence on detail pages
status: In Progress
assignee: []
created_date: '2026-06-07 21:50'
updated_date: '2026-06-07 22:01'
labels: []
dependencies: []
priority: high
ordinal: 47000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the audit detail view so stored checked pages and compact website-enrichment evidence are visible instead of only showing the page count.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Audit detail query returns ordered checked-page evidence with crawl, technical, and screenshot summaries
- [x] #2 Audit detail UI renders a compact Geprüfte Seiten section between overview and skills
- [x] #3 Fallback rows render checkedPages even when enrichment evidence is missing
- [x] #4 Public audit and outreach flows remain unchanged
- [x] #5 Regression tests and local verification pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add RED tests for getDetail sourceSummaries checked-page evidence
2. Add RED tests for AuditDetail compact evidence rendering
3. Extend audits.getDetail with bounded lead/enrichment evidence summaries
4. Render compact checked-page evidence card in AuditDetail
5. Run focused and full verification without closing the task
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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 -->

View File

@@ -0,0 +1,48 @@
---
id: TASK-46
title: Add Convex specialist fan-out audit pipeline
status: In Progress
assignee: []
created_date: '2026-06-08 09:04'
updated_date: '2026-06-08 09:19'
labels: []
dependencies: []
priority: high
ordinal: 48000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement an evidence-first specialist fan-out/fan-in audit generation pipeline in Convex so audits produce verified, reviewable findings before German copy and publication.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Specialist audit stages run after evidence collection and before German copy
- [x] #2 Specialist findings include typed evidence refs and unsupported claims are rejected
- [x] #3 Verified findings are persisted separately and surfaced on audit detail pages
- [x] #4 Quality review blocks when either model QA or German copy guard fails
- [x] #5 Skill summaries use real registry purpose or instructions
- [x] #6 Schema, evidence, action-source, persistence, quality gate, and UI tests pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add RED tests for specialist schemas, evidence IDs, action ordering, persistence, QA gates, and UI rendering
2. Implement schema validators and evidence ledger helpers
3. Add auditFindings persistence and detail query joins
4. Wire specialist fan-out stages and evidence verifier before German copy
5. Make qualityReview model invalid state blocking and improve skill summaries
6. Update audit detail UI to render findings with evidence chips
7. Run focused tests, typecheck, and full test suite where feasible
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
RED: pnpm exec tsc -p tsconfig.test.json fails because AuditEvidenceInput has no evidenceLedger and lib/ai/schemas exports no specialist/verifier schemas yet. This is the expected missing-feature failure.
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 -->

View File

@@ -0,0 +1,48 @@
---
id: TASK-47
title: Fix evidence verifier audit generation failure
status: In Progress
assignee: []
created_date: '2026-06-08 09:35'
updated_date: '2026-06-08 10:07'
labels: []
dependencies: []
priority: high
ordinal: 49000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Diagnose and fix the evidenceVerifier stage failure in the Convex specialist fan-out audit pipeline so live audit generation can complete or fail with actionable verifier diagnostics.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Root cause is identified from persisted run or generation evidence
- [x] #2 Evidence verifier schema or prompt no longer fails on valid specialist outputs
- [x] #3 Audit generation preserves strict evidence gates without schema-induced false failures
- [x] #4 Focused and full regression tests pass
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Pull the failing evidenceVerifier error details from Convex run/generation records
2. Add a RED regression test for the root cause
3. Fix the verifier schema/prompt or fallback behavior at the source
4. Run focused fan-out tests and full pnpm test
5. Record verification notes and keep task In Progress until user confirms live audit works
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause from Convex auditGenerations/agentRunEvents: all specialist structured-output calls failed before content generation because Azure rejected the response_format schema. The shared evidenceRef object declared sourceUrl as an optional property, but Azure/OpenAI strict structured outputs require every declared property to be listed in required. The verifier then received an empty findings array and failed on the same schema issue.
Fix: made Specialist/Verifier output schemas strict-output compatible by requiring sourceUrl and required array fields, added explicit prompt guidance for sourceUrl/status/findings/notes, and replaced rejectedFindings with a narrow rejection schema so unknown/generic rejected claims do not have to pass the publishable finding schema.
Verification: RED test reproduced schema.findings[].evidenceRefs[].sourceUrl missing from required; focused schema tests now pass; fan-out/persistence/UI tests pass; pnpm test passes 386/386; git diff --check passes; ESLint on touched source/test files passes.
Second live failure root cause: after the strict schema fix, specialist stages succeeded, but evidenceVerifier failed with "No object generated: could not parse the response." The persisted verifier prompt contained about 10 full specialist findings and the verifier schema required echoing full verifiedFindings objects back. With the classification profile capped at 1200 output tokens, this made verifier output too large/fragile to parse. Context7 AI SDK docs confirmed AI SDK 6 uses strict OpenAI JSON schema behavior by default; the issue was now output shape/size rather than schema rejection.
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 -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-48
title: Integrate impeccable critique into audit pipeline
status: In Progress
assignee: []
created_date: '2026-06-08 12:02'
updated_date: '2026-06-08 12:10'
labels: []
dependencies: []
priority: high
ordinal: 50000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Extend the evidence-first audit pipeline with design critique/impeccable-style visual and UX evaluation, especially the critique skill, while keeping verified findings evidence-linked and customer-safe.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Critique/impeccable skill guidance is inspected and translated into bounded audit stages or skill prompts
- [x] #2 New critique findings stay evidence-linked and flow through the compact evidence verifier
- [x] #3 German copy synthesis consumes only verified critique findings, not raw skill output
- [x] #4 Audit UI exposes critique findings with evidence chips and actual skill purpose text
- [x] #5 Focused and full regression tests cover the new critique integration
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect impeccable/critique skill guidance and current audit pipeline shape
2. Define a compact critique/impeccable stage that maps skill guidance into evidence-backed audit findings
3. Add schemas/prompts or stage wiring without expanding verifier output size
4. Update UI/tests so critique findings are visible with evidence and real skill purpose
5. Run focused and full regression tests, deploy Convex dev, keep task In Progress for live confirmation
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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 -->

View File

@@ -0,0 +1,43 @@
---
id: TASK-49
title: Improve audit outreach email tone
status: In Progress
assignee: []
created_date: '2026-06-08 19:30'
updated_date: '2026-06-08 19:48'
labels: []
dependencies: []
priority: high
ordinal: 51000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add evidence-first, collegial-direct tonal guidelines for generated outreach emails, wire them into the existing German copy stage without extra AI calls, and hard-block unnatural email copy before outreach_ready.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Shared customer tone guidelines capture the selected collegial-direct email style and banned patterns
- [x] #2 German copy prompts use the tone guidelines, explicit lead context, at most two verified findings, and no extra AI stage or model call
- [x] #3 Deterministic German copy guard blocks unnatural email subjects and bodies while keeping public audit tone checks limited to existing rules
- [x] #4 Quality review applies the same first-contact email rubric
- [x] #5 Focused and full regression tests cover natural email pass cases, unnatural email failures, source wiring, and no new generation stage
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing tests for natural vs. formulaic outreach email tone
2. Add shared collegial-direct tone guideline module
3. Add deterministic hard guard for email subject/body tone
4. Wire guidelines into German copy and quality review prompts without a new AI stage
5. Run focused tests, full regression, lint, diff check, and Convex dev sync
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- 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 -->

View File

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

View File

@@ -0,0 +1,52 @@
---
id: TASK-50
title: Refactor dashboard views into compact cards
status: In Progress
assignee: []
created_date: '2026-06-08 19:56'
updated_date: '2026-06-08 20:21'
labels: []
dependencies: []
priority: high
ordinal: 52000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement the planned internal Ops UX refactor for Campaigns, Leads, Audits, and Review Workspace using compact shadcn-style cards, modal/detail disclosure, and accessible status feedback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Campaigns render as a responsive card grid while preserving existing campaign actions and run logs.
- [x] #2 Leads show compact cards and open the review form in an accessible modal from Mehr anzeigen.
- [x] #3 Audits use responsive cards with detail links for audit rows and non-clickable pipeline states for generation rows.
- [x] #4 Review Workspace uses compact queue cards with a single selected detail editor while preserving existing save, publish, approve, and send flows.
- [x] #5 Relevant tests, lint, and build pass or any remaining blockers are documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing UI/source tests for card-grid, lead modal, audit cards, and review master-detail
2. Implement Campaigns responsive grid and accessible card semantics
3. Move Leads inline review details into Dialog modal
4. Replace Audits row table with responsive cards
5. Convert Review Workspace to queue cards plus selected detail editor
6. Run focused tests, then lint/build where feasible
7. Record verification notes on TASK-50 without marking Done
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented compact card UX for Campaigns, Leads, Audits, and Review Workspace.
Verification: pnpm test -- campaigns-board-layout leads-review-table audits-board-layout outreach-review-workspace-ui passed with 399/399 tests.
Verification: pnpm lint passed with 0 errors and 2 pre-existing generated Convex warnings.
Verification: pnpm build passed outside sandbox; sandbox build failed only because next/font could not fetch Google Fonts.
Smoke check: production server routes /dashboard/campaigns, /dashboard/leads, /dashboard/audits, /dashboard/outreach returned 307 /login as expected for protected routes.
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 -->

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-8 id: TASK-8
title: Implement Playwright website crawling and screenshot capture title: Implement Playwright website crawling and screenshot capture
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-06-03 19:13' created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 18:09'
labels: labels:
- mvp - mvp
- audit - audit
@@ -19,24 +20,56 @@ ordinal: 8000
## Description ## Description
<!-- SECTION:DESCRIPTION:BEGIN --> <!-- SECTION:DESCRIPTION:BEGIN -->
Build the website inspection layer using Playwright. For qualified leads, the system should load the company website, inspect the homepage and a small set of relevant subpages, capture desktop/mobile screenshots, extract visible text and contact signals, and store all raw evidence in Convex. Build the website inspection and contact-enrichment layer using Playwright. For qualified leads, the system should load the company website, inspect the homepage and a small set of relevant subpages, capture desktop/mobile screenshots, extract visible text and contact signals, store all raw evidence in Convex, and feed found email candidates back into the TASK-7 qualification rules before a lead remains in Kontakt fehlt. Google Places does not provide business email fields, so website crawl evidence is the primary MVP source for usable business email addresses.
<!-- SECTION:DESCRIPTION:END --> <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 Playwright captures desktop and mobile screenshots for the homepage and stores them in Convex File Storage - [x] #1 Playwright captures desktop and mobile screenshots for the homepage and stores them in Convex File Storage
- [ ] #2 Crawler visits a bounded set of relevant subpages: Kontakt, Impressum, Leistungen/Angebot, Über uns/Team when discoverable - [x] #2 Crawler visits a bounded set of relevant subpages: Kontakt, Impressum, Leistungen/Angebot, Über uns/Team when discoverable
- [ ] #3 Crawler extracts visible text, page title, meta description, headings, links, phone numbers, email candidates, and CTA/contact-form signals - [x] #3 Crawler extracts visible text, page title, meta description, headings, links, phone numbers, email candidates, email source URLs, contact-person context, and CTA/contact-form signals
- [ ] #4 Simple technical checks include HTTPS/final URL, missing title/meta description, visible contact path, and obvious broken internal links within the crawl limit - [x] #4 Extracted email candidates are classified through the TASK-7 rules: generic business emails are preferred; named emails are accepted only when explicitly published as business contact addresses; no guessed addresses are generated
- [ ] #5 Crawler failures produce useful dashboard-visible errors without blocking unrelated leads - [x] #5 Leads discovered by Google Places with a website are automatically scheduled for contact enrichment before they remain in Kontakt fehlt; found usable email updates the lead contact fields and status while preserving phone and source data
- [x] #6 Simple technical checks include HTTPS/final URL, missing title/meta description, visible contact path, and obvious broken internal links within the crawl limit
- [x] #7 Crawler failures produce useful dashboard-visible errors without blocking unrelated leads
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Add Playwright runtime setup compatible with local development and Coolify container deployment. 1. Worker A: add pure crawler/extraction helpers with RED/GREEN tests.
2. Define crawl limits, viewports, timeout behavior, and allowed same-domain URL rules. 2. Worker B: add Convex schema/run/storage persistence with RED/GREEN tests.
3. Capture homepage desktop/mobile screenshots and upload to Convex storage. 3. Worker C: wire lead-discovery scheduling/contact update flow with RED/GREEN tests.
4. Discover and inspect relevant subpages with bounded depth. 4. Worker D: add dashboard-visible enrichment state/error UI with RED/GREEN tests where practical.
5. Persist extracted text, metadata, contact candidates, technical checks, screenshots, and errors. 5. Orchestrator: run spec review, code-quality review, full verification, and update acceptance criteria without marking Done.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Expanded TASK-8 to cover website-based contact enrichment because Google Places does not provide business email fields. This keeps email handling evidence-based and reuses TASK-7 qualification rules instead of guessing addresses.
Orchestration started on branch codex-task-8-playwright-enrichment. Parallel wave 1 dispatched with gpt-5.3-codex-spark: Worker A owns lib/website-crawler.ts + tests/website-crawler.test.ts; Worker B owns convex/schema.ts + schema tests; Worker C owns Playwright package/runtime docs. All workers instructed to use TDD or config verification and avoid unrelated changes.
Completed wave 1 foundations: Playwright runtime/docs approved; crawler helper spec+quality approved; Convex enrichment schema/run-type parity spec+quality approved. Wave 2 dispatched with gpt-5.3-codex-spark: Worker D owns convex/websiteEnrichment.ts action/persistence; Worker E owns lead-discovery scheduling integration. Orchestrator remains code-review/integration only.
2026-06-04: Worker D started implementing convex/websiteEnrichment.ts with unit/source tests for queue/process/persist enrichment flow and Playwright evidence capture.
2026-06-04: Added TASK-8 source tests for website-enrichment action queue/process/persistence contract and confirmed all assertions pass with existing implementation.
Worker G retry: moved website enrichment scheduling out of persistDiscoveredLeads into processCampaignRun (returns queue items), scoped startCampaignRun active checks to by_type_and_status campaign running, and added source assertions for this sequencing.
Implementation complete pending user confirmation. Built Playwright Chromium website enrichment with bounded crawl, desktop/mobile screenshot storage, raw evidence tables, TASK-7 email qualification reuse, post-discovery scheduling, technical checks, and dashboard-visible run events/errors. Final verification passed: pnpm exec tsc -p tsconfig.json; pnpm test (105/105); pnpm lint (0 errors, existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
2026-06-04: Updated source tests/README/.env for TASK-8 browser-runtime strategy migration to @sparticuz/chromium-min and TASK8_BROWSER_ASSET_URL deployment expectations.
Resolved Convex Playwright runtime follow-up: local npx playwright install only populates the developer machine cache, not Convex runtime. Full playwright was replaced with playwright-core + @sparticuz/chromium-min and a required TASK8_BROWSER_ASSET_URL source so Convex no longer relies on /home/sbx_user ms-playwright cache. Verification passed: pnpm exec tsc -p tsconfig.json; pnpm test; pnpm lint (existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable.
TASK-21 runtime cache fix applied to TASK-8 crawler action: stale @sparticuz/chromium-min /tmp cache is invalidated when browser asset source changes, addressing repeated /tmp/chromium cannot execute binary file after x64/arm64 URL changes.
TASK-8 crawler action now explicitly prepares @sparticuz/chromium-min AL2023 shared libraries for Convex to address /tmp/chromium libnspr4.so missing errors before screenshot/crawl launch.
TASK-23 extractor improvement applied: website enrichment now extracts published emails from mailto links with query params, common German obfuscations, HTML entities/spaced separators, and footer/impressum/contact contexts while preserving TASK-7 no-guessing rules.
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 -->

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-9 id: TASK-9
title: Integrate PageSpeed Insights into internal audits title: Integrate PageSpeed Insights into internal audits
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-06-03 19:13' created_date: '2026-06-03 19:13'
updated_date: '2026-06-04 20:12'
labels: labels:
- mvp - mvp
- audit - audit
@@ -24,19 +25,55 @@ Add Google PageSpeed Insights as an objective internal audit signal. The system
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 PageSpeed API runs for mobile and desktop strategies for qualified website leads - [x] #1 PageSpeed API runs for mobile and desktop strategies for qualified website leads
- [ ] #2 Raw PageSpeed/Lighthouse response data is stored internally in Convex - [x] #2 Raw PageSpeed/Lighthouse response data is stored internally in Convex
- [ ] #3 Key metrics are normalized for downstream analysis without exposing scores on customer audit pages - [x] #3 Key metrics are normalized for downstream analysis without exposing scores on customer audit pages
- [ ] #4 Failures, quota errors, and unavailable pages are recorded without failing the entire audit pipeline - [x] #4 Failures, quota errors, and unavailable pages are recorded without failing the entire audit pipeline
- [ ] #5 Generated audit inputs translate technical signals into customer-impact language for later text generation - [x] #5 Generated audit inputs translate technical signals into customer-impact language for later text generation
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
<!-- SECTION:PLAN:BEGIN --> <!-- SECTION:PLAN:BEGIN -->
1. Add PageSpeed API client using environment/Convex secrets. 1. Worker A: write RED/GREEN pure PageSpeed client and normalization tests plus implementation in lib/pagespeed-insights.ts.
2. Run mobile and desktop analysis for the lead domain or final URL. 2. Worker B: write RED/GREEN Convex schema and persistence contract tests plus pageSpeed mutation module.
3. Normalize key findings such as load speed, mobile/desktop gap, SEO, accessibility, and best-practice hints. 3. Worker C: write RED/GREEN PageSpeed action queue/process source tests plus Node action implementation.
4. Store raw and normalized results in Convex. 4. Worker D: write RED/GREEN audit input/public-safety tests plus internal plain-language audit input helper.
5. Add error handling and dashboard-visible status for quota, timeout, and API failures. 5. Orchestrator: run integration verification, resolve conflicts via agents, update acceptance criteria, and leave TASK-9 open for user confirmation.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-06-04: Implementation started on branch codex-task-9-pagespeed-insights. Wave 1 dispatched with gpt-5.3-codex-spark: Worker A owns lib/pagespeed-insights.ts + tests/pagespeed-insights.test.ts; Worker B owns Convex PageSpeed schema/persistence contracts in convex/schema.ts, convex/domain.ts, convex/pageSpeed.ts, and related tests. Orchestrator remains coordination/review only.
2026-06-04T19:40Z: Implemented and validated lib/pagespeed-insights.ts with request URL builder, normalizer, fetch helper, and error classifier. Added tests/pagespeed-insights.test.ts with RED→GREEN coverage (URL contract, normalization, error classification, injected fetch, offline-only assertions).
Wave 1 complete: Worker A delivered pure PageSpeed URL/client/normalizer tests and implementation; Worker B delivered Convex pageSpeedResults schema and internal persistence queue/start/persist/finish module. Worker A concern noted: targeted pnpm test args are incompatible with project script, but isolated compiled test and tsconfig.test passed.
2026-06-04T19:50Z: Worker D taking subtask for TDD implementation of lib/pagespeed-audit-input.ts and tests/pagespeed-audit-input.test.ts (score-free German customer-implication generator).
Implementation complete pending user confirmation. Built PageSpeed API client/normalizer, Convex pageSpeedResults raw-storage persistence, internal audit run queue/action for mobile+desktop, post-website-enrichment scheduling, per-strategy error recording, raw payload size guard, score-free audit input translator, and public-output sanitization. Review findings addressed: malformed JSON 200 responses now fail as api_error; PageSpeed action has outer failure guard; oversized raw payloads fail per strategy; audit inputs strip URLs/markup/JSON/raw score artifacts. Final verification passed: pnpm test (155/155); pnpm exec tsc -p tsconfig.json --pretty false; pnpm lint (0 errors, existing generated BetterAuth warnings only); pnpm exec convex codegen --dry-run --typecheck enable (rerun outside sandbox after DNS ENOTFOUND). TASK-9 remains In Progress until user confirms manual acceptance.
2026-06-04: Follow-up from manual test: PageSpeed failed with generic api_error summary "PageSpeed-API lieferte einen Fehler." Root cause at diagnostics layer: HTTP 4xx/5xx classifier discarded Google error.message/error_message/runtimeError details. Added RED/GREEN regression coverage and now preserves Google API error messages in PageSpeedError summaries. Verification passed: pnpm test (156/156); pnpm exec tsc -p tsconfig.json --pretty false; pnpm lint (0 errors, existing generated BetterAuth warnings only).
2026-06-04: Manual test follow-up: after API key renewal, PageSpeed failed with timeout. Root cause: convex/pageSpeedAction.ts used hardcoded 10_000ms timeout, too short for PageSpeed Insights. Added PAGESPEED_TIMEOUT_MS env support with 60_000ms default and 10_000-120_000 clamp; fetchPageSpeedResult now receives resolved timeout. Updated README/.env.example. Verification passed: pnpm test (159/159); pnpm exec tsc -p tsconfig.json --pretty false; pnpm lint (0 errors, existing generated BetterAuth warnings only).
2026-06-04: Systematic debugging follow-up for recurring PageSpeed timeout/unknown reports. Root cause after increasing timeout was not another HTTP timeout: PageSpeed responses reached the action, but Convex rejected the success payload because `normalized` included extra normalizer-only fields (`strategy`, `sourceUrl`, `finalUrl`, `analysisTimestamp`) not allowed by the persistPageSpeedResult validator. Spark Worker A added `toPersistedPageSpeedNormalizedResult` in convex/pageSpeedAction.ts and success persistence now stores only `scores`, `metrics`, `opportunities`, and `implications`, with `finalUrl` kept top-level. Spark Reviewer B confirmed the mapping matches convex/pageSpeed.ts and convex/schema.ts. Verification passed: pnpm test (160/160), pnpm exec tsc -p tsconfig.json --pretty false, pnpm lint (0 errors, two pre-existing generated BetterAuth warnings), pnpm exec convex dev --once --typecheck enable. Real dev retry for lead jx7cnezm2xg7b2xr2gfmqyeg5h881m2d on run j972t5ra323rgax4a7ycsbrtzd881m8n confirmed desktop persisted successfully with raw storage and normalized keys [implications, metrics, opportunities, scores]; mobile failed separately with a genuine Google/Lighthouse api_error: "Lighthouse returned error: Something went wrong." A subsequent clean rerun could not start because the lead had been manually deleted during Convex cleanup. TASK-9 remains In Progress pending user manual acceptance.
2026-06-04: Manual retest update from user: mobile PageSpeed produced one transient api_error ("Lighthouse returned error: Something went wrong.") and succeeded three times. This confirms the previous timeout/unknown/validator failure is no longer recurring; remaining failure mode is an intermittent Google/Lighthouse strategy-level error that is recorded without breaking the pipeline. TASK-9 remains In Progress until explicit user confirmation to close.
2026-06-04: Follow-up opened from user manual testing: PageSpeed should still be triggered when website enrichment fails but the lead has a website URL. Initial trace shows current queueLeadPageSpeedAudit call is only in the successful enrichment path after persistence; fatal failure paths finish/patch the website enrichment run without queueing PageSpeed. Keeping TASK-9 In Progress.
Started minimal PAGE-SPEED queueing fix for processLeadEnrichment failure paths; targeting invalid-URL guard + outer catch to queue PageSpeed before return and keep existing success queue/warn semantics.
Implemented PASS for processLeadEnrichment missing failure-path queueing: added queue+warning fallback in !rootUrl branch and fatal outer catch when started exists; kept success path queue behavior. Verified with: pnpm exec tsc -p tsconfig.test.json && pnpm exec node --test .test-output/tests/website-enrichment-action.test.js (20 pass).
2026-06-04: Follow-up fixed after manual finding that PageSpeed was not triggered when website enrichment failed. Added RED regression tests for both failure paths in tests/website-enrichment-action.test.ts: invalid URL failure and fatal catch path must queue internal.pageSpeed.queueLeadPageSpeedAudit with leadId started.lead._id and parentRunId runId before returning. Spark GREEN worker updated convex/websiteEnrichmentAction.ts so invalid-url and fatal failure paths queue PageSpeed with warning-safe handling; success path remains queued before success finish. Refactor pass restored guard-style structure and fixed test helper source parameter usage. Verification passed: pnpm test (162/162), pnpm exec tsc -p tsconfig.json --pretty false, pnpm lint (0 errors, two pre-existing generated BetterAuth warnings), pnpm exec convex dev --once --typecheck enable. TASK-9 remains In Progress pending manual acceptance.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Integrated Google PageSpeed Insights into the internal audit pipeline. Added mobile and desktop PageSpeed queue/action processing, raw Convex storage, normalized metrics, score-free customer-impact audit inputs, resilient per-strategy failure recording, API diagnostics, configurable timeout, and follow-up fixes from manual testing: persisted normalized payload shape now matches Convex validators and PageSpeed is triggered even when website enrichment fails for a lead with a website URL. Verified with pnpm test, TypeScript, lint, Convex dev deploy, and user manual retests.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,219 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { Activity, Filter, MousePointerClick } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
const metricLabels: Record<string, string> = {
foundLeads: "Gefundene Leads",
leadsWithContact: "Mit Kontakt",
missingContact: "Kontakt fehlt",
auditsCreated: "Audits erstellt",
approvalsOpen: "Freigaben offen",
emailsSent: "E-Mails gesendet",
followUpsPlanned: "Follow-ups geplant",
followUpsSent: "Follow-ups gesendet",
responses: "Antworten",
conversations: "Gespräche",
offers: "Angebote",
wins: "Gewonnen",
losses: "Verloren",
skippedDuplicates: "Duplikate übersprungen",
skippedBlacklisted: "Sperrliste übersprungen",
rybbitAuditOpens: "Audit-Öffnungen",
rybbitCtaClicks: "CTA-Klicks",
};
export function AnalyticsDashboard() {
const dashboard = useQuery(api.campaignMetrics.getDashboard, { limit: 20 });
const [rybbitData, setRybbitData] = useState<{
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
byPath?: Record<string, {
auditOpens: number;
ctaClicks: number;
outboundClicks: number;
}>;
} | null>(null);
const [rybbitError, setRybbitError] = useState<string | null>(null);
const metricEntries = useMemo(() => {
if (!dashboard) {
return [];
}
return Object.entries(dashboard.metrics).filter(([key]) => key in metricLabels);
}, [dashboard]);
const rybbitGroups = useMemo(() => {
if (!dashboard || !rybbitData?.byPath) {
return [];
}
const grouped = new Map<string, { label: string; auditOpens: number; ctaClicks: number }>();
for (const segment of dashboard.auditSegments) {
const metrics = rybbitData.byPath[segment.path];
if (!metrics) {
continue;
}
for (const [kind, label] of [
["Kampagne", segment.campaignName],
["Nische", segment.niche],
["Region", segment.region],
] as const) {
const key = `${kind}:${label}`;
const current = grouped.get(key) ?? {
label: `${kind}: ${label}`,
auditOpens: 0,
ctaClicks: 0,
};
current.auditOpens += metrics.auditOpens;
current.ctaClicks += metrics.ctaClicks;
grouped.set(key, current);
}
}
return [...grouped.values()].slice(0, 8);
}, [dashboard, rybbitData]);
useEffect(() => {
let isMounted = true;
fetch("/api/internal/rybbit/campaign")
.then(async (response) => {
const payload = await response.json();
if (!isMounted) {
return;
}
if (!payload.ok) {
setRybbitError("Rybbit-Daten konnten nicht geladen werden.");
}
setRybbitData(payload.data ?? null);
})
.catch(() => {
if (isMounted) {
setRybbitError("Rybbit-Daten konnten nicht geladen werden.");
}
});
return () => {
isMounted = false;
};
}, []);
if (dashboard === undefined) {
return (
<section className="space-y-4">
<Skeleton className="h-24 rounded-lg" />
<Skeleton className="h-64 rounded-lg" />
</section>
);
}
return (
<section className="space-y-4">
<header className="border-b pb-3">
<p className="text-sm text-muted-foreground">Kampagnen-Reporting</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">Analytics</h1>
</header>
<Card>
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<Filter className="size-5" />
Filter
</CardTitle>
<CardDescription>
Kampagne, Nische, PLZ, Radius, Priorität, Status und Zeitraum.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-4">
<p>Kampagne: {dashboard.filters.campaigns.length}</p>
<p>Nische: {dashboard.filters.niches.length}</p>
<p>PLZ: {dashboard.filters.postalCodes.length}</p>
<p>Radius: Kampagnenradius</p>
<p>Priorität: Hoch/Mittel/Niedrig</p>
<p>Status: Funnel-Status</p>
<p>Zeitraum: Erstellungsdatum</p>
</CardContent>
</Card>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{metricEntries.map(([key, value]) => (
<Card key={key}>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">{metricLabels[key]}</p>
<p className="mt-2 text-2xl font-semibold">{value}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.7fr)]">
<Card>
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<Activity className="size-5" />
Run-Details
</CardTitle>
<CardDescription>
Neue Leads, übersprungene Duplikate, Sperrliste, Fehler und erzeugte Audits.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm">
{dashboard.runs.length === 0 ? (
<p className="text-muted-foreground">Noch keine Kampagnenläufe.</p>
) : (
dashboard.runs.map((run) => (
<div className="rounded-md border p-3" key={run.id}>
<div className="flex flex-wrap justify-between gap-2">
<p className="font-medium">{run.status}</p>
<p className="text-muted-foreground">
Leads {run.newLeads} · Audits {run.auditsGenerated} · Fehler {run.errors}
</p>
</div>
{run.errorSummary ? (
<p className="mt-1 text-xs text-destructive">{run.errorSummary}</p>
) : null}
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="inline-flex items-center gap-2">
<MousePointerClick className="size-5" />
Rybbit
</CardTitle>
<CardDescription>
Audit-Öffnungen und CTA-Aktivität werden bei Bedarf aus der Rybbit API geladen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>Rybbit-Daten konnten nicht geladen werden, wenn API-URL, Site-ID oder API-Key fehlen.</p>
{rybbitError ? <p className="text-destructive">{rybbitError}</p> : null}
<p>Audit-Öffnungen: {rybbitData?.auditOpens ?? dashboard.metrics.rybbitAuditOpens}</p>
<p>CTA-Klicks: {rybbitData?.ctaClicks ?? dashboard.metrics.rybbitCtaClicks}</p>
<p>Website-Link-Klicks: {rybbitData?.outboundClicks ?? 0}</p>
{rybbitGroups.length > 0 ? (
<div className="space-y-1 pt-2">
{rybbitGroups.map((group) => (
<p key={group.label}>
{group.label}: {group.auditOpens} Öffnungen · {group.ctaClicks} CTA
</p>
))}
</div>
) : null}
<p>Public-Audit Tracking läuft nur auf veröffentlichten Audit-Seiten.</p>
</CardContent>
</Card>
</div>
</section>
);
}

View File

@@ -0,0 +1,451 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "convex/react";
import type { Id } from "@/convex/_generated/dataModel";
import { api } from "@/convex/_generated/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Globe } from "lucide-react";
type UsedSkill = {
id?: string;
name: string;
purpose?: string;
category?: string;
source?: string;
version?: string;
};
type SkillSummary = {
name: string;
purpose: string;
summary: string;
};
type LeadContext = {
_id: Id<"leads">;
companyName?: string;
websiteDomain?: string;
websiteUrl?: string;
city?: string;
niche?: string;
};
type SkillAwareAudit = {
_id: Id<"audits">;
slug: string;
checkedDomain: string;
status: "draft" | "approved" | "published" | "deactivated";
checkedPages: string[];
createdAt?: number;
updatedAt?: number;
usedSkills?: UsedSkill[];
skillSummaries?: SkillSummary[];
internalSummary?: string | null;
};
type AuditFindingEvidenceRef = {
id: string;
type:
| "crawl_page"
| "technical_check"
| "screenshot"
| "pagespeed"
| "jina_excerpt"
| "generation_stage";
label: string;
sourceUrl?: string;
};
type AuditFinding = {
_id: string;
skillId: string;
claim: string;
recommendation: string;
customerBenefit: string;
severity: 1 | 2 | 3;
confidence: number;
evidenceRefs: AuditFindingEvidenceRef[];
reviewStatus: "pending" | "accepted" | "rejected";
};
type CheckedPageScreenshot = {
id: Id<"_storage">;
url: string;
viewport: "desktop" | "mobile";
sourceUrl: string;
width: number;
height: number;
createdAt: number;
};
type CheckedPageEvidence = {
url: string;
sourceUrl: string | null;
finalUrl: string | null;
pageKind: string | null;
title: string | null;
metaDescription: string | null;
headings: string[];
visibleTextExcerpt: string | null;
hasContactFormSignal: boolean | null;
hasContactCtaSignal: boolean | null;
usesHttps: boolean | null;
missingMetaDescription: boolean | null;
brokenInternalLinkCount: number | null;
screenshots: CheckedPageScreenshot[];
createdAt: number | null;
};
type AuditDetailResult = {
audit: SkillAwareAudit;
lead: LeadContext | null;
findings: AuditFinding[];
sourceSummaries: {
checkedPages: CheckedPageEvidence[];
};
} | null;
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
function getStatusLabel(status: SkillAwareAudit["status"]) {
return statusText[status] ?? "Unbekannt";
}
function getPageKindLabel(pageKind: string | null) {
const labels: Record<string, string> = {
contact: "Kontakt",
homepage: "Startseite",
imprint: "Impressum",
other: "Unterseite",
service: "Leistung",
};
return pageKind ? labels[pageKind] ?? pageKind : "Geprüft";
}
function signalText(value: boolean | null, positive: string, negative: string) {
if (value === null) {
return "Unbekannt";
}
return value ? positive : negative;
}
function metaSignalText(page: CheckedPageEvidence) {
if (page.metaDescription) {
return "Vorhanden";
}
if (page.missingMetaDescription === true) {
return "Fehlt";
}
if (page.missingMetaDescription === false) {
return "Vorhanden";
}
return "Unbekannt";
}
function evidenceTypeLabel(type: AuditFindingEvidenceRef["type"]) {
const labels: Record<AuditFindingEvidenceRef["type"], string> = {
crawl_page: "Crawl",
technical_check: "Technik",
screenshot: "Screenshot",
pagespeed: "PageSpeed",
jina_excerpt: "Reader",
generation_stage: "KI-Stufe",
};
return labels[type] ?? type;
}
function leadSummary(lead: LeadContext | null | undefined) {
if (!lead) {
return "Kein Lead-Kontext gespeichert";
}
const detail = [lead.city, lead.niche].filter(Boolean).join(" • ");
let leadDomain = lead.websiteDomain ?? "—";
if (!leadDomain && lead.websiteUrl) {
try {
leadDomain = new URL(lead.websiteUrl).hostname;
} catch {
leadDomain = lead.websiteUrl;
}
}
return (
<>
<p className="font-medium">{lead.companyName ?? "Lead ohne Name"}</p>
<p className="text-sm text-muted-foreground">{detail || "Kein Kontext textlich"}</p>
<p className="mt-1 inline-flex items-center gap-1 text-sm text-muted-foreground">
<Globe className="size-3.5" />
{leadDomain}
</p>
</>
);
}
export function AuditDetail({ id }: { id: string | Id<"audits"> }) {
const result = useQuery(api.audits.getDetail, {
id: id as Id<"audits">,
}) as AuditDetailResult | undefined;
const audit = result?.audit;
const lead = result?.lead;
const usedSkills = useMemo(() => {
const summaries = audit?.skillSummaries ?? [];
return (audit?.usedSkills ?? []).map((skill) => {
const summary = summaries.find(
(candidate) => candidate.name === skill.name || candidate.name === skill.id,
);
return {
...skill,
purpose: summary?.purpose ?? skill.purpose,
summary: summary?.summary,
};
});
}, [audit]);
const findings = useMemo(() => result?.findings ?? [], [result]);
const checkedPageEvidence = useMemo(
() => result?.sourceSummaries.checkedPages ?? [],
[result],
);
if (result === null) {
return (
<Card>
<CardHeader>
<CardTitle>Audit nicht gefunden</CardTitle>
<CardDescription>
Der gewünschte Audit-Datensatz konnte nicht geladen werden.
</CardDescription>
</CardHeader>
</Card>
);
}
if (audit === undefined) {
return (
<Card>
<CardHeader>
<CardTitle>Audit wird geladen...</CardTitle>
</CardHeader>
</Card>
);
}
return (
<div className="grid gap-4">
<Card>
<CardHeader>
<CardDescription>Audit-Detail</CardDescription>
<CardTitle className="text-xl">#{audit.slug}</CardTitle>
<p className="inline-flex max-w-full items-center gap-1 truncate text-sm text-muted-foreground">
<Globe className="size-3.5" />
{audit.checkedDomain}
</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Seitenanzahl</p>
<p>{audit.checkedPages.length}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Lead-Kontext</p>
<div className="text-sm">{leadSummary(lead)}</div>
</div>
{audit.internalSummary ? (
<div>
<p className="text-sm text-muted-foreground">Interne Notiz</p>
<p className="text-sm text-muted-foreground">{audit.internalSummary}</p>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Geprüfte Befunde</CardTitle>
<CardDescription>
Verifizierte Aussagen mit konkreten Belegen aus Crawl, Screenshots und Messungen.
</CardDescription>
</CardHeader>
<CardContent>
{findings.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine verifizierten Befunde gespeichert.
</p>
) : (
<ul className="grid gap-3">
{findings.map((finding) => (
<li className="rounded-md border p-3 text-sm" key={finding._id}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium">{finding.claim}</p>
<p className="mt-2 text-muted-foreground">
{finding.customerBenefit}
</p>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant={finding.severity === 3 ? "secondary" : "outline"}>
Priorität {finding.severity}
</Badge>
<Badge variant="outline">
{Math.round(finding.confidence * 100)}% sicher
</Badge>
</div>
</div>
<p className="mt-3">
<span className="font-medium">Empfehlung: </span>
{finding.recommendation}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{finding.evidenceRefs.map((ref) => (
<Badge variant="outline" key={ref.id}>
Quelle: {evidenceTypeLabel(ref.type)} · {ref.label}
</Badge>
))}
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Geprüfte Seiten</CardTitle>
<CardDescription>
Kompakte Evidence aus Website-Enrichment und Screenshot-Erfassung.
</CardDescription>
</CardHeader>
<CardContent>
{checkedPageEvidence.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Seiten-Evidence gespeichert</p>
) : (
<ul className="grid gap-3">
{checkedPageEvidence.map((page, index) => (
<li
className="rounded-md border p-3 text-sm"
key={`${page.url}-${index}`}
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="break-words font-medium">
{page.title ?? page.finalUrl ?? page.sourceUrl ?? page.url}
</p>
<p className="mt-1 break-all text-xs text-muted-foreground">
{page.finalUrl ?? page.sourceUrl ?? page.url}
</p>
</div>
<Badge variant="outline">{getPageKindLabel(page.pageKind)}</Badge>
</div>
{page.visibleTextExcerpt ? (
<p className="mt-3 line-clamp-3 text-sm text-muted-foreground">
{page.visibleTextExcerpt}
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant={page.missingMetaDescription === true ? "secondary" : "outline"}>
Meta: {metaSignalText(page)}
</Badge>
<Badge variant="outline">
Kontaktformular:{" "}
{signalText(page.hasContactFormSignal, "Signal", "Kein Signal")}
</Badge>
<Badge variant="outline">
CTA: {signalText(page.hasContactCtaSignal, "Signal", "Kein Signal")}
</Badge>
<Badge variant="outline">
Interne Links: {page.brokenInternalLinkCount ?? "Unbekannt"}
</Badge>
</div>
{page.screenshots.length > 0 ? (
<div className="mt-3 grid gap-2 sm:grid-cols-2">
{page.screenshots.map((screenshot) => (
<figure
className="overflow-hidden rounded-md border bg-muted/20"
key={`${screenshot.id}-${screenshot.viewport}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={screenshot.url}
alt={`${screenshot.viewport === "desktop" ? "Desktop" : "Mobile"} Screenshot von ${screenshot.sourceUrl}`}
width={screenshot.width}
height={screenshot.height}
className="aspect-[16/10] w-full object-cover"
/>
<figcaption className="flex items-center justify-between gap-2 border-t px-2 py-1 text-xs text-muted-foreground">
<span className="font-medium">
{screenshot.viewport === "desktop" ? "Desktop" : "Mobil"}
</span>
<span className="truncate">{screenshot.sourceUrl}</span>
</figcaption>
</figure>
))}
</div>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Verwendete Skills</CardTitle>
<CardDescription>Skills, die an diesem Audit beteiligt wurden.</CardDescription>
</CardHeader>
<CardContent>
{usedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Skills gespeichert</p>
) : (
<ul className="grid gap-2">
{usedSkills.map((skill, index) => (
<li
className="rounded-md border p-2 text-sm"
key={`${skill.name}-${index}`}
>
<p className="font-medium">{skill.name}</p>
<p className="text-sm text-muted-foreground">
{skill.purpose ?? "Keine Zweckbeschreibung"}
</p>
{"summary" in skill && skill.summary ? (
<p className="text-sm text-muted-foreground">{skill.summary}</p>
) : null}
<p className="mt-1 inline-flex flex-wrap items-center gap-1">
{skill.category ? <Badge variant="outline">{skill.category}</Badge> : null}
{skill.version ? <Badge variant="outline">{skill.version}</Badge> : null}
{skill.source ? <span className="text-xs text-muted-foreground">{skill.source}</span> : null}
</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,292 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Activity, Files, SquarePen } from "lucide-react";
import Link from "next/link";
import { api } from "@/convex/_generated/api";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
type AuditRow = Extract<
NonNullable<AuditDashboardRowsResult>[number],
{ kind: "audit" }
>;
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
type AuditStatusFilter = "all" | "audit" | "generation" | "failed";
const statusText: Record<string, string> = {
draft: "Entwurf",
approved: "Freigegeben",
published: "Veröffentlicht",
deactivated: "Deaktiviert",
};
const fallbackStatus = "Unbekannt";
const generationStageText: Record<string, string> = {
audit_generation: "Audit-Generierung",
classification: "Klassifikation",
multimodalAudit: "Multimodale Analyse",
germanCopy: "Deutsche Texte",
qualityReview: "Qualitätsprüfung",
};
function formatPageCount(pageCount: number) {
return `${pageCount} Seite${pageCount === 1 ? "" : "n"}`;
}
function getStatusLabel(status: AuditRow["status"]) {
return statusText[status] ?? fallbackStatus;
}
function getGenerationStatusLabel(
row: Extract<AuditDashboardRow, { kind: "generation" }>,
) {
if (row.status === "pending") {
return "Wartet auf Start";
}
if (row.status === "failed") {
return "Fehlgeschlagen";
}
if (row.status === "canceled") {
return "Abgebrochen";
}
if (row.status === "succeeded") {
return "Wartet auf finales Audit";
}
return "Generierung läuft";
}
function getStageLabel(stage: string) {
return generationStageText[stage] ?? stage;
}
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>
<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">
{Array.from({ length: 4 }, (_, index) => (
<Skeleton className="h-40 rounded-lg" key={index} />
))}
</div>
</section>
);
}
export function AuditsBoard() {
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
const [activeFilter, setActiveFilter] = useState<AuditStatusFilter>("all");
const rows = useMemo(() => {
if (!dashboardRows) {
return [];
}
return [...dashboardRows].sort((a, b) => b.updatedAt - a.updatedAt);
}, [dashboardRows]);
const statusCounts = useMemo(() => {
return {
all: rows.length,
audit: rows.filter((row) => row.kind === "audit").length,
generation: rows.filter((row) => row.kind === "generation").length,
failed: rows.filter(
(row) => row.kind === "generation" && row.status === "failed",
).length,
};
}, [rows]);
const visibleRows = useMemo(() => {
if (activeFilter === "audit") {
return rows.filter((row) => row.kind === "audit");
}
if (activeFilter === "generation") {
return rows.filter((row) => row.kind === "generation");
}
if (activeFilter === "failed") {
return rows.filter(
(row) => row.kind === "generation" && row.status === "failed",
);
}
return rows;
}, [activeFilter, rows]);
const auditStatusFilters: Array<{
label: string;
value: AuditStatusFilter;
count: number;
}> = [
{ label: "Alle", value: "all", count: statusCounts.all },
{ label: "Audits", value: "audit", count: statusCounts.audit },
{ label: "Pipeline", value: "generation", count: statusCounts.generation },
{ label: "Fehlgeschlagen", value: "failed", count: statusCounts.failed },
];
if (dashboardRows === undefined) {
return <AuditsBoardLoading />;
}
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>
<Card>
<CardHeader>
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<CardDescription>
Sobald neue Audits oder laufende Audit-Generierungen angelegt
wurden, erscheinen sie hier als kompakte Cards.
</CardDescription>
</CardHeader>
</Card>
</section>
);
}
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>
<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"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<section
className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
aria-label="Audit-Cards"
>
{visibleRows.map((row: AuditDashboardRow) => {
const rowTitleId = `audit-row-title-${row.id}`;
return (
<Card
aria-labelledby={rowTitleId}
className="flex min-w-0 flex-col"
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>
<CardTitle className="mt-1 break-words text-base" id={rowTitleId}>
{row.title}
</CardTitle>
</div>
<Badge variant={row.kind === "audit" ? "secondary" : "outline"}>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="grid gap-3 text-sm">
<div className="min-w-0">
<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">
<p className="text-xs font-medium text-muted-foreground">
{row.kind === "audit" ? "Seiten" : "Phase"}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-muted-foreground">
{row.kind === "audit" ? (
<>
<Files className="size-3.5" aria-hidden="true" />
{formatPageCount(row.pageCount)}
</>
) : (
<>
<Activity className="size-3.5" aria-hidden="true" />
{getStageLabel(row.latestStage)}
</>
)}
</p>
</div>
<div className="min-w-0">
<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">
{row.errorSummary}
</p>
) : null}
</div>
<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"
href={row.detailHref}
>
<SquarePen className="size-4" aria-hidden="true" />
Öffnen
</Link>
) : (
<span className="inline-flex min-h-8 items-center text-sm text-muted-foreground">
Pipeline läuft
</span>
)}
</div>
</CardContent>
</Card>
);
})}
{visibleRows.length === 0 ? (
<Card className="sm:col-span-2 xl:col-span-3">
<CardHeader>
<CardTitle>Keine Treffer</CardTitle>
<CardDescription>
Für diesen Filter gibt es aktuell keine Audit-Einträge.
</CardDescription>
</CardHeader>
</Card>
) : null}
</section>
</section>
);
}

View File

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

View File

@@ -0,0 +1,407 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useMemo, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod/v4";
import {
campaignFormDefaults,
campaignFormSchema,
mapCampaignFormToPayload,
} from "@/lib/campaign-form";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
type CampaignFormValues = z.infer<typeof campaignFormSchema>;
type CampaignFormSeed = Partial<CampaignFormValues> & {
_id?: string;
};
type CampaignFormDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
campaign?: CampaignFormSeed | null;
onSubmit: (
payload: Omit<CampaignFormValues, "status"> &
Required<Pick<CampaignFormValues, "status">> & {
countryCode: "DE";
country: "Deutschland";
},
) => Promise<void>;
};
const categoryOptions = [
"Anwalt",
"Bauunternehmen",
"Friseur",
"Gastronomie",
"Handwerk",
"Immobilien",
"Kfz-Werkstatt",
"Marketing",
"Restaurant",
"Zahnarzt",
"Anderes",
] as const;
const recurrenceOptions: Record<CampaignFormValues["recurrence"], string> = {
manual: "manuell",
daily: "täglich",
weekly: "wöchentlich",
monthly: "monatlich",
};
const statusLabel: Record<CampaignFormValues["status"], string> = {
active: "Aktiv",
paused: "Pausiert",
};
const customCategoryValue = "Anderes";
export function CampaignFormDialog({
open,
onOpenChange,
campaign,
onSubmit,
}: CampaignFormDialogProps) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const methods = useForm<CampaignFormValues>({
resolver: zodResolver(campaignFormSchema),
defaultValues: campaignFormDefaults,
});
const {
control,
reset,
setValue,
formState: { isSubmitting },
} = methods;
const selectedCategory = useWatch({
control,
name: "category",
defaultValue: campaignFormDefaults.category,
});
const selectedStatus = useWatch({
control,
name: "status",
defaultValue: campaignFormDefaults.status,
});
const showCustomSearch = selectedCategory === customCategoryValue;
useEffect(() => {
const defaults = campaign
? {
status: campaign.status ?? campaignFormDefaults.status,
categoryMode: campaign.categoryMode ?? campaignFormDefaults.categoryMode,
recurrence: campaign.recurrence ?? campaignFormDefaults.recurrence,
radiusKm: campaign.radiusKm ?? campaignFormDefaults.radiusKm,
maxNewLeadsPerRun:
campaign.maxNewLeadsPerRun ?? campaignFormDefaults.maxNewLeadsPerRun,
maxAuditsPerRun:
campaign.maxAuditsPerRun ?? campaignFormDefaults.maxAuditsPerRun,
name: campaign.name ?? "",
category: campaign.category ?? "",
customSearchTerm: campaign.customSearchTerm ?? "",
postalCode: campaign.postalCode ?? campaignFormDefaults.postalCode,
}
: campaignFormDefaults;
reset(defaults);
}, [campaign, reset]);
useEffect(() => {
if (showCustomSearch) {
setValue("categoryMode", "custom");
return;
}
setValue("categoryMode", "preset");
setValue("customSearchTerm", "");
}, [showCustomSearch, setValue]);
const dialogTitle = useMemo(
() => (campaign ? "Kampagne bearbeiten" : "Kampagne anlegen"),
[campaign],
);
const submitLabel = useMemo(
() => (campaign ? "Speichern" : "Erstellen"),
[campaign],
);
const submitForm = async (values: CampaignFormValues) => {
setPending(true);
setError(null);
try {
const payload = mapCampaignFormToPayload(values as Record<string, unknown>);
await onSubmit({
...payload,
status: values.status,
categoryMode: values.categoryMode,
category: values.category,
customSearchTerm: values.customSearchTerm || undefined,
postalCode: values.postalCode,
radiusKm: values.radiusKm,
recurrence: values.recurrence,
maxNewLeadsPerRun: values.maxNewLeadsPerRun,
maxAuditsPerRun: values.maxAuditsPerRun,
name: values.name,
});
onOpenChange(false);
} catch {
setError("Speichern fehlgeschlagen.");
} finally {
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>
Wähle Kategorie, PLZ, Radius und Limits je Kampagne.
</DialogDescription>
<DialogCloseButton />
</DialogHeader>
<Form form={methods} onSubmit={submitForm}>
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="category"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOptions.map((category) => (
<SelectItem value={category} key={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{showCustomSearch && (
<FormField
control={control}
name="customSearchTerm"
render={({ field }) => (
<FormItem>
<FormLabel>Eigene Nische</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder="Beispiel: Webdesigner für Restaurants"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={control}
name="postalCode"
render={({ field }) => (
<FormItem>
<FormLabel>PLZ</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
inputMode="numeric"
maxLength={5}
placeholder="10115"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="radiusKm"
render={({ field }) => (
<FormItem>
<FormLabel>Radius (km)</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
step={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="recurrence"
render={({ field }) => (
<FormItem>
<FormLabel>Wiederholung</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Wiederholung wählen" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(recurrenceOptions).map(([value, label]) => (
<SelectItem value={value} key={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={control}
name="maxNewLeadsPerRun"
render={({ field }) => (
<FormItem>
<FormLabel>Max. neue Leads</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="maxAuditsPerRun"
render={({ field }) => (
<FormItem>
<FormLabel>Max. Audits</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
type="number"
inputMode="numeric"
min={1}
onChange={(event) => {
const value = Number(event.target.value);
field.onChange(Number.isFinite(value) ? value : 0);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<div className="flex items-center gap-3">
<FormControl>
<Switch
checked={field.value === "active"}
onCheckedChange={(checked) =>
field.onChange(checked ? "active" : "paused")
}
/>
</FormControl>
<span>{statusLabel[selectedStatus ?? "paused"]}</span>
</div>
<FormMessage />
</FormItem>
)}
/>
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
<div className="flex flex-wrap gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting || pending}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || pending}>
{isSubmitting || pending ? "Speichert..." : submitLabel}
</Button>
</div>
</Form>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,673 @@
"use client";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Building2, Mail, MapPin, Phone, ShieldAlert } from "lucide-react";
import { api } from "@/convex/_generated/api";
import { Id } from "@/convex/_generated/dataModel";
import {
getLeadBlacklistStatusLabel,
getLeadContactStatusLabel,
getLeadDuplicateStatusLabel,
getLeadPriorityLabel,
leadBlacklistStatusOptions,
leadContactStatusOptions,
leadDuplicateStatusOptions,
leadPriorityOptions,
type LeadContactStatus,
type LeadDuplicateStatus,
type LeadPriority,
type LeadBlacklistStatus,
} from "@/lib/dashboard-model";
import { Button } from "@/components/ui/button";
import { Card, CardHeader } from "@/components/ui/card";
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
type LeadsListResult = FunctionReturnType<typeof api.leads.list>;
type LeadRow = NonNullable<LeadsListResult>[number];
type LeadReviewDraft = {
priority: LeadPriority;
contactStatus: LeadContactStatus;
priorityReason: string;
contactStatusReason: string;
notes: string;
reviewEmail: string;
reviewEmailSource: string;
reviewContactPerson: string;
reviewIsBusinessContactAddress: boolean;
duplicateStatus: LeadDuplicateStatus;
blacklistStatus: LeadBlacklistStatus;
};
type LeadReviewPayload = {
id: Id<"leads">;
priority?: LeadPriority;
priorityReason?: string;
contactStatus?: LeadContactStatus;
contactStatusReason?: string;
notes?: string;
duplicateStatus?: LeadDuplicateStatus;
duplicateReason?: string;
blacklistStatus?: LeadBlacklistStatus;
blacklistReason?: string;
duplicateOfLeadId?: Id<"leads">;
applyBlacklist?: boolean;
reviewEmail?: string;
reviewEmailSource?: string;
reviewContactPerson?: string;
reviewIsBusinessContactAddress?: boolean;
};
type LeadStatusFilter = "all" | "high" | "blocked";
function normalizeTextInput(value: string): string | undefined {
const next = value.trim();
return next.length > 0 ? next : undefined;
}
function contactSourceLabel(lead: LeadRow): string {
if (lead.sourceProvider) {
return lead.sourceProvider;
}
if (lead.emailSource) {
return lead.emailSource;
}
return "Unbekannt";
}
function formatLocation(lead: LeadRow): string {
if (lead.postalCode && lead.city) {
return `${lead.postalCode} ${lead.city}`;
}
if (lead.city || lead.address) {
return lead.city ?? lead.address ?? "";
}
return lead.address ?? "Ort offen";
}
function priorityBadgeClass(priority: LeadPriority): string {
switch (priority) {
case "high":
return "text-destructive border-destructive/30 bg-destructive/15";
case "medium":
return "text-muted-foreground border-muted-foreground/30 bg-muted/20";
case "low":
return "text-muted-foreground border-muted/40 bg-muted/35";
case "defer":
return "text-muted-foreground border-secondary/50 bg-secondary/30";
case "blocked":
return "text-destructive border-destructive/40 bg-destructive/15";
default:
return "text-muted-foreground border-muted bg-muted/20";
}
}
function duplicateBadgeVariant(
duplicateStatus: LeadDuplicateStatus,
): "secondary" | "default" | "outline" | "destructive" {
if (duplicateStatus === "duplicate") {
return "destructive";
}
if (duplicateStatus === "possible_duplicate") {
return "outline";
}
if (duplicateStatus === "unique") {
return "secondary";
}
return "outline";
}
export function LeadsReviewTable() {
const leads = useQuery(api.leads.list, { limit: 120 });
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<LeadStatusFilter>("all");
const sortedLeads = useMemo(() => {
if (!leads) {
return [];
}
return [...leads].sort((a, b) => b.createdAt - a.createdAt);
}, [leads]);
const filteredLeads = useMemo(() => {
if (activeFilter === "high") {
return sortedLeads.filter((lead) => lead.priority === "high");
}
if (activeFilter === "blocked") {
return sortedLeads.filter((lead) => lead.blacklistStatus === "blocked");
}
return sortedLeads;
}, [activeFilter, sortedLeads]);
const leadStatusFilters: Array<{ label: string; value: LeadStatusFilter; count: number }> = [
{ label: "Alle Leads", value: "all", count: sortedLeads.length },
{
label: "Hohe Priorität",
value: "high",
count: sortedLeads.filter((lead) => lead.priority === "high").length,
},
{
label: "Gesperrt",
value: "blocked",
count: sortedLeads.filter((lead) => lead.blacklistStatus === "blocked").length,
},
];
return (
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
<p className="text-sm text-muted-foreground">Leads Review</p>
<h1 className="text-2xl font-semibold tracking-normal">Leads prüfen</h1>
</div>
<div className="mx-auto 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"
key={filter.value}
onClick={() => setActiveFilter(filter.value)}
type="button"
>
{filter.label}
<Badge variant="secondary">{filter.count}</Badge>
</button>
))}
</div>
<div className="mx-auto grid w-full max-w-7xl gap-3">
{leads === undefined ? (
Array.from({ length: 4 }, (_, index) => (
<Card 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" />
<div className="mt-2 h-12 rounded-md bg-muted" />
</CardHeader>
</Card>
))
) : sortedLeads.length === 0 ? (
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Leads vorhanden</p>
<p className="text-sm text-muted-foreground">
Bitte zuerst eine Kampagne starten oder importieren.
</p>
</CardHeader>
</Card>
) : filteredLeads.length === 0 ? (
<Card>
<CardHeader>
<p className="text-sm font-medium">Keine Treffer</p>
<p className="text-sm text-muted-foreground">
Für diesen Filter sind aktuell keine Leads vorhanden.
</p>
</CardHeader>
</Card>
) : (
filteredLeads.map((lead) => (
<LeadReviewRow
key={lead._id}
lead={lead}
onActionMessage={setActionMessage}
/>
))
)}
</div>
{actionMessage ? (
<p className="mx-auto max-w-7xl text-sm text-muted-foreground" role="status">
{actionMessage}
</p>
) : null}
</section>
);
}
function LeadReviewRow({
lead,
onActionMessage,
}: {
lead: LeadRow;
onActionMessage: (value: string) => void;
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [draft, setDraft] = useState<LeadReviewDraft>(() => ({
priority: lead.priority,
contactStatus: lead.contactStatus,
priorityReason: lead.priorityReason ?? "",
contactStatusReason: lead.contactStatusReason ?? "",
notes: lead.notes ?? "",
reviewEmail: lead.email ?? "",
reviewEmailSource: lead.emailSource ?? "",
reviewContactPerson: lead.contactPerson ?? "",
reviewIsBusinessContactAddress: false,
duplicateStatus: (lead.duplicateStatus as LeadDuplicateStatus) ?? "unchecked",
blacklistStatus: lead.blacklistStatus,
}));
const [isSaving, setIsSaving] = useState(false);
const [isBlocking, setIsBlocking] = useState(false);
const [rowMessage, setRowMessage] = useState<string | null>(null);
const reviewUpdate = useMutation(api.leads.reviewUpdate);
const location = formatLocation(lead);
const reasonParts = [
lead.priorityReason,
lead.contactStatusReason,
lead.duplicateReason,
lead.blacklistReason,
].filter((item): item is string => Boolean(item));
const update = async (
payload?: Omit<LeadReviewPayload, "id">,
) => {
setIsSaving(true);
setRowMessage(null);
onActionMessage("");
try {
await reviewUpdate({ id: lead._id, ...payload } as LeadReviewPayload);
setRowMessage("Gespeichert");
onActionMessage("Aktualisierung übernommen");
} catch {
setRowMessage("Speichern fehlgeschlagen");
} finally {
setIsSaving(false);
setTimeout(() => setRowMessage(null), 1400);
}
};
const saveRow = async () => {
const reviewEmail = normalizeTextInput(draft.reviewEmail);
const reviewEmailSource = normalizeTextInput(draft.reviewEmailSource);
const reviewContactPerson = draft.reviewContactPerson.trim();
const shouldUpdateEmailReview =
reviewEmail !== normalizeTextInput(lead.email ?? "") ||
reviewEmailSource !== normalizeTextInput(lead.emailSource ?? "") ||
reviewContactPerson !== normalizeTextInput(lead.contactPerson ?? "");
if (shouldUpdateEmailReview && !reviewEmail && !lead.email) {
setRowMessage("Review-E-Mail setzen, um Kontaktinfos zu ändern.");
return;
}
const payload = {
id: lead._id,
priority: draft.priority,
priorityReason: draft.priorityReason,
contactStatus: draft.contactStatus,
contactStatusReason: draft.contactStatusReason,
notes: draft.notes,
duplicateStatus: draft.duplicateStatus,
duplicateReason: lead.duplicateReason,
blacklistStatus: draft.blacklistStatus,
blacklistReason: lead.blacklistReason,
reviewIsBusinessContactAddress: draft.reviewIsBusinessContactAddress,
...(shouldUpdateEmailReview ? {
reviewEmail: reviewEmail ?? lead.email,
reviewEmailSource: reviewEmailSource ?? lead.emailSource,
reviewContactPerson,
} : {}),
};
await update(payload);
};
const blockLead = async () => {
setIsBlocking(true);
await update({ applyBlacklist: true });
setIsBlocking(false);
};
const updateDraft = <T extends keyof LeadReviewDraft>(
field: T,
value: LeadReviewDraft[T],
) => {
setDraft((current) => ({ ...current, [field]: value }));
};
const detailsId = `lead-review-details-${lead._id}`;
const titleId = `lead-review-title-${lead._id}`;
const priorityId = `lead-priority-${lead._id}`;
const contactStatusId = `lead-contact-status-${lead._id}`;
const priorityReasonId = `lead-priority-reason-${lead._id}`;
const contactReasonId = `lead-contact-reason-${lead._id}`;
const notesId = `lead-notes-${lead._id}`;
const reviewEmailId = `lead-review-email-${lead._id}`;
const reviewSourceId = `lead-review-source-${lead._id}`;
const contactPersonId = `lead-contact-person-${lead._id}`;
const businessContactId = `lead-business-contact-${lead._id}`;
const duplicateStatusId = `lead-duplicate-status-${lead._id}`;
const blacklistStatusId = `lead-blacklist-status-${lead._id}`;
return (
<Card aria-labelledby={titleId}>
<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">
<div className="min-w-0 flex-1">
<p className="max-w-full truncate font-medium" id={titleId}>
{lead.companyName}
</p>
<p className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground">
<Building2 className="size-3 shrink-0" />
<span className="inline-flex min-w-0 max-w-full break-words">
{lead.niche ?? "Nische offen"}
</span>
</p>
<p className="mt-2 inline-flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3 shrink-0" />
<span className="inline-flex min-w-0 max-w-full truncate">
{location}
</span>
</p>
</div>
<p
className={`inline-flex shrink-0 rounded-md border px-2 py-1 text-xs font-medium ${priorityBadgeClass(
draft.priority,
)}`}
>
{getLeadPriorityLabel(draft.priority)}
</p>
</div>
<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>
</p>
{lead.phone ? (
<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>
</p>
) : null}
<p className="truncate max-w-full">
Quelle: {contactSourceLabel(lead)}
</p>
{lead.websiteDomain ? (
<p className="truncate max-w-full">Domain: {lead.websiteDomain}</p>
) : null}
</div>
</div>
</CardHeader>
<div className="border-t p-4 pt-3">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(true)}
size="sm"
>
Mehr anzeigen
</Button>
</div>
<Dialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
>
<DialogContent
className="max-h-[calc(100dvh-2rem)] max-w-5xl overflow-y-auto"
id={detailsId}
>
<DialogHeader>
<div>
<DialogTitle>{lead.companyName} prüfen</DialogTitle>
<DialogDescription>
Priorität, Kontaktstatus, Duplikate und Kontaktinformationen bearbeiten.
</DialogDescription>
</div>
<DialogCloseButton />
</DialogHeader>
<div className="grid gap-3 xl:grid-cols-2">
<section className="grid gap-2">
<div>
<Label className="text-xs text-muted-foreground" htmlFor={priorityId}>Priorität</Label>
<div className="mt-2">
<Select
value={draft.priority}
onValueChange={(nextPriority) =>
updateDraft("priority", nextPriority as LeadPriority)
}
>
<SelectTrigger id={priorityId}>
<SelectValue placeholder="Priorität" />
</SelectTrigger>
<SelectContent>
{leadPriorityOptions.map((value) => (
<SelectItem value={value} key={value}>
{getLeadPriorityLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground" htmlFor={contactStatusId}>Kontaktstatus</Label>
<div className="mt-2">
<Select
value={draft.contactStatus}
onValueChange={(nextStatus) =>
updateDraft("contactStatus", nextStatus as LeadContactStatus)
}
>
<SelectTrigger id={contactStatusId}>
<SelectValue placeholder="Kontaktstatus" />
</SelectTrigger>
<SelectContent>
{leadContactStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadContactStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</section>
<section className="grid gap-2">
<div>
<Label className="text-xs text-muted-foreground" htmlFor={priorityReasonId}>Prioritätsgrund</Label>
<Input
id={priorityReasonId}
value={draft.priorityReason}
onChange={(event) => {
updateDraft("priorityReason", event.target.value);
}}
/>
</div>
<div>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactReasonId}>
Kontaktstatus-Notiz
</Label>
<Input
id={contactReasonId}
value={draft.contactStatusReason}
onChange={(event) => {
updateDraft("contactStatusReason", event.target.value);
}}
/>
</div>
<div>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={notesId}>Notiz</Label>
<Input
id={notesId}
value={draft.notes}
onChange={(event) => {
updateDraft("notes", event.target.value);
}}
/>
</div>
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
{reasonParts.length === 0 ? (
<p>Keine Zusatzhinweise</p>
) : (
reasonParts.map((reason) => <p key={reason}> {reason}</p>)
)}
</div>
</section>
<section className="grid gap-2">
<div>
<Label className="text-xs text-muted-foreground" htmlFor={reviewEmailId}>Review-E-Mail</Label>
<Input
id={reviewEmailId}
value={draft.reviewEmail}
onChange={(event) => {
updateDraft("reviewEmail", event.target.value);
}}
/>
</div>
<div>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={reviewSourceId}>Review-Quelle</Label>
<Input
id={reviewSourceId}
value={draft.reviewEmailSource}
onChange={(event) => {
updateDraft("reviewEmailSource", event.target.value);
}}
/>
</div>
<div>
<Label className="mt-2 text-xs text-muted-foreground" htmlFor={contactPersonId}>Ansprechperson</Label>
<Input
id={contactPersonId}
value={draft.reviewContactPerson}
onChange={(event) => {
updateDraft("reviewContactPerson", event.target.value);
}}
/>
</div>
<Label className="mt-2 inline-flex items-center gap-2 text-xs text-muted-foreground" htmlFor={businessContactId}>
<Switch
id={businessContactId}
checked={draft.reviewIsBusinessContactAddress}
onCheckedChange={(checked) => {
updateDraft("reviewIsBusinessContactAddress", checked);
}}
/>
Genannte E-Mail als Business-Kontakt
</Label>
</section>
<section className="grid gap-2">
<div>
<Label className="text-xs text-muted-foreground" htmlFor={duplicateStatusId}>Duplikatstatus</Label>
<div className="mt-2">
<Select
value={draft.duplicateStatus}
onValueChange={(nextStatus) =>
updateDraft("duplicateStatus", nextStatus as LeadDuplicateStatus)
}
>
<SelectTrigger id={duplicateStatusId}>
<SelectValue placeholder="Duplikatstatus" />
</SelectTrigger>
<SelectContent>
{leadDuplicateStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadDuplicateStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground" htmlFor={blacklistStatusId}>Sperrstatus</Label>
<div className="mt-2">
<Select
value={draft.blacklistStatus}
onValueChange={(nextStatus) =>
updateDraft("blacklistStatus", nextStatus as LeadBlacklistStatus)
}
>
<SelectTrigger id={blacklistStatusId}>
<SelectValue placeholder="Sperrstatus" />
</SelectTrigger>
<SelectContent>
{leadBlacklistStatusOptions.map((status) => (
<SelectItem value={status} key={status}>
{getLeadBlacklistStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<Badge
variant={duplicateBadgeVariant(draft.duplicateStatus)}
title={lead.duplicateReason ?? undefined}
>
{getLeadDuplicateStatusLabel(draft.duplicateStatus)}
</Badge>
<Badge
variant={lead.blacklistStatus === "blocked" ? "destructive" : "secondary"}
>
{getLeadBlacklistStatusLabel(lead.blacklistStatus)}
</Badge>
</div>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<Button onClick={saveRow} disabled={isSaving || isBlocking} size="sm">
<span>Speichern</span>
</Button>
<Button
variant="destructive"
onClick={blockLead}
disabled={isSaving || isBlocking}
size="sm"
>
<ShieldAlert className="size-4" />
Sperren
</Button>
</div>
{rowMessage ? (
rowMessage === "Speichern fehlgeschlagen" ? (
<p className="text-xs text-destructive" role="alert">{rowMessage}</p>
) : (
<p className="text-xs text-muted-foreground" role="status">{rowMessage}</p>
)
) : null}
</section>
</div>
</DialogContent>
</Dialog>
</Card>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
import { CheckCircle2 } from "lucide-react";
import type { PublicAuditRenderState } from "@/lib/audits/public-audit-types";
import { RybbitTracking } from "./rybbit-tracking";
import { PublicAuditScreenshot } from "./public-audit-screenshot";
import { TrackedPublicAuditLink } from "./tracked-public-audit-link";
type PublicAuditPageProps = {
audit: Extract<PublicAuditRenderState, { kind: "published" }>["audit"];
};
export function PublicAuditPage({ audit }: PublicAuditPageProps) {
return (
<main className="min-h-dvh bg-slate-50 text-slate-950">
<RybbitTracking domain={audit.domain} />
<section className="border-b border-slate-200 bg-white">
<div className="mx-auto grid min-h-[72dvh] w-full max-w-6xl content-center gap-10 px-6 py-14 md:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)] md:px-8">
<div className="max-w-3xl">
<p className="text-sm font-semibold uppercase tracking-normal text-emerald-700">
Öffentliche Audit-Kurzfassung
</p>
<h1 className="mt-4 text-4xl font-semibold tracking-normal text-slate-950 md:text-5xl">
{audit.headline}
</h1>
<p className="mt-5 text-lg leading-8 text-slate-700">{audit.intro}</p>
<dl className="mt-8 grid gap-4 text-sm text-slate-700 sm:grid-cols-2">
<div>
<dt className="font-semibold text-slate-950">Unternehmen</dt>
<dd className="mt-1">{audit.companyName}</dd>
</div>
<div>
<dt className="font-semibold text-slate-950">Geprüfte Domain</dt>
<dd className="mt-1">{audit.domain}</dd>
</div>
</dl>
</div>
<aside className="self-end border-l-4 border-emerald-600 bg-emerald-50 px-5 py-4 text-sm leading-6 text-emerald-950">
Diese Fassung enthält nur freigegebene Beobachtungen und keine internen Scores,
Skills oder Rohdaten.
</aside>
</div>
</section>
<section className="mx-auto w-full max-w-6xl px-6 py-12 md:px-8">
<div className="grid gap-5">
{audit.observations.map((observation, index) => (
<article
key={`${observation.title}-${index}`}
className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"
>
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-1 h-5 w-5 shrink-0 text-emerald-700" aria-hidden />
<div>
<h2 className="text-xl font-semibold tracking-normal text-slate-950">
{observation.title}
</h2>
<div className="mt-5 grid gap-5 md:grid-cols-3">
<div>
<h3 className="text-sm font-semibold text-slate-950">Beobachtung</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{observation.observation}
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-slate-950">Auswirkung</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{observation.impact}
</p>
</div>
<div>
<h3 className="text-sm font-semibold text-slate-950">Vorschlag</h3>
<p className="mt-2 text-sm leading-6 text-slate-700">
{observation.suggestion}
</p>
</div>
</div>
</div>
</div>
</article>
))}
</div>
</section>
{audit.screenshots.length > 0 ? (
<section className="border-y border-slate-200 bg-white">
<div className="mx-auto w-full max-w-6xl px-6 py-12 md:px-8">
<h2 className="text-2xl font-semibold tracking-normal text-slate-950">
Screenshots aus der Prüfung
</h2>
<div className="mt-6 grid gap-5 md:grid-cols-2">
{audit.screenshots.map((screenshot) => (
<PublicAuditScreenshot key={screenshot.id} screenshot={screenshot} />
))}
</div>
</div>
</section>
) : null}
<section className="mx-auto w-full max-w-6xl px-6 py-12 md:px-8">
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm md:flex md:items-center md:justify-between md:gap-8">
<div>
<h2 className="text-2xl font-semibold tracking-normal text-slate-950">
Nächster sinnvoller Schritt
</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-700">
{audit.finalOffer.body}
</p>
</div>
{audit.finalOffer.ctaHref ? (
<TrackedPublicAuditLink
domain={audit.domain}
href={audit.finalOffer.ctaHref}
label={audit.finalOffer.ctaLabel ?? "Audit besprechen"}
/>
) : null}
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,26 @@
import type { PublicAuditScreenshot as PublicAuditScreenshotData } from "@/lib/audits/public-audit-types";
type PublicAuditScreenshotProps = {
screenshot: PublicAuditScreenshotData;
};
export function PublicAuditScreenshot({ screenshot }: PublicAuditScreenshotProps) {
return (
<figure className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={screenshot.url}
alt={screenshot.alt}
width={screenshot.width}
height={screenshot.height}
className="aspect-[16/10] w-full object-cover"
/>
<figcaption className="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-3 text-xs text-slate-600">
<span className="font-medium uppercase tracking-normal text-slate-700">
{screenshot.viewport === "desktop" ? "Desktop" : "Mobil"}
</span>
<span className="truncate">{screenshot.sourceUrl}</span>
</figcaption>
</figure>
);
}

View File

@@ -0,0 +1,27 @@
type PublicAuditStatusProps = {
status?: "pending" | "unavailable";
};
export function PublicAuditStatus({ status = "pending" }: PublicAuditStatusProps) {
const isUnavailable = status === "unavailable";
return (
<main className="flex min-h-dvh items-center justify-center bg-slate-50 px-6 py-12 text-slate-950">
<section className="w-full max-w-xl rounded-lg border border-slate-200 bg-white p-8 shadow-sm">
<p className="text-sm font-semibold uppercase tracking-normal text-slate-500">
Website-Audit
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-normal text-slate-950">
{isUnavailable
? "Dieser Audit ist nicht verfügbar"
: "Dieser Audit ist noch nicht freigegeben"}
</h1>
<p className="mt-4 text-sm leading-6 text-slate-600">
{isUnavailable
? "Die angeforderte öffentliche Kurzfassung wurde entfernt oder existiert nicht."
: "Die öffentliche Kurzfassung wird erst nach manueller Prüfung angezeigt."}
</p>
</section>
</main>
);
}

View File

@@ -0,0 +1,27 @@
import Script from "next/script";
type RybbitTrackingProps = {
domain: string;
};
export function RybbitTracking({ domain }: RybbitTrackingProps) {
const siteId = process.env.NEXT_PUBLIC_RYBBIT_SITE_ID?.trim();
if (!siteId) {
return null;
}
const apiUrl = process.env.RYBBIT_API_URL?.trim() || "https://app.rybbit.io";
const src = `${apiUrl.replace(/\/$/, "")}/api/script.js`;
return (
<Script
async
data-site-id={siteId}
data-domain={domain}
defer
id="rybbit-public-audit"
src={src}
strategy="afterInteractive"
/>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { ArrowRight, ExternalLink } from "lucide-react";
declare global {
interface Window {
rybbit?: {
event?: (name: string, properties?: Record<string, string | number>) => void;
};
}
}
type TrackedPublicAuditLinkProps = {
href: string;
label: string;
domain: string;
};
export function TrackedPublicAuditLink({
href,
label,
domain,
}: TrackedPublicAuditLinkProps) {
const isInternal = href.startsWith("/");
return (
<a
href={href}
className="mt-6 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 md:mt-0"
onClick={() => {
window.rybbit?.event?.("audit_cta_click", {
domain,
target: isInternal ? "cta" : "outbound_cta",
});
if (!isInternal) {
window.rybbit?.event?.("audit_website_link_click", {
domain,
href,
});
}
}}
>
{label}
{isInternal ? (
<ArrowRight className="h-4 w-4" aria-hidden />
) : (
<ExternalLink className="h-4 w-4" aria-hidden />
)}
</a>
);
}

View File

@@ -0,0 +1,62 @@
import { AlertTriangle, CheckCircle2 } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { IntegrationReadinessRow } from "@/lib/operational-readiness";
type OperationsReadinessProps = {
rows: IntegrationReadinessRow[];
};
export function OperationsReadiness({ rows }: OperationsReadinessProps) {
return (
<section className="space-y-4">
<header className="border-b pb-3">
<p className="text-sm text-muted-foreground">MVP-Betrieb</p>
<h1 className="mt-2 text-3xl font-semibold tracking-normal">
Einstellungen
</h1>
</header>
<Card>
<CardHeader>
<CardTitle>Integrationsstatus</CardTitle>
<CardDescription>
Diese Übersicht zeigt nur fehlende Variablennamen der Next.js-Runtime.
Convex-Action-Env bitte zusätzlich über Run-Events oder CLI prüfen.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
{rows.map((row) => {
const isConfigured = row.status === "configured";
const Icon = isConfigured ? CheckCircle2 : AlertTriangle;
return (
<article className="rounded-lg border p-4" key={row.id}>
<div className="flex items-start gap-3">
<Icon
aria-hidden
className={isConfigured ? "mt-0.5 size-5 text-emerald-600" : "mt-0.5 size-5 text-amber-600"}
/>
<div className="min-w-0">
<h2 className="text-base font-semibold">{row.label}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{isConfigured ? "Konfiguration vorhanden" : "Konfiguration fehlt"}
</p>
{row.missingEnv.length > 0 ? (
<p className="mt-2 break-words text-xs text-muted-foreground">
Fehlend: {row.missingEnv.join(", ")}
</p>
) : null}
<p className="mt-2 text-xs text-muted-foreground">
{row.errorSurface}
</p>
</div>
</div>
</article>
);
})}
</CardContent>
</Card>
</section>
);
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

6
convex.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/get-convex/convex-backend/main/npm-packages/convex/schemas/convex.schema.json",
"node": {
"externalPackages": ["playwright-core", "@sparticuz/chromium-min"]
}
}

View File

@@ -8,16 +8,29 @@
* @module * @module
*/ */
import type * as auditGeneration from "../auditGeneration.js";
import type * as auditGenerationAction from "../auditGenerationAction.js";
import type * as auditInputs from "../auditInputs.js";
import type * as audits from "../audits.js"; import type * as audits from "../audits.js";
import type * as blacklist from "../blacklist.js"; import type * as blacklist from "../blacklist.js";
import type * as campaignMetrics from "../campaignMetrics.js";
import type * as campaigns from "../campaigns.js"; import type * as campaigns from "../campaigns.js";
import type * as crons from "../crons.js";
import type * as domain from "../domain.js"; import type * as domain from "../domain.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as leadDiscovery from "../leadDiscovery.js";
import type * as leads from "../leads.js"; import type * as leads from "../leads.js";
import type * as outreach from "../outreach.js"; import type * as outreach from "../outreach.js";
import type * as outreachSendAction from "../outreachSendAction.js";
import type * as pageSpeed from "../pageSpeed.js";
import type * as pageSpeedAction from "../pageSpeedAction.js";
import type * as runs from "../runs.js"; import type * as runs from "../runs.js";
import type * as scheduledJobs from "../scheduledJobs.js";
import type * as settings from "../settings.js"; import type * as settings from "../settings.js";
import type * as storage from "../storage.js"; import type * as storage from "../storage.js";
import type * as usageEvents from "../usageEvents.js";
import type * as websiteEnrichment from "../websiteEnrichment.js";
import type * as websiteEnrichmentAction from "../websiteEnrichmentAction.js";
import type { import type {
ApiFromModules, ApiFromModules,
@@ -26,16 +39,29 @@ import type {
} from "convex/server"; } from "convex/server";
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
auditGeneration: typeof auditGeneration;
auditGenerationAction: typeof auditGenerationAction;
auditInputs: typeof auditInputs;
audits: typeof audits; audits: typeof audits;
blacklist: typeof blacklist; blacklist: typeof blacklist;
campaignMetrics: typeof campaignMetrics;
campaigns: typeof campaigns; campaigns: typeof campaigns;
crons: typeof crons;
domain: typeof domain; domain: typeof domain;
http: typeof http; http: typeof http;
leadDiscovery: typeof leadDiscovery;
leads: typeof leads; leads: typeof leads;
outreach: typeof outreach; outreach: typeof outreach;
outreachSendAction: typeof outreachSendAction;
pageSpeed: typeof pageSpeed;
pageSpeedAction: typeof pageSpeedAction;
runs: typeof runs; runs: typeof runs;
scheduledJobs: typeof scheduledJobs;
settings: typeof settings; settings: typeof settings;
storage: typeof storage; storage: typeof storage;
usageEvents: typeof usageEvents;
websiteEnrichment: typeof websiteEnrichment;
websiteEnrichmentAction: typeof websiteEnrichmentAction;
}>; }>;
/** /**

691
convex/auditGeneration.ts Normal file
View File

@@ -0,0 +1,691 @@
import { internal } from "./_generated/api";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, internalQuery } from "./_generated/server";
import {
AUDIT_GENERATION_STAGES,
AUDIT_GENERATION_STATUSES,
RUN_STATUSES,
} from "./domain";
import { v } from "convex/values";
import {
type PageSpeedAuditErrorType,
type PageSpeedMinimalAuditResult,
} from "../lib/pagespeed-audit-input";
export const MAX_PROMPT_BYTES = 12_000;
export const MAX_RAW_RESPONSE_BYTES = 12_000;
export const MAX_PARSED_JSON_BYTES = 12_000;
const TRUNCATION_MARKER = "\n\n[... abgeschnitten ...]";
const auditGenerationStage = v.union(
...AUDIT_GENERATION_STAGES.map((stage) => v.literal(stage)),
);
const auditGenerationStatus = v.union(
...AUDIT_GENERATION_STATUSES.map((status) => v.literal(status)),
);
const runStatus = v.union(...RUN_STATUSES.map((status) => v.literal(status)));
const auditGenerationParsedValue = v.union(
v.string(),
v.number(),
v.boolean(),
v.null(),
v.array(v.any()),
v.record(v.string(), v.any()),
);
const auditGenerationParsedJson = v.union(
v.string(),
v.record(v.string(), auditGenerationParsedValue),
);
const auditFindingEvidenceRef = v.object({
id: v.string(),
type: v.union(
v.literal("crawl_page"),
v.literal("technical_check"),
v.literal("screenshot"),
v.literal("pagespeed"),
v.literal("jina_excerpt"),
v.literal("generation_stage"),
),
label: v.string(),
sourceUrl: v.optional(v.string()),
});
type AuditGenerationLead = Pick<
Doc<"leads">,
| "_id"
| "companyName"
| "niche"
| "city"
| "address"
| "websiteUrl"
| "websiteDomain"
| "phone"
| "contactPerson"
>;
type AuditGenerationEvidenceCrawlPage = Pick<
Doc<"websiteCrawlPages">,
| "sourceUrl"
| "finalUrl"
| "title"
| "metaDescription"
| "pageKind"
| "hasContactFormSignal"
| "hasContactCtaSignal"
| "visibleTextExcerpt"
>;
type AuditGenerationEvidenceTechnicalCheck = Pick<
Doc<"websiteTechnicalChecks">,
| "sourceUrl"
| "finalUrl"
| "usesHttps"
| "missingTitle"
| "missingMetaDescription"
| "hasVisibleContactPath"
| "brokenInternalLinkCount"
>;
type AuditGenerationEvidenceScreenshot = Pick<
Doc<"websiteCrawlScreenshots">,
| "storageId"
| "viewport"
| "sourceUrl"
| "capturedAt"
| "width"
| "height"
| "mimeType"
>;
type AuditGenerationEvidence = {
lead: AuditGenerationLead;
crawlPages: AuditGenerationEvidenceCrawlPage[];
technicalChecks: AuditGenerationEvidenceTechnicalCheck[];
screenshots: AuditGenerationEvidenceScreenshot[];
pageSpeedInputs: PageSpeedMinimalAuditResult[];
externalMarkdown?: string;
};
function byteLength(value: string) {
return new TextEncoder().encode(value).byteLength;
}
function truncateToByteLimit(value: string, maxBytes: number) {
if (maxBytes <= 0) {
return "";
}
let usedBytes = 0;
let endIndex = 0;
for (const char of value) {
const charBytes = byteLength(char);
if (usedBytes + charBytes > maxBytes) {
break;
}
usedBytes += charBytes;
endIndex += char.length;
}
return value.slice(0, endIndex);
}
function truncateWithMarker(value: string, maxBytes: number) {
if (byteLength(value) <= maxBytes) {
return value;
}
const markerBytes = byteLength(TRUNCATION_MARKER);
if (markerBytes >= maxBytes) {
const markerBytesBuffer = new TextEncoder().encode(TRUNCATION_MARKER);
return new TextDecoder().decode(markerBytesBuffer.slice(0, maxBytes));
}
const byteBudget = Math.max(0, maxBytes - markerBytes);
const trimmed = truncateToByteLimit(value, byteBudget);
return `${trimmed}${TRUNCATION_MARKER}`;
}
function sanitizeAndCapString(value: string | undefined, maxBytes: number) {
if (!value) {
return undefined;
}
const safe = (sanitizeSecretCandidates(value) ?? "").trim();
return byteLength(safe) > maxBytes ? truncateWithMarker(safe, maxBytes) : safe;
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch {
return "[unserializable payload]";
}
}
function sanitizeAndCapParsedJson(parsedJson: unknown) {
if (parsedJson === undefined) {
return undefined;
}
if (typeof parsedJson === "string") {
return sanitizeAndCapString(parsedJson, MAX_PARSED_JSON_BYTES);
}
const serialized = safeStringify(parsedJson);
const safeSerialized = sanitizeSecretCandidates(serialized) ?? "";
if (byteLength(safeSerialized) <= MAX_PARSED_JSON_BYTES) {
return safeSerialized;
}
return truncateWithMarker(safeSerialized, MAX_PARSED_JSON_BYTES);
}
function normalizePageSpeedResultRow(
row: Doc<"pageSpeedResults">,
): PageSpeedMinimalAuditResult {
return {
strategy: row.strategy,
status: row.status,
sourceUrl: row.sourceUrl,
...(row.finalUrl ? { finalUrl: row.finalUrl } : {}),
...(row.normalized ? { normalized: row.normalized } : {}),
...(row.errorType ? { errorType: row.errorType as PageSpeedAuditErrorType } : {}),
...(row.errorSummary ? { errorSummary: row.errorSummary } : {}),
};
}
const auditGenerationUsage = v.object({
promptTokens: v.optional(v.number()),
completionTokens: v.optional(v.number()),
totalTokens: v.optional(v.number()),
cacheReadTokens: v.optional(v.number()),
totalCostUsd: v.optional(v.number()),
});
const secretHints = [
"OPENROUTER_API_KEY",
"GOOGLE_PLACES_API_KEY",
"GOOGLE_GEOCODING_API_KEY",
"PAGESPEED_API_KEY",
"SMTP_PASSWORD",
"SMTP_HOST",
"SMTP_USER",
"BETTER_AUTH_SECRET",
"RYBBIT_API_KEY",
"SCREENSHOTONE_API_KEY",
"JINA_API_KEY",
];
function sanitizeSecretCandidates(value: string | undefined): string | undefined {
if (!value) {
return value;
}
let sanitized = value;
for (const key of secretHints) {
const secret = process.env[key];
if (!secret) {
continue;
}
sanitized = sanitized.replace(
new RegExp(escapeRegExp(secret), "g"),
"[REDACTED]",
);
}
return sanitized
.replace(/\b(?:api[_-]?key|token|secret|password)\s*[:=]\s*[^\s\"']+/gi, "[REDACTED]")
.trim();
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
type StartLeadSnapshot = Pick<
Doc<"leads">,
"_id" | "websiteUrl" | "websiteDomain" | "contactStatus"
>;
export const getAuditGenerationEvidence = internalQuery({
args: {
runId: v.id("agentRuns"),
},
handler: async (ctx, args): Promise<AuditGenerationEvidence | null> => {
const run = await ctx.db.get(args.runId);
if (!run || !run.leadId) {
return null;
}
const lead = await ctx.db.get(run.leadId);
if (!lead) {
return null;
}
const leadIdFilter = {
table: "by_leadId" as const,
value: lead._id,
};
const latestSuccessfulEnrichmentRun = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "website_enrichment")
.eq("status", "succeeded")
.eq("leadId", lead._id),
)
.order("desc")
.take(1);
const enrichmentEvidenceRunId =
latestSuccessfulEnrichmentRun[0]?._id ?? args.runId;
const crawlPagesByRun = await ctx.db
.query("websiteCrawlPages")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
.order("desc")
.take(40);
const technicalChecksByRun = await ctx.db
.query("websiteTechnicalChecks")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
.order("desc")
.take(80);
const auditCaptureScreenshotsByRun = await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", args.runId))
.order("desc")
.take(20);
const enrichmentScreenshotsByRun =
enrichmentEvidenceRunId === args.runId
? []
: await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentEvidenceRunId))
.order("desc")
.take(20);
const pageSpeedByRun = run.auditId
? await ctx.db
.query("pageSpeedResults")
.withIndex("by_auditId", (q) => q.eq("auditId", run.auditId as Id<"audits">))
.order("desc")
.take(20)
: await ctx.db
.query("pageSpeedResults")
.withIndex("by_leadId", (q) => q.eq("leadId", leadIdFilter.value))
.order("desc")
.take(20);
const crawlPages = crawlPagesByRun;
const technicalChecks = technicalChecksByRun;
const screenshots = [...auditCaptureScreenshotsByRun, ...enrichmentScreenshotsByRun];
return {
lead: {
_id: lead._id,
companyName: lead.companyName,
niche: lead.niche,
city: lead.city,
address: lead.address,
websiteUrl: lead.websiteUrl,
websiteDomain: lead.websiteDomain,
phone: lead.phone,
contactPerson: lead.contactPerson,
},
crawlPages,
technicalChecks,
screenshots,
pageSpeedInputs: pageSpeedByRun.map(normalizePageSpeedResultRow),
};
},
});
export const queueLeadAuditGeneration = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
parentRunId: v.optional(v.id("agentRuns")),
},
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) {
return null;
}
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", args.leadId),
)
.take(1);
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", 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_generation",
leadId: args.leadId,
...(args.auditId ? { auditId: args.auditId } : {}),
status: "pending",
currentStep: "audit_generation",
counters: {
leadsFound: 0,
leadsCreated: 0,
auditsCreated: 0,
outreachPrepared: 0,
errors: 0,
},
createdAt: now,
updatedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId,
level: "info",
message: "Audit-Generierung 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.auditGenerationAction.processAuditGeneration,
{
runId,
},
);
return runId;
},
});
export const startAuditGenerationRun = internalMutation({
args: {
runId: v.id("agentRuns"),
},
returns: v.union(
v.object({
lead: v.object({
_id: v.id("leads"),
websiteUrl: v.optional(v.string()),
websiteDomain: v.optional(v.string()),
contactStatus: v.union(
v.literal("new"),
v.literal("missing_contact"),
v.literal("audit_ready"),
v.literal("outreach_ready"),
v.literal("contacted"),
v.literal("replied"),
v.literal("do_not_contact"),
),
}),
auditId: v.optional(v.id("audits")),
}),
v.null(),
),
handler: async (ctx, args): Promise<
{ lead: StartLeadSnapshot; auditId?: Id<"audits"> } | null
> => {
const now = Date.now();
const run = await ctx.db.get(args.runId);
if (!run || run.type !== "audit_generation" || run.status !== "pending") {
return null;
}
if (!run.leadId) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "audit_generation",
errorSummary: "Der Lauf hat keine Lead-ID.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"Audit-Generierung konnte nicht gestartet werden: Keine Lead-ID.",
details: [{ label: "Lead-ID", value: "unbekannt" }],
createdAt: now,
});
return null;
}
const lead = await ctx.db.get(run.leadId);
if (!lead) {
await ctx.db.patch(args.runId, {
status: "failed",
currentStep: "audit_generation",
errorSummary: "Lead wurde nicht gefunden.",
updatedAt: now,
finishedAt: now,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "error",
message:
"Audit-Generierung konnte nicht gestartet werden: Kein Lead mit dieser ID.",
details: [{ label: "Lead-ID", value: run.leadId }],
createdAt: now,
});
return null;
}
await ctx.db.patch(args.runId, {
status: "running",
currentStep: "audit_generation",
startedAt: now,
updatedAt: now,
errorSummary: undefined,
});
await ctx.db.insert("agentRunEvents", {
runId: args.runId,
level: "info",
message: "Audit-Generierung gestartet.",
details: [{ label: "Lead-ID", value: lead._id }],
createdAt: now,
});
return {
lead: {
_id: lead._id,
websiteUrl: lead.websiteUrl,
websiteDomain: lead.websiteDomain,
contactStatus: lead.contactStatus,
},
...(run.auditId ? { auditId: run.auditId } : {}),
};
},
});
export const persistAuditGenerationResult = internalMutation({
args: {
leadId: v.id("leads"),
auditId: v.optional(v.id("audits")),
runId: v.id("agentRuns"),
stage: auditGenerationStage,
modelProfile: v.string(),
modelId: v.string(),
prompt: v.string(),
systemPrompt: v.optional(v.string()),
rawResponse: v.optional(v.string()),
parsedJson: v.optional(auditGenerationParsedJson),
usage: v.optional(auditGenerationUsage),
finishReason: v.optional(v.string()),
status: auditGenerationStatus,
errorSummary: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
return await ctx.db.insert("auditGenerations", {
leadId: args.leadId,
auditId: args.auditId,
runId: args.runId,
stage: args.stage,
modelProfile: args.modelProfile,
modelId: args.modelId,
prompt: sanitizeAndCapString(args.prompt, MAX_PROMPT_BYTES) ?? "",
...(args.systemPrompt
? { systemPrompt: sanitizeAndCapString(args.systemPrompt, MAX_PROMPT_BYTES) }
: {}),
...(args.rawResponse
? { rawResponse: sanitizeAndCapString(args.rawResponse, MAX_RAW_RESPONSE_BYTES) }
: {}),
...(args.parsedJson ? { parsedJson: sanitizeAndCapParsedJson(args.parsedJson) } : {}),
...(args.usage ? { usage: args.usage } : {}),
...(args.finishReason ? { finishReason: args.finishReason } : {}),
status: args.status,
...(args.errorSummary
? { errorSummary: sanitizeAndCapString(args.errorSummary, MAX_RAW_RESPONSE_BYTES) }
: {}),
createdAt: now,
updatedAt: now,
});
},
});
export const replaceAuditFindings = internalMutation({
args: {
auditId: v.id("audits"),
runId: v.id("agentRuns"),
findings: v.array(
v.object({
skillId: v.string(),
claim: v.string(),
recommendation: v.string(),
customerBenefit: v.string(),
severity: v.union(v.literal(1), v.literal(2), v.literal(3)),
confidence: v.number(),
evidenceRefs: v.array(auditFindingEvidenceRef),
reviewStatus: v.union(
v.literal("pending"),
v.literal("accepted"),
v.literal("rejected"),
),
}),
),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("auditFindings")
.withIndex("by_auditId", (q) => q.eq("auditId", args.auditId))
.collect();
for (const finding of existing) {
await ctx.db.delete(finding._id);
}
const now = Date.now();
for (const finding of args.findings) {
await ctx.db.insert("auditFindings", {
auditId: args.auditId,
runId: args.runId,
skillId: finding.skillId,
claim: finding.claim,
recommendation: finding.recommendation,
customerBenefit: finding.customerBenefit,
severity: finding.severity,
confidence: finding.confidence,
evidenceRefs: finding.evidenceRefs,
reviewStatus: finding.reviewStatus,
createdAt: now,
updatedAt: now,
});
}
},
});
export const persistExternalCaptureScreenshot = internalMutation({
args: {
leadId: v.id("leads"),
runId: v.id("agentRuns"),
storageId: v.id("_storage"),
viewport: v.union(v.literal("desktop"), v.literal("mobile")),
sourceUrl: v.string(),
capturedAt: v.number(),
width: v.number(),
height: v.number(),
mimeType: v.string(),
},
returns: v.id("websiteCrawlScreenshots"),
handler: async (ctx, args): Promise<Id<"websiteCrawlScreenshots">> => {
return await ctx.db.insert("websiteCrawlScreenshots", {
leadId: args.leadId,
runId: args.runId,
storageId: args.storageId,
viewport: args.viewport,
sourceUrl: args.sourceUrl,
capturedAt: args.capturedAt,
width: args.width,
height: args.height,
mimeType: args.mimeType,
createdAt: Date.now(),
});
},
});
export const finishAuditGenerationRun = internalMutation({
args: {
runId: v.id("agentRuns"),
status: runStatus,
currentStep: v.optional(v.string()),
errorSummary: v.optional(v.string()),
errors: v.optional(v.number()),
},
handler: async (ctx, args) => {
const now = Date.now();
await ctx.db.patch(args.runId, {
status: args.status,
updatedAt: now,
finishedAt: now,
currentStep: args.currentStep ?? "audit_generation",
errorSummary: args.errorSummary,
counters: {
leadsFound: 0,
leadsCreated: 0,
auditsCreated: 0,
outreachPrepared: 0,
errors: args.errors ?? 0,
},
});
},
});

File diff suppressed because it is too large Load Diff

60
convex/auditInputs.ts Normal file
View File

@@ -0,0 +1,60 @@
import { v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { internalQuery } from "./_generated/server";
import { buildPageSpeedAuditInputs, type PageSpeedMinimalAuditResult } from "../lib/pagespeed-audit-input";
function normalizePageSpeedResultRow(
row: Doc<"pageSpeedResults">,
): PageSpeedMinimalAuditResult {
return {
strategy: row.strategy,
status: row.status,
sourceUrl: row.sourceUrl,
...(row.finalUrl ? { finalUrl: row.finalUrl } : {}),
...(row.normalized ? { normalized: row.normalized } : {}),
...(row.errorType ? { errorType: row.errorType } : {}),
...(row.errorSummary ? { errorSummary: row.errorSummary } : {}),
};
}
export const getPageSpeedAuditInputs = internalQuery({
args: {
leadId: v.optional(v.id("leads")),
auditId: v.optional(v.id("audits")),
},
handler: async (
ctx,
args,
): Promise<{
technicalSignals: string[];
customerImplications: string[];
internalNotes: string[];
}> => {
let results: Doc<"pageSpeedResults">[];
if (args.auditId) {
results = await ctx.db
.query("pageSpeedResults")
.withIndex("by_auditId", (q) => q.eq("auditId", args.auditId as Id<"audits">))
.order("desc")
.take(50);
return buildPageSpeedAuditInputs(results.map(normalizePageSpeedResultRow));
}
if (args.leadId) {
results = await ctx.db
.query("pageSpeedResults")
.withIndex("by_leadId", (q) => q.eq("leadId", args.leadId as Id<"leads">))
.order("desc")
.take(50);
return buildPageSpeedAuditInputs(results.map(normalizePageSpeedResultRow));
}
return {
technicalSignals: [],
customerImplications: [],
internalNotes: [],
};
},
});

View File

@@ -1,7 +1,12 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { normalizeListLimit } from "./domain"; import { normalizeListLimit } from "./domain";
import { mutation, query } from "./_generated/server"; import { internalMutation, mutation, query } from "./_generated/server";
import type { Doc, Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
export const AUDIT_REVIEW_NOTICE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
const DETAIL_EVIDENCE_LIMIT = 50;
const auditStatus = v.union( const auditStatus = v.union(
v.literal("draft"), v.literal("draft"),
@@ -9,6 +14,223 @@ const auditStatus = v.union(
v.literal("published"), v.literal("published"),
v.literal("deactivated"), v.literal("deactivated"),
); );
const usedSkillsValidator = v.array(
v.object({
id: v.optional(v.string()),
name: v.string(),
category: v.optional(v.string()),
version: v.optional(v.string()),
source: v.optional(v.string()),
}),
);
const skillSummaryValidator = v.array(
v.object({
name: v.string(),
purpose: v.string(),
summary: v.string(),
}),
);
const publicObservationValidator = v.object({
title: v.string(),
observation: v.string(),
impact: v.string(),
suggestion: v.string(),
screenshotIds: v.optional(v.array(v.id("_storage"))),
});
const publicOfferValidator = v.object({
body: v.string(),
ctaLabel: v.optional(v.string()),
ctaHref: v.optional(v.string()),
});
type AuditDashboardRow =
| {
kind: "audit";
id: Id<"audits">;
auditId: Id<"audits">;
slug: string;
title: string;
checkedDomain: string;
status: Doc<"audits">["status"];
pageCount: number;
checkedPages: string[];
detailHref: string;
createdAt: number;
updatedAt: number;
}
| {
kind: "generation";
id: Id<"agentRuns">;
runId: Id<"agentRuns">;
leadId: Id<"leads"> | null;
title: string;
checkedDomain: string;
status: Doc<"agentRuns">["status"];
latestStage: string;
stageStatus: Doc<"agentRuns">["status"];
errorSummary: string | null;
pageCount: number;
checkedPages: string[];
createdAt: number;
updatedAt: number;
};
const requireOperator = async (ctx: MutationCtx | QueryCtx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Nicht autorisiert.");
}
};
const domainFromLead = (
lead: Pick<Doc<"leads">, "companyName" | "websiteDomain" | "websiteUrl"> | null,
) => {
if (lead?.websiteDomain) {
return lead.websiteDomain;
}
if (lead?.websiteUrl) {
try {
return new URL(lead.websiteUrl).hostname;
} catch {
return lead.websiteUrl;
}
}
return "unbekannte-domain";
};
const latestGenerationStage = (stages: Doc<"auditGenerations">[]) => {
return [...stages].sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
};
const normalizeComparableAuditUrl = (value: string | null | undefined) => {
const trimmed = value?.trim();
if (!trimmed) {
return "";
}
const normalizeParsedUrl = (parsedUrl: URL) => {
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, "");
const pathname = parsedUrl.pathname.replace(/\/+$/, "");
return `${hostname}${pathname}${parsedUrl.search}`.toLowerCase();
};
try {
return normalizeParsedUrl(new URL(trimmed));
} catch {
try {
return normalizeParsedUrl(new URL(`https://${trimmed}`));
} catch {
return trimmed
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "")
.replace(/\/+$/, "");
}
}
};
const setIfPresent = <T>(
target: Map<string, T>,
url: string | null | undefined,
value: T,
) => {
const key = normalizeComparableAuditUrl(url);
if (key && !target.has(key)) {
target.set(key, value);
}
};
const findByUrl = <T>(source: Map<string, T>, ...urls: Array<string | null | undefined>) => {
for (const url of urls) {
const key = normalizeComparableAuditUrl(url);
if (key && source.has(key)) {
return source.get(key) ?? null;
}
}
return null;
};
const fallbackCheckedPageEvidence = (url: string) => ({
url,
sourceUrl: null,
finalUrl: null,
pageKind: null,
title: null,
metaDescription: null,
headings: [],
visibleTextExcerpt: null,
hasContactFormSignal: null,
hasContactCtaSignal: null,
usesHttps: null,
missingMetaDescription: null,
brokenInternalLinkCount: null,
screenshots: [],
createdAt: null,
});
const toIsoDate = (timestamp: number | undefined, fallback: number) => {
return new Date(timestamp ?? fallback).toISOString();
};
const fallbackObservation = (audit: {
publicSummary?: string;
publicBody?: string;
textFindings?: string[];
}) => {
const firstFinding = audit.textFindings?.find((finding) => finding.trim().length > 0);
return {
title: "Wichtigster Hebel",
observation: firstFinding ?? audit.publicSummary ?? "Die Website hat bereits eine gute Grundlage.",
impact: "Unklare Nutzerführung kann qualifizierte Anfragen kosten.",
suggestion:
audit.publicBody ??
"Priorisieren Sie die sichtbarsten Kontaktwege und testen Sie die wichtigsten Seiten mobil.",
};
};
const publicContentForAudit = (audit: {
checkedDomain: string;
publicSummary?: string;
publicBody?: string;
publicObservations?: Array<{
title: string;
observation: string;
impact: string;
suggestion: string;
screenshotIds?: string[];
}>;
publicOffer?: {
body: string;
ctaLabel?: string;
ctaHref?: string;
};
textFindings?: string[];
}) => {
const observations = audit.publicObservations?.length
? audit.publicObservations
: [fallbackObservation(audit)];
return {
headline: audit.publicSummary ?? `Website-Audit fuer ${audit.checkedDomain}`,
intro:
audit.publicBody ??
"Diese Kurzfassung zeigt die wichtigsten oeffentlichen Findings aus dem geprueften Website-Audit.",
observations,
finalOffer:
audit.publicOffer ?? {
body: "Wenn Sie die naechsten Verbesserungen priorisieren moechten, besprechen wir die sinnvollsten Schritte gemeinsam.",
ctaLabel: "Audit besprechen",
},
};
};
const screenshotAlt = (viewport: "desktop" | "mobile", sourceUrl: string) => {
return `${viewport === "desktop" ? "Desktop" : "Mobile"} Screenshot von ${sourceUrl}`;
};
export const create = mutation({ export const create = mutation({
args: { args: {
@@ -16,13 +238,18 @@ export const create = mutation({
slug: v.string(), slug: v.string(),
checkedDomain: v.string(), checkedDomain: v.string(),
checkedPages: v.array(v.string()), checkedPages: v.array(v.string()),
usedSkills: v.optional(usedSkillsValidator),
status: v.optional(auditStatus), status: v.optional(auditStatus),
internalSummary: v.optional(v.string()), internalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()), publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()), publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicObservationValidator)),
publicOffer: v.optional(publicOfferValidator),
ctaType: v.optional(v.string()), ctaType: v.optional(v.string()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await requireOperator(ctx);
const now = Date.now(); const now = Date.now();
const existing = await ctx.db const existing = await ctx.db
.query("audits") .query("audits")
@@ -42,16 +269,242 @@ export const create = mutation({
}, },
}); });
export const getDetail = query({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
return null;
}
const lead = await ctx.db.get(audit.leadId);
const latestSuccessfulEnrichmentRun = await ctx.db
.query("agentRuns")
.withIndex("by_type_and_status_and_leadId", (q) =>
q
.eq("type", "website_enrichment")
.eq("status", "succeeded")
.eq("leadId", audit.leadId),
)
.order("desc")
.take(1);
const enrichmentRunId = latestSuccessfulEnrichmentRun[0]?._id ?? null;
const crawlPages = enrichmentRunId
? await ctx.db
.query("websiteCrawlPages")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT)
: [];
const technicalChecks = enrichmentRunId
? await ctx.db
.query("websiteTechnicalChecks")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT)
: [];
const crawlScreenshots = enrichmentRunId
? await ctx.db
.query("websiteCrawlScreenshots")
.withIndex("by_runId", (q) => q.eq("runId", enrichmentRunId))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT)
: [];
const findings = await ctx.db
.query("auditFindings")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(DETAIL_EVIDENCE_LIMIT);
const pagesByUrl = new Map<string, Doc<"websiteCrawlPages">>();
for (const page of crawlPages) {
setIfPresent(pagesByUrl, page.sourceUrl, page);
setIfPresent(pagesByUrl, page.finalUrl, page);
}
const checksByUrl = new Map<string, Doc<"websiteTechnicalChecks">>();
for (const checks of technicalChecks) {
setIfPresent(checksByUrl, checks.sourceUrl, checks);
setIfPresent(checksByUrl, checks.finalUrl, checks);
}
const screenshotsByUrl = new Map<
string,
Array<{
id: Id<"_storage">;
url: string;
viewport: Doc<"websiteCrawlScreenshots">["viewport"];
sourceUrl: string;
width: number;
height: number;
createdAt: number;
}>
>();
for (const screenshot of crawlScreenshots) {
const url = await ctx.storage.getUrl(screenshot.storageId);
if (!url) {
continue;
}
const key = normalizeComparableAuditUrl(screenshot.sourceUrl);
if (!key) {
continue;
}
const current = screenshotsByUrl.get(key) ?? [];
current.push({
id: screenshot.storageId,
url,
viewport: screenshot.viewport,
sourceUrl: screenshot.sourceUrl,
width: screenshot.width,
height: screenshot.height,
createdAt: screenshot.createdAt,
});
screenshotsByUrl.set(key, current);
}
const checkedPages = audit.checkedPages.map((checkedUrl) => {
const page = findByUrl(pagesByUrl, checkedUrl);
if (!page) {
return fallbackCheckedPageEvidence(checkedUrl);
}
const checks = findByUrl(checksByUrl, checkedUrl, page.sourceUrl, page.finalUrl);
const screenshots = [
...(
findByUrl(screenshotsByUrl, checkedUrl, page.sourceUrl, page.finalUrl) ?? []
),
].sort((a, b) => b.createdAt - a.createdAt);
return {
url: checkedUrl,
sourceUrl: page.sourceUrl,
finalUrl: page.finalUrl,
pageKind: page.pageKind,
title: page.title ?? null,
metaDescription: page.metaDescription ?? null,
headings: page.headings.slice(0, DETAIL_EVIDENCE_LIMIT),
visibleTextExcerpt: page.visibleTextExcerpt ?? null,
hasContactFormSignal: page.hasContactFormSignal,
hasContactCtaSignal: page.hasContactCtaSignal,
usesHttps: checks?.usesHttps ?? null,
missingMetaDescription: checks?.missingMetaDescription ?? null,
brokenInternalLinkCount: checks?.brokenInternalLinkCount ?? null,
screenshots,
createdAt: page.createdAt,
};
});
return {
audit,
lead,
findings,
sourceSummaries: {
checkedPages,
},
};
},
});
export const get = query({ export const get = query({
args: { id: v.id("audits") }, args: { id: v.id("audits") },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await requireOperator(ctx);
return await ctx.db.get(args.id); return await ctx.db.get(args.id);
}, },
}); });
export const upsertFromAuditGeneration = internalMutation({
args: {
leadId: v.id("leads"),
runId: v.id("agentRuns"),
auditId: v.optional(v.id("audits")),
checkedDomain: v.string(),
checkedPages: v.array(v.string()),
internalSummary: v.optional(v.string()),
multimodalSummary: v.optional(v.string()),
publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicObservationValidator)),
publicOffer: v.optional(publicOfferValidator),
usedSkills: v.optional(usedSkillsValidator),
skillSummaries: v.optional(skillSummaryValidator),
},
handler: async (ctx, args) => {
const now = Date.now();
const lead = await ctx.db.get(args.leadId);
if (!lead) {
throw new Error("Lead wurde nicht gefunden.");
}
if (args.auditId) {
const existing = await ctx.db.get(args.auditId);
if (!existing) {
throw new Error("Audit wurde nicht gefunden.");
}
await ctx.db.patch(args.auditId, {
checkedDomain: args.checkedDomain,
checkedPages: args.checkedPages,
internalSummary: args.internalSummary,
multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary,
publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries,
updatedAt: now,
});
return args.auditId;
}
const safeCheckedDomain = args.checkedDomain.trim().toLowerCase();
const domainTag = safeCheckedDomain.length > 0
? safeCheckedDomain.replace(/[^a-z0-9]+/g, "-").slice(0, 50)
: "lead";
let slug = `audit-${domainTag}-${args.leadId}-${now}`;
const slugCandidates = await ctx.db
.query("audits")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.take(1);
if (slugCandidates.length > 0) {
slug = `${slug}-${Math.floor(now / 1_000)}`;
}
return await ctx.db.insert("audits", {
leadId: args.leadId,
status: "draft",
slug,
checkedDomain: args.checkedDomain,
checkedPages: args.checkedPages,
internalSummary: args.internalSummary,
multimodalSummary: args.multimodalSummary,
publicSummary: args.publicSummary,
publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
usedSkills: args.usedSkills,
skillSummaries: args.skillSummaries,
createdAt: now,
updatedAt: now,
});
},
});
export const getBySlug = query({ export const getBySlug = query({
args: { slug: v.string() }, args: { slug: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await requireOperator(ctx);
const audits = await ctx.db const audits = await ctx.db
.query("audits") .query("audits")
.withIndex("by_slug", (q) => q.eq("slug", args.slug)) .withIndex("by_slug", (q) => q.eq("slug", args.slug))
@@ -61,6 +514,184 @@ export const getBySlug = query({
}, },
}); });
export const getPublicBySlug = query({
args: { slug: v.string() },
handler: async (ctx: QueryCtx, args) => {
const audit = await ctx.db
.query("audits")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.unique();
if (!audit) {
return null;
}
if (audit.status === "deactivated") {
return { publicationStatus: "deactivated" as const };
}
if (audit.status !== "published") {
return { publicationStatus: "draft" as const };
}
const lead = await ctx.db.get(audit.leadId);
const screenshots = await ctx.db
.query("auditScreenshots")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.order("desc")
.take(8);
const publicScreenshots = [];
for (const screenshot of screenshots) {
const url = await ctx.storage.getUrl(screenshot.storageId);
if (!url) {
continue;
}
publicScreenshots.push({
id: screenshot.storageId,
url,
alt: screenshotAlt(screenshot.viewport, screenshot.sourceUrl),
viewport: screenshot.viewport,
sourceUrl: screenshot.sourceUrl,
width: screenshot.width,
height: screenshot.height,
});
}
return {
publicationStatus: "published" as const,
companyName: lead?.companyName ?? audit.checkedDomain,
domain: audit.checkedDomain,
publishedAt: toIsoDate(audit.publishedAt, audit.updatedAt),
publicContent: publicContentForAudit(audit),
screenshots: publicScreenshots,
};
},
});
export const savePublicAuditContent = mutation({
args: {
id: v.id("audits"),
publicSummary: v.optional(v.string()),
publicBody: v.optional(v.string()),
publicObservations: v.optional(v.array(publicObservationValidator)),
publicOffer: v.optional(publicOfferValidator),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
publicSummary: args.publicSummary,
publicBody: args.publicBody,
publicObservations: args.publicObservations,
publicOffer: args.publicOffer,
status: audit.status === "published" ? "approved" : audit.status,
updatedAt: now,
});
return args.id;
},
});
export const publishPublicAudit = mutation({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
status: "published",
publishedAt: now,
reviewDueAt: now + AUDIT_REVIEW_NOTICE_AFTER_MS,
lifecycleNotificationAt: undefined,
lifecycleExtendedUntil: undefined,
deactivatedAt: undefined,
updatedAt: now,
});
return { slug: audit.slug };
},
});
export const reapprovePublicAudit = mutation({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
status: "published",
publishedAt: now,
reviewDueAt: now + AUDIT_REVIEW_NOTICE_AFTER_MS,
lifecycleNotificationAt: undefined,
lifecycleExtendedUntil: undefined,
deactivatedAt: undefined,
updatedAt: now,
});
return { slug: audit.slug };
},
});
export const extendPublicAuditLifecycle = mutation({
args: {
id: v.id("audits"),
days: v.number(),
},
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
status: "published",
lifecycleExtendedUntil: now + args.days * 24 * 60 * 60 * 1000,
reviewDueAt: now + args.days * 24 * 60 * 60 * 1000,
deactivatedAt: undefined,
updatedAt: now,
});
return { slug: audit.slug };
},
});
export const deactivatePublicAudit = mutation({
args: { id: v.id("audits") },
handler: async (ctx, args) => {
await requireOperator(ctx);
const audit = await ctx.db.get(args.id);
if (!audit) {
throw new Error("Audit wurde nicht gefunden.");
}
const now = Date.now();
await ctx.db.patch(args.id, {
status: "deactivated",
deactivatedAt: now,
updatedAt: now,
});
return { slug: audit.slug };
},
});
export const list = query({ export const list = query({
args: { args: {
leadId: v.optional(v.id("leads")), leadId: v.optional(v.id("leads")),
@@ -68,6 +699,8 @@ export const list = query({
limit: v.optional(v.number()), limit: v.optional(v.number()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit); const limit = normalizeListLimit(args.limit);
if (args.leadId) { if (args.leadId) {
@@ -93,3 +726,105 @@ export const list = query({
return await ctx.db.query("audits").order("desc").take(limit); return await ctx.db.query("audits").order("desc").take(limit);
}, },
}); });
export const listDashboardRows = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx: QueryCtx, args): Promise<AuditDashboardRow[]> => {
await requireOperator(ctx);
const limit = normalizeListLimit(args.limit);
const audits = await ctx.db.query("audits").order("desc").take(limit);
const finalAuditLeadIds = new Set<string>();
const finalAuditRunIds = new Set<string>();
const finalAuditIds = new Set<string>();
const rows: AuditDashboardRow[] = audits.map((audit) => {
finalAuditLeadIds.add(audit.leadId);
finalAuditIds.add(audit._id);
return {
kind: "audit",
id: audit._id,
auditId: audit._id,
slug: audit.slug,
title: audit.slug,
checkedDomain: audit.checkedDomain,
status: audit.status,
pageCount: audit.checkedPages.length,
checkedPages: audit.checkedPages,
detailHref: `/dashboard/audits/${audit._id}`,
createdAt: audit.createdAt,
updatedAt: audit.updatedAt,
};
});
for (const audit of audits) {
const linkedGenerations = await ctx.db
.query("auditGenerations")
.withIndex("by_auditId", (q) => q.eq("auditId", audit._id))
.take(20);
for (const generation of linkedGenerations) {
finalAuditRunIds.add(generation.runId);
}
}
const generationRuns = await ctx.db
.query("agentRuns")
.withIndex("by_type", (q) => q.eq("type", "audit_generation"))
.order("desc")
.take(limit);
for (const run of generationRuns) {
if (!run.leadId) {
continue;
}
const directFinalAudit = run.auditId ? await ctx.db.get(run.auditId) : null;
const leadFinalAudits = await ctx.db
.query("audits")
.withIndex("by_leadId", (q) => q.eq("leadId", run.leadId as Id<"leads">))
.take(1);
if (
finalAuditRunIds.has(run._id) ||
(run.auditId && finalAuditIds.has(run.auditId)) ||
directFinalAudit ||
finalAuditLeadIds.has(run.leadId) ||
leadFinalAudits.length > 0
) {
continue;
}
const stages = await ctx.db
.query("auditGenerations")
.withIndex("by_runId", (q) => q.eq("runId", run._id))
.order("desc")
.take(20);
const latestStage = latestGenerationStage(stages);
const lead = await ctx.db.get(run.leadId);
const checkedDomain = domainFromLead(lead);
rows.push({
kind: "generation",
id: run._id,
runId: run._id,
leadId: run.leadId,
title: lead?.companyName ?? checkedDomain,
checkedDomain,
status: run.status,
latestStage: latestStage?.stage ?? run.currentStep ?? "audit_generation",
stageStatus: latestStage?.status ?? run.status,
errorSummary: run.errorSummary ?? latestStage?.errorSummary ?? null,
pageCount: 0,
checkedPages: [],
createdAt: run.createdAt,
updatedAt: Math.max(run.updatedAt, latestStage?.updatedAt ?? 0),
});
}
return rows.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
},
});

Some files were not shown because too many files have changed in this diff Show More