Surface audit generations on dashboard audits

This commit is contained in:
2026-06-06 18:14:27 +02:00
parent 3efbc06e40
commit e9463e8ef2
20 changed files with 3181 additions and 38 deletions

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

@@ -4,15 +4,16 @@ import { useMemo } from "react";
import { useQuery } from "convex/react";
import { FunctionReturnType } from "convex/server";
import { Files, SquarePen } from "lucide-react";
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";
type AuditsListResult = FunctionReturnType<typeof api.audits.list>;
type AuditRow = NonNullable<AuditsListResult>[number];
type AuditDashboardRowsResult = FunctionReturnType<typeof api.audits.listDashboardRows>;
type AuditRow = Extract<NonNullable<AuditDashboardRowsResult>[number], { kind: "audit" }>;
type AuditDashboardRow = NonNullable<AuditDashboardRowsResult>[number];
const statusText: Record<string, string> = {
draft: "Entwurf",
@@ -23,14 +24,48 @@ const statusText: Record<string, string> = {
const fallbackStatus = "Unbekannt";
function formatPageCount(pages: AuditRow["checkedPages"]) {
return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`;
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">
@@ -51,16 +86,16 @@ function AuditsBoardLoading() {
}
export function AuditsBoard() {
const audits = useQuery(api.audits.list, { limit: 100 });
const dashboardRows = useQuery(api.audits.listDashboardRows, { limit: 100 });
const rows = useMemo(() => {
if (!audits) {
if (!dashboardRows) {
return [];
}
return [...audits].sort((a, b) => b.createdAt - a.createdAt);
}, [audits]);
return [...dashboardRows].sort((a, b) => b.updatedAt - a.updatedAt);
}, [dashboardRows]);
if (audits === undefined) {
if (dashboardRows === undefined) {
return <AuditsBoardLoading />;
}
@@ -75,8 +110,8 @@ export function AuditsBoard() {
<article className="rounded-lg border p-4">
<h2 className="text-sm font-medium">Noch keine Audits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sobald neue Audits angelegt wurden, erscheinen sie hier als kompakte
Zeilen.
Sobald neue Audits oder laufende Audit-Generierungen angelegt
wurden, erscheinen sie hier als kompakte Zeilen.
</p>
</article>
</section>
@@ -90,40 +125,70 @@ export function AuditsBoard() {
<h1 className="text-2xl font-semibold tracking-normal">Audits</h1>
</header>
<section className="space-y-2">
<div className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<section className="space-y-2 overflow-x-auto">
<div className="grid min-w-[840px] grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_170px_150px_auto] gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<span>Slug</span>
<span>Domain</span>
<span>Status</span>
<span>Seitenanzahl</span>
<span>Seiten/Phase</span>
<span className="text-right">Aktion</span>
</div>
<div className="space-y-2">
{rows.map((audit: AuditRow) => (
{rows.map((row: AuditDashboardRow) => (
<article
className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_120px_120px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
key={audit._id}
className="grid min-w-[840px] grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_170px_150px_auto] items-center gap-2 rounded-lg border px-3 py-2 text-sm"
key={row.id}
>
<div className="min-w-0">
<p className="truncate font-medium">{audit.slug}</p>
<p className="truncate font-medium">{row.title}</p>
{row.kind === "generation" ? (
<p className="truncate text-xs text-muted-foreground">
Run {row.runId}
</p>
) : null}
</div>
<p className="truncate text-muted-foreground">{audit.checkedDomain}</p>
<Badge variant="secondary">{getStatusLabel(audit.status)}</Badge>
<p className="truncate text-muted-foreground">{row.checkedDomain}</p>
<Badge
className="h-auto min-h-6 justify-center whitespace-normal text-center"
variant="secondary"
>
{row.kind === "audit"
? getStatusLabel(row.status)
: getGenerationStatusLabel(row)}
</Badge>
<p className="text-muted-foreground">
<span className="inline-flex items-center gap-1">
{row.kind === "audit" ? (
<>
<Files className="size-3.5" />
{formatPageCount(audit.checkedPages)}
{formatPageCount(row.pageCount)}
</>
) : (
<>
<Activity className="size-3.5" />
{getStageLabel(row.latestStage)}
</>
)}
</span>
{row.kind === "generation" && row.errorSummary ? (
<span className="mt-1 block truncate text-xs">
{row.errorSummary}
</span>
) : null}
</p>
<div className="flex justify-end">
{row.kind === "audit" ? (
<Link
className="inline-flex min-h-8 items-center gap-1 text-sm text-primary"
href={`/dashboard/audits/${audit._id}`}
href={row.detailHref}
>
<SquarePen className="size-4" />
Öffnen
</Link>
) : (
<span className="text-sm text-muted-foreground">Pipeline</span>
)}
</div>
</article>
))}

View File

@@ -2,6 +2,7 @@ import { v } from "convex/values";
import { normalizeListLimit } from "./domain";
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;
@@ -40,13 +41,67 @@ const publicOfferValidator = v.object({
ctaHref: v.optional(v.string()),
});
const requireOperator = async (ctx: MutationCtx) => {
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 toIsoDate = (timestamp: number | undefined, fallback: number) => {
return new Date(timestamp ?? fallback).toISOString();
};
@@ -466,3 +521,105 @@ export const list = query({
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);
},
});

View File

@@ -30,7 +30,7 @@ test("audits dashboard page uses a dedicated board component", async () => {
);
});
test("audits board renders compact list with convex list query and core columns", async () => {
test("audits board renders compact list with dashboard rows query and core columns", async () => {
const boardSource = await source("components/audits/audits-board.tsx");
assert.match(
@@ -40,13 +40,13 @@ test("audits board renders compact list with convex list query and core columns"
);
assert.match(
boardSource,
/useQuery\s*\(\s*api\.audits\.list,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.list with { limit: 100 }.",
/useQuery\s*\(\s*api\.audits\.listDashboardRows,\s*\{\s*limit:\s*100\s*\}\s*\)/,
"AuditsBoard should call api.audits.listDashboardRows with { limit: 100 }.",
);
assert.match(
boardSource,
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.createdAt\s*-\s*a\.createdAt\)/,
"Audits should be sorted newest first.",
/sort\(\(\s*a,\s*b\s*\)\s*=>\s*b\.updatedAt\s*-\s*a\.updatedAt\)/,
"Dashboard rows should be sorted newest first.",
);
assert.match(boardSource, /Loading|lädt|Lade/i);
assert.match(boardSource, /Keine Audits|keine Audits/i);
@@ -54,10 +54,20 @@ test("audits board renders compact list with convex list query and core columns"
assert.match(boardSource, /Domain/);
assert.match(boardSource, /Status/);
assert.match(boardSource, /Seiten/);
assert.match(boardSource, /Generierung läuft|Generierung laeuft/);
assert.match(boardSource, /Fehlgeschlagen/);
assert.match(boardSource, /Wartet auf finales Audit/);
assert.match(boardSource, /Wartet auf Start/);
assert.match(boardSource, /Abgebrochen/);
assert.match(
boardSource,
/href=\{`\/dashboard\/audits\/\$\{audit\._id\}`\}/,
"Each audit row should link to /dashboard/audits/{id}.",
/href=\{row\.detailHref\}/,
"Final audit rows should link through their detailHref.",
);
assert.match(
boardSource,
/row\.kind\s*===\s*"audit"/,
"Board should branch between final audit rows and generation rows.",
);
});

View File

@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import test from "node:test";
const source = async (relativePath: string) => {
return await readFile(
join(process.cwd(), ...relativePath.split("/")),
"utf8",
);
};
function extractExportSource(sourceText: string, name: string) {
const marker = `export const ${name} = `;
const declarationIndex = sourceText.indexOf(marker);
assert.notEqual(declarationIndex, -1, `Expected declaration for ${name}.`);
const openBraceIndex = sourceText.indexOf("{", declarationIndex);
let depth = 0;
let end = -1;
for (let index = openBraceIndex; index < sourceText.length; index += 1) {
const char = sourceText[index];
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
end = index;
break;
}
}
}
assert.notEqual(end, -1, `Expected balanced braces for ${name}.`);
return sourceText.slice(openBraceIndex, end + 1);
}
test("audits dashboard query combines final audits with generation runs", async () => {
const auditsSource = await source("convex/audits.ts");
const querySource = extractExportSource(auditsSource, "listDashboardRows");
assert.match(
auditsSource,
/export const listDashboardRows = query/,
"Dashboard rows should be exposed as a public Convex query.",
);
assert.match(
querySource,
/requireOperator\(ctx\)/,
"Dashboard rows should require an authenticated operator.",
);
assert.match(
auditsSource,
/ctx\.auth\.getUserIdentity\(\)/,
"Operator checks should derive identity from Convex auth.",
);
assert.match(
querySource,
/kind:\s*"audit"/,
"Final audit documents should be returned as audit rows.",
);
assert.match(
querySource,
/kind:\s*"generation"/,
"Audit generation runs should be returned as generation rows.",
);
assert.match(
querySource,
/\.query\("audits"\)/,
"Dashboard rows should read finalized audits.",
);
assert.match(
querySource,
/\.query\("agentRuns"\)[\s\S]*\.withIndex\("by_type"/,
"Dashboard rows should read audit_generation runs through the type index.",
);
assert.match(
querySource,
/\.query\("auditGenerations"\)[\s\S]*\.withIndex\("by_runId"/,
"Dashboard rows should load generation stages by run id.",
);
});
test("audits dashboard query suppresses generation rows once a final audit exists", async () => {
const auditsSource = await source("convex/audits.ts");
const querySource = extractExportSource(auditsSource, "listDashboardRows");
assert.match(
querySource,
/finalAuditRunIds/,
"Query should track run ids that already have finalized audits.",
);
assert.match(
querySource,
/finalAuditLeadIds/,
"Query should track lead ids that already have finalized audits.",
);
assert.match(
querySource,
/\.query\("audits"\)[\s\S]*\.withIndex\("by_leadId"/,
"Query should suppress generation rows even when the finalized audit is outside the fetched dashboard page.",
);
assert.match(
querySource,
/ctx\.db\.get\(run\.auditId\)/,
"Query should suppress generation rows that directly reference an existing audit.",
);
assert.match(
querySource,
/continue/,
"Query should skip duplicate generation rows instead of returning them.",
);
assert.match(
querySource,
/latestStage/,
"Generation rows should surface the latest generation stage.",
);
assert.match(
querySource,
/errorSummary/,
"Generation rows should surface run or stage errors.",
);
});

View File

@@ -0,0 +1,381 @@
# Product Requirements Document: PitchFast.io
### Lokaler Akquise-Agent für Webdesign — SaaS-Edition (Deutschland zuerst)
**Version:** 3.1 · **Stand:** Juni 2026 · **Status:** Planung
**Vorgänger:** v1 (internes Tool, Playwright-self-hosted) → v2 (serverless, AppSumo-Vorbereitung) → v3 (Multi-Tenant-SaaS, DE-first, Compliance- und Unit-Economics-gehärtet) → **v3.1 (SaaS-Abo als primäres Geschäftsmodell durchgerechnet, LTD sekundär)**
> **Was sich gegenüber v2 ändert:** v2 hatte den *technischen* Stack auf serverlos umgestellt, aber Geschäftsmodell, Recht und Mandantenfähigkeit noch wie beim internen Tool behandelt. v3 schließt diese Lücken: echtes Multi-Tenant-Modell, ein durchgerechnetes Geschäftsmodell mit realen API-Kosten (**SaaS-Abo als primäres Modell mit ≥75 % Bruttomarge; LTD nur sekundär und BYO-only**), Compliance als Produktfeature statt als Disclaimer, sowie ein **BYO-Architekturprinzip** (eigene API-Keys + eigenes Postfach pro Kunde), das gleichzeitig das Unit-Economics-Problem *und* das Haftungsproblem löst.
---
## 1. Produktvision und Problemstellung
PitchFast.io ist eine deutsche B2B-SaaS-Plattform, die Webdesignern, kleinen Agenturen und SEO-Freelancern hochwertige lokale Akquise ermöglicht — ohne generische Massenmails. Das Tool findet lokale Unternehmen, führt ein nachvollziehbares Website-Mini-Audit durch (Struktur, Copy, Performance, visueller Eindruck) und bereitet daraus persönliche, datengestützte Erstkontakte vor, die der Nutzer manuell freigibt und versendet.
**Kernproblem:** Manuelle Website-Analyse und echte Personalisierung kosten pro Lead Stunden. Generischer Massen-Spam konvertiert nicht, schadet der Absender-Reputation und ist in Deutschland rechtlich riskant.
**Lösung:** PitchFast automatisiert Recherche und Audit über eine schlanke, serverlose Pipeline und befähigt Nutzer zu chirurgisch präzisem, „account-based" Outreach in kleiner Stückzahl. Qualität vor Menge ist nicht nur Positionierung, sondern reduziert zugleich Kosten- und Rechtsrisiko.
---
## 2. Strategische Leitplanken
Diese Prinzipien sind übergeordnet und entscheiden in Zweifelsfällen die Detailplanung:
1. **Deutschland zuerst — aber markt-konfigurierbar, nicht markt-hartkodiert.** Deutschland ist eine der strengsten Anti-Spam-Zonen (UWG). Wir bauen compliant-by-default für DE. Internationale Expansion ist *kein* Gratis-Carry-over (CASL/Kanada ist auf der Consent-Achse strenger, CAN-SPAM/USA laxer-aber-kompetitiver), deshalb sind Versandregeln, Opt-out-Fristen und Rechtsgrundlagen-Logik pro Markt konfigurierbar, nicht fest verdrahtet.
2. **BYO everything als Default.** Kunden bringen ihre eigenen API-Keys (Google, OpenRouter, ScreenshotOne) und ihr eigenes Postfach mit. Das verschiebt variable Kosten *und* rechtliche Verantwortung an den Kunden und macht das LTD-Modell tragfähig. Ein „Managed"-Modus mit aufgeschlagenen Credits ist die kostenpflichtige Alternative für nicht-technische Nutzer.
3. **Qualität vor Menge.** Harte, niedrige Limits pro Lauf. Kein „Unlimited".
4. **Kein Autopilot.** Versand niemals ohne manuelle Freigabe — architektonisch erzwungen, nicht nur per Policy.
5. **Compliance als Feature.** Quellen-Dokumentation, Opt-out, Impressums-Block, Sperrfristen und AVV sind verkaufbare Funktionen, kein Kleingedrucktes.
6. **Dogfooding.** Matthias ist erster Nutzer und Referenzkunde; jedes Feature muss seinen eigenen Akquiseprozess real verbessern, bevor es verallgemeinert wird.
---
## 3. Zielgruppe und Positionierung
### 3.1 MVP-Nutzer / Referenzkunde: Matthias Meister
Solo-Freelancer im Webdesign (Crimmitschau). Braucht ein dichtes, scanbares Arbeits-Dashboard, das Recherche, Audit, Review, Freigabe, Versand und Follow-up bündelt. Validiert das Produkt im echten Einsatz.
### 3.2 SaaS-Zielgruppe (DE)
Wachstumsorientierte Webdesigner, kleine Web-/Digitalagenturen und SEO-Freelancer in Deutschland, die einen skalierbaren, aber sauberen Outbound-Kanal suchen und keine eigene Audit-/Outreach-Pipeline bauen wollen.
### 3.3 Positionierung
- **Boutique-Pricing, Qualität statt Volumen.** Ein gewonnener Webdesign-Auftrag ist mehrere tausend Euro wert; das Tool muss nur wenige Abschlüsse ermöglichen, um sich zu rechnen.
- **„Compliant German Outreach" als USP.** In einem Markt, in dem Abmahnungen real sind, ist eingebaute Rechtskonformität ein Verkaufsargument, kein Bremsklotz.
- **Nachhaltige Limits statt „Unlimited"-Versprechen.** Schützt die Marge und die Reputation aller Nutzer.
### 3.4 AppSumo-Eignung (Hinweis)
Ein AppSumo-Launch bleibt eine *Option*, ist aber nicht handlungsleitend für das MVP. Falls verfolgt, gilt das LTD-Modell in Abschnitt 4. Wichtig: AppSumo erzwingt vollständige Mandantenfähigkeit, Self-Service-Onboarding, Lizenz-Code-Einlösung und belastbare Unit Economics — alles in v3 berücksichtigt. AppSumos Umsatzbeteiligung (Planungsannahme ~30 %, vor Launch verifizieren) und die LTD-Mechanik sind in Abschnitt 4 eingerechnet.
---
## 4. Geschäftsmodell und Unit Economics
> **Dies ist die Gating-Sektion.** Wenn die Stückkosten das Preismodell nicht tragen, ist alles andere irrelevant.
### 4.1 Reale API-Kosten (Stand Mitte 2026 — vor Launch erneut verifizieren)
Alle Preise in USD (Quellwährung der Anbieter). EUR ≈ USD × 0,92.
**Pro Deep-Audit:**
| Komponente | Annahme | Kosten |
|---|---|---|
| LLM (GPT-5.4 mini, OpenRouter): $0,75/M Input, $4,50/M Output | ~1520k Input-Token (Markdown 4 Seiten + 2 Screenshots als Vision-Input + skills.md) + ~35k Output inkl. Reasoning | **~$0,0250,035** |
| Screenshots (ScreenshotOne ~$0,0085/Shot) | Desktop + Mobile = 2 Shots | **~$0,017** |
| Copy-Extraktion (Jina AI Reader) | 4 Seiten, token-/anfragebasiert | **~$0,0020,005** |
| PageSpeed Insights | kostenlose Quota | **$0,00** |
| **Summe pro Audit** | | **~$0,0450,06** |
**Pro Lead (Recherche):**
| Komponente | Annahme | Kosten |
|---|---|---|
| Text/Nearby Search (Places, Pro ~$0,032/Call, bis zu 20 Ergebnisse) | amortisiert auf ~20 Leads | **~$0,002/Lead** |
| Place Details (Pro ~$0,017/Call) | 1 Call je Lead | **~$0,017** |
| Contact Data SKU (~$0,003/Call, für Telefon/Website) | 1 je Lead | **~$0,003** |
| Geocoding (~$0,005/Call) | 1 je Kampagne, vernachlässigbar | **~$0,00** |
| **Summe pro qualifiziertem Lead** | | **~$0,020,025** |
**Realistische Gesamtkosten „Lead gefunden + auditiert + Outreach entworfen": ~$0,070,09.**
> ⚠️ Das ist das **3- bis 4-fache** der in v2 genannten $0,02. Das alte Erfolgskriterium „unter $0,02 pro Deep-Audit" war nicht haltbar und wurde in Abschnitt 17 ersetzt. Hinweis: Google hat 2026 das Places-Preismodell auf SKU-/Field-Mask-Basis umgestellt; Contact-/Atmosphere-Daten kosten extra, und die Free-Tier-Caps (pro SKU pro Monat) sind schnell aufgebraucht. Volumen treibt die Places-Kosten überproportional.
### 4.2 Drei Kosten-Modi: Wer trägt die variablen Kosten?
Die Stückkosten aus 4.1 muss jemand tragen. Daraus ergeben sich drei Modi:
1. **BYO-Keys (Default, empfohlen):** Der Kunde hinterlegt eigene Keys für Google/OpenRouter/ScreenshotOne. Die variablen Kosten fallen direkt bei ihm an. PitchFasts Grenzkosten pro Nutzer sinken auf ~Convex-Speicher/Compute ≈ nahe null. Löst zugleich das Haftungsthema (Keys = Verantwortung des Kunden). Ideal für die technik-affine Webdesigner-Zielgruppe.
2. **Managed (Aufpreis-Modell):** PitchFast nutzt eigene Keys und trägt die API-Kosten; der Preis muss sie mit gesunder Marge decken. Für nicht-technische Nutzer.
3. **Hybrid:** Software/Seat fix, teure Aktionen (Audits) ziehen aus einem konservativ dimensionierten Kontingent.
### 4.3 SaaS-Abo — primäres Geschäftsmodell (empfohlen)
Beim monatlichen Abo deckt die wiederkehrende Einnahme die variablen Kosten **jeden Monat erneut**. Es gibt also kein „einmal zahlen, ewig kosten"-Problem — und selbst der Managed-Modus ist tragfähig. Konkrete Tiers (marktplausibel, Boutique-Positionierung):
| Tier (mtl.) | Kontingent/Monat | Sitze | Managed-Marge (Voll / realist.) | BYO-Marge | ARR |
|---|---|---|---|---|---|
| **Starter** $29 (≈€27) | 30 Audits, 150 Leads | 1 | 82 % / 93 % | 98 % | $348 |
| **Pro** $79 (≈€73) | 100 Audits, 500 Leads | 2 | 79 % / 93 % | 99 % | $948 |
| **Agency** $199 (≈€183) | 300 Audits, 1.500 Leads | 5 | 76 % / 93 % | 100 % | $2.388 |
Lesart: „Voll" = Kunde schöpft das Monatskontingent komplett aus (Worst Case für die Marge); „realist." = ~30 % Auslastung. Selbst im Worst Case bleibt die Managed-Bruttomarge **≥ 75 %**; BYO liegt auf Software-Niveau (~98100 %).
**Mindestpreis-Regel:** `Mindestpreis = COGS bei Vollnutzung ÷ (1 Ziel-Marge)`. Bei 75 % Ziel-Marge ergibt das pro Tier $21 / $66 / $193 als Preisboden — kein Tier darf darunter liegen. „Unlimited"-Tarife sind ausgeschlossen, weil sie die Vollnutzungs-Marge aushebeln.
**Empfehlung:** BYO-Keys als Default (höchste Marge, keine Kostenkopplung, beste Rechtsposition); Managed als bequemer, höherpreisiger Aufpreis-Tarif für nicht-technische Kunden. Jahreszahlung mit Rabatt anbieten.
### 4.4 Lifetime-Deal / AppSumo (optional, sekundär)
Ein LTD bleibt eine *Option* für einen Reichweiten-Push, ist dem Abo aber wirtschaftlich klar unterlegen und nur unter Auflagen vertretbar. Durchgerechnet (24 Monate Lebensdauer, 30 % Auslastung, 30 % AppSumo-Cut):
| Tier (einmalig) | Netto nach Cut | Managed-Lifetime-P&L | BYO-Lifetime-P&L |
|---|---|---|---|
| Tier 1 ($69) | $48 | **$78** | **+$36** |
| Tier 2 ($129) | $90 | **$265** | **+$78** |
| Tier 3 ($199) | $139 | **$1.016** | **+$127** |
**Managed-LTD ist in jedem Tier strukturell defizitär** (bei Vollnutzung katastrophal, Tier 3: $3.683). Falls überhaupt LTD, dann **ausschließlich BYO-Keys mit konservativen, nicht-rollierenden Kontingenten**. Zum Vergleich: Schon das günstigste Abo bringt im ersten Jahr ($348) mehr ein, als ein LTD Tier-1 je einbringt.
> Das vollständige, anpassbare Rechenmodell (Annahmen, Stückkosten, Abo- und LTD-Tiers) liegt als `PitchFast_Pricing_Modell.xlsx` bei. Alle Annahmen (blau) lassen sich frei drehen; Margen und Mindestpreise rechnen live nach.
---
## 5. Rechtlicher Rahmen und Compliance-by-Design
> **Kein Ersatz für Rechtsberatung.** Vor einem öffentlichen Launch ist eine Prüfung durch einen Fachanwalt für Wettbewerbs-/IT-Recht zwingend. Die folgenden Punkte sind Produktanforderungen, die Risiko reduzieren, nicht es eliminieren.
### 5.1 Zwei getrennte Rechtsregime (beide müssen erfüllt sein)
- **UWG § 7 (Versandakt / unlautere Belästigung):** E-Mail-Werbung an Unternehmen setzt grundsätzlich vorherige Einwilligung voraus; Telefonwerbung im B2B verlangt mindestens „mutmaßliche Einwilligung". Eine im Impressum veröffentlichte Geschäftsadresse ist **keine** Einwilligung, Werbung zu empfangen. → Das Produkt darf Kaltakquise nicht als rechtlich unbedenklich darstellen.
- **DSGVO (Datenverarbeitung):** Verarbeitung von Kontaktdaten kann im B2B über berechtigtes Interesse (Art. 6 Abs. 1 f) begründbar sein, erfordert aber Transparenz, Zweckbindung, Datenminimierung, dokumentierte Interessenabwägung und Wahrung der Betroffenenrechte.
### 5.2 Rolle von PitchFast als Anbieter
Sobald andere Nutzer Drittdaten über das Tool verarbeiten, wird PitchFast voraussichtlich **Auftragsverarbeiter** (teils evtl. gemeinsam Verantwortlicher). Daraus folgt:
- **AVV/DPA** mit jedem Kunden (Vertragsvorlage als Onboarding-Schritt).
- Eigenes Verarbeitungsverzeichnis, TOMs, Löschkonzept.
- **BYO-Keys/BYO-Mailbox** schwächt diese Rolle bewusst ab, weil der Kunde die Verarbeitung in eigener Infrastruktur ausführt.
### 5.3 Compliance-Features (Pflicht im MVP)
- **Keine erratenen/generierten E-Mail-Adressen.** Nur Adressen, die nachweislich öffentlich als geschäftlicher Kontakt ausgewiesen sind. Quelle wird pro Datensatz gespeichert (URL + Zeitstempel).
- **Opt-out-Mechanik** in jeder Outreach-Mail (klar, frictionless), plus interne Suppression-Liste, die einen Opt-out dauerhaft respektiert.
- **Pflicht-Absenderblock** (Name, ladungsfähige Anschrift, Impressumslink) automatisch in jede Mail.
- **Quellen-/Begründungsfeld** je Lead: Rechtsgrundlage und Herkunft dokumentiert (Nachweis der Interessenabwägung).
- **Blacklist** (Domain, E-Mail, Telefon, Firmenname, Place ID) und **Sperrfristen** („Nicht erneut kontaktieren" = 12 Monate hart, danach nur Vorschlag zur manuellen Prüfung).
- **Betroffenenrechte:** Lösch-/Auskunftsworkflow für angefragte Drittdaten.
- **Audit-Seiten:** `noindex`, keine Sitemap, kein öffentliches Verzeichnis, Lifecycle-Deaktivierung (30-Tage-Hinweis, 60-Tage-Deaktivierung).
### 5.4 Markt-Regel-Engine
Eine konfigurierbare `MarketConfig` kapselt pro Land: erlaubte Kontaktkanäle, Consent-Modell (opt-in/opt-out/presumed), Opt-out-Frist, Pflicht-Footer-Felder, Sperrfristen. DE ist die erste Konfiguration; weitere Märkte erst nach eigener Rechtsprüfung.
---
## 6. MVP-Scope und Core User Stories
### 6.1 Mandanten & Konten
- Als Nutzer möchte ich mich registrieren/einloggen und arbeite in einer isolierten Organisation; ich sehe ausschließlich meine eigenen Daten.
- Als Org-Inhaber möchte ich (perspektivisch) weitere Sitze einladen — im MVP genügt 1 Nutzer pro Org.
- Als Nutzer möchte ich meine eigenen API-Keys (Google, OpenRouter, ScreenshotOne) sicher hinterlegen (BYO-Modus) oder Managed-Credits nutzen.
### 6.2 Kampagnen
- Kampagne anlegen mit Kategorie/Nische (Dropdown + „Anderes"-Freitext), PLZ, Umkreis, Wiederholungsrhythmus, max. neue Leads/Lauf, max. Audits/Lauf.
- Geocoding der PLZ für die Radius-Suche; Berücksichtigung des 50-km-Radius-Limits und des 60-Ergebnis-Caps (automatische Subdivision bei größerem Gebiet).
- Wiederkehrende Ausführung per Cron + manueller „Jetzt ausführen"-Trigger.
### 6.3 Lead-Recherche & Qualifizierung
- Lokale Businesses über Google Places finden (kein Browser-Scraping).
- Priorisierung nach Website-Potenzial: keine Website, veraltet, schwache Mobile-Darstellung, schwache Kontaktführung, lokale SEO-Chancen; moderne Seiten = niedrige Priorität.
- Google-Bewertungen intern zur Priorisierung nutzen, extern nie anzeigen.
- Dublettenschutz + Schutz bereits kontaktierter Firmen + Blacklist-Abgleich.
- Leads ohne E-Mail in „Kontakt fehlt" sammeln statt verwerfen; bei Telefonnummer Skript zur manuellen Klärung erzeugen.
### 6.4 Website-Audit (serverlose Pipeline)
- Startseite + relevante Unterseiten (Kontakt, Impressum, Leistungen, Über uns) analysieren.
- Desktop- + Mobile-Screenshots erfassen und speichern.
- PageSpeed intern nutzen, extern keine Scores zeigen.
- Multimodales LLM bezieht Screenshots ein; lokale Design-/Marketing-Skills aus `skills.md`-Registry.
- Rohdaten (Prompts, Modellname, Rohantworten, Zwischenergebnisse) intern nachvollziehbar speichern.
### 6.5 Öffentliche Audit-Seiten
- Personalisierte Seite unter `audit.pitchfast.io/<org-slug>/<firmenname-ort>`.
- Default Entwurfsmodus; extern Hinweis „Dieser Audit ist noch nicht freigegeben", bis manuell freigegeben.
- `noindex`, Cache + Invalidierung bei Änderung; 30-/60-Tage-Lifecycle mit manueller Reaktivierung.
### 6.6 Outreach (BYO-Mailbox)
- Kontaktstrategie pro Lead: erst anrufen / direkt E-Mail / zurückstellen / nicht kontaktieren.
- Review-Workspace: E-Mail-Entwurf, Betreff, Audit-Link, Telefon-Skript — alles editierbar.
- Versand **aus dem eigenen verbundenen Postfach des Nutzers** (OAuth Gmail/Microsoft oder SMTP), erst nach Freigabe. Kein zentraler Versand.
- Respektvolles Follow-up vorbereitbar, ebenfalls nur nach Freigabe.
- Eingehende Antworten im MVP manuell markieren.
### 6.7 Dashboard
- Kanban/Funnel für Leads & Status.
- Kampagnenmetriken: Leads, Audits, gesendete Mails, Antworten, Gespräche, Angebote, Aufträge.
- Rybbit-Daten der eigenen Audit-Seiten im Dashboard (per API), ohne Rybbit separat zu öffnen.
- Credit-/Nutzungsanzeige + Fehlerstatus je externem Dienst.
### 6.8 Abrechnung & Lizenz
- Credit-Verbrauch messen (pro Audit/Lead), Kontingente durchsetzen.
- Abo-Verwaltung (Stripe) und/oder AppSumo-Code-Einlösung per Webhook (falls LTD-Launch).
---
## 7. Out of Scope (MVP)
- Mehr als 1 aktiver Nutzer pro Organisation (Datenmodell aber bereits multi-tenant).
- Automatische Inbox-/Antwort-Auswertung (JMAP-Threading).
- Vollwertiges CRM, Call-Queue mit Gesprächsnotizen.
- Automatischer Versand ohne manuelle Freigabe.
- Google-Maps-Scraping per Browser-Automation.
- Internationale Märkte außerhalb Deutschlands (Engine vorbereitet, Konfiguration nicht).
- Externes Error-Monitoring (Sentry) — optional später.
- Mehr als ein gleichzeitiger Agentenlauf pro Organisation.
---
## 8. Technische Architektur und Tech-Stack
### 8.1 Übersicht
```
Next.js (App Router) ──▶ Cloudflare Pages (Edge)
│ WebSockets (reaktiv)
CONVEX CLOUD ── DB (multi-tenant) · File Storage · Crons · Actions
│ triggert externe serverlose Dienste / Worker
┌─────────────┬───────────────┬───────────────┬──────────────┐
│ Google │ Jina AI │ ScreenshotOne │ OpenRouter │
│ Places/Geo/ │ (Markdown) │ (Screenshots) │ (GPT-5.4 mini)│
│ PageSpeed │ │ │ │
└─────────────┴───────────────┴───────────────┴──────────────┘
Transaktionsmail: Resend · Outreach-Versand: BYO-Mailbox (OAuth/SMTP)
Analytics: Rybbit (self-hosted, nur Audit-Seiten)
```
### 8.2 Komponenten
- **Frontend/Edge:** Next.js App Router, shadcn/ui, Tailwind, React Hook Form + Zod. Dashboard mit Light/Dark Mode; öffentliche Audit-Seiten fest hell/ruhig.
- ⚠️ *Entscheidung offen:* Next.js App Router auf Cloudflare Pages hat Reibung (OpenNext/`next-on-pages`, Edge-Runtime-Eigenheiten). Vercel ist der Weg des geringsten Widerstands, Cloudflare billiger bei Skalierung. → In Abschnitt 19 zu entscheiden.
- **Backend/Daten:** Convex Cloud. **Jedes Dokument trägt `orgId` (Pflicht); jede Query filtert nach `orgId`.** File Storage für Screenshots. Crons/Actions für Kampagnenläufe, Audit-Pipeline, Lifecycle-Regeln.
- **Auth:** Better Auth mit Convex; E-Mail/Passwort, Organisations-Scoping. Öffentliche Audit-Seiten ohne Login.
- **Agenten-/LLM-Schicht:** Vercel AI SDK als Orchestrierung, OpenRouter als Modellzugang, GPT-5.4 mini (Text+Vision), strukturierte Outputs via Zod. Modellprofile für Lead-Klassifikation, Audit-Analyse, multimodale Analyse, Textgenerierung, Qualitätscheck. `skills.md`-Registry (Name, Zweck, Einsatzbereich, Input, Output) im Repo; Dashboard zeigt pro Audit die verwendeten Skills.
- **Recherche/Analyse:** Google Geocoding + Places + PageSpeed Insights; Cheerio (schneller HTML-/SEO-First-Pass auf der Edge); Jina AI Reader (HTML→Markdown); ScreenshotOne (Desktop/Mobile, Cookie-/Pop-up-Filter). RapidAPI nur als optionaler Fallback.
- **Mail:** **Resend** für transaktionale Mails (App-Benachrichtigungen, „Audit fertig", Passwort-Reset). **BYO-Mailbox** für Outreach (Gmail/Microsoft via OAuth oder Kunden-SMTP). Kein gemeinsamer Outreach-Versand → schützt Reputation und Rechtsposition. (Stalwart entfällt als zentrale Komponente; Matthias kann sein eigenes Stalwart-Postfach im BYO-Modus verbinden.)
- **Analytics:** Rybbit (self-hosted), nur auf öffentlichen Audit-Seiten, anonym; Aggregation per API im Dashboard.
- **Deployment/Config:** Coolify oder Cloudflare (siehe offene Entscheidung). Secrets in Convex Secrets / Env; **Kunden-API-Keys verschlüsselt at rest, nie im Klartext im UI/Repo/Log**.
### 8.3 Kostenkontrolle (eingebaut)
- Harte Limits pro Kampagne (Leads/Audits pro Lauf).
- Nur ein aktiver Agentenlauf pro Org gleichzeitig.
- **Globaler Monats-Budget-Cap + Kill-Switch** je Org (greift auch im Managed-Modus).
- Kosten-Logging pro Lauf (Token-, Call-Zahlen) für reale Unit-Economics-Messung.
---
## 9. Konzeptionelles Datenmodell (multi-tenant)
> Jede Entität trägt `orgId` und (wo sinnvoll) `createdBy`. Alle Queries scopen auf `orgId`.
**Organization** — id, name, slug, plan, status, marketConfigId, createdAt
**User** — id, orgId, email, role, authRef
**ApiKeyVault** — orgId, provider (google/openrouter/screenshotone), encryptedKey, mode (byo/managed), status
**Subscription / CreditLedger** — orgId, plan, creditBalance, monthlyAllowance, allowanceResetAt, appsumoLicenseId?
**UsageEvent** — orgId, type (audit/lead_lookup), costEstimate, tokensIn/Out, callCounts, createdAt *(für Abrechnung + Unit-Economics)*
**MailboxConnection** — orgId, provider (gmail/microsoft/smtp), oauthRef/smtpConfig (encrypted), fromName, fromAddress, status
**MarketConfig** — id, country, consentModel, allowedChannels, optOutDeadlineDays, requiredFooterFields, suppressionMonths
**Campaign** — orgId, name, niche/freitext, plz, ort, coordinates, radius, schedule, maxLeadsPerRun, maxAuditsPerRun, status, lastRun, nextRun
**Lead** — orgId, campaignId, companyName, niche, address, googlePlaceId, source, domain, phone, email, emailSource (url+ts), contactPerson, priority, contactStatus, duplicateStatus, blocklistStatus, legalBasisNote, internalNotes
**Audit** — orgId, leadId, status (draft/approved/published/expired), slug, checkedDomain, checkedSubpages, screenshots[], pageSpeedRaw, cheerioFindings, jinaMarkdown, foundTextSnippets, usedSkills[], skillOutputs, multimodalAnalysis, finalSummary, publicAuditText, ctaType, publishedAt, noticeSentAt (30d), deactivatedAt (60d)
**Outreach** — orgId, leadId, auditId, contactStrategy, phoneScript, emailSubject, emailBody, followUpBody, approvalStatus, sentAt, mailboxConnectionId, replyStatus, salesStatus *(Follow-up geplant/gesendet, Antwort erhalten, Kein Interesse, Später, Gespräch vereinbart, Angebot angefragt/gesendet, Auftrag gewonnen/verloren, Nicht weiter verfolgen)*
**Suppression / OptOut** — orgId, value (email/domain), reason, createdAt *(dauerhaft respektiert)*
**Blacklist** — orgId, type (domain/email/phone/company/placeId), value, note, createdAt
**RunLog** — orgId, campaignId, status, errorByService (google/pagespeed/openrouter/screenshotone/jina/mail), startedAt, finishedAt
> **Google-Places-ToS-Hinweis:** Nur die `googlePlaceId` darf dauerhaft gespeichert werden; übrige Places-Felder unterliegen Caching-Beschränkungen (i. d. R. ~30 Tage) und müssen ggf. per ID-Refresh erneuert werden. Datenhaltung entsprechend gestalten.
---
## 10. Audit-Pipeline im Detail
Asynchroner Job, nicht synchroner Request (kein hartes 30-Sek-SLA — siehe Erfolgskriterien). Ablauf je Lead:
1. **Struktur-Check (Cheerio):** Roh-HTML parsen → Meta/Title/Headings/Viewport/strukturelle SEO-Signale. Billiger First-Pass.
2. **Copy-Extraktion (Jina):** Startseite + relevante Unterseiten → sauberes Markdown.
3. **Visuelle Erfassung (ScreenshotOne):** Desktop + Mobile, Cookie-/Pop-up-Filter. *Reliability-Hinweis:* ScreenshotOne hat in Benchmarks bei komplexen Seiten Ausfälle gezeigt — Fallback/Retry vorsehen, ggf. Alternativ-Provider (Urlbox o. ä.) für schwierige Seiten evaluieren. ScreenshotOne berechnet fehlgeschlagene Requests nicht.
4. **PageSpeed:** technische Performance-/Accessibility-Signale (intern).
5. **KI-Auswertung (GPT-5.4 mini):** Markdown + technische Signale + Screenshots (Vision) + `skills.md` → strukturierter Output (Zod): interne Befunde, öffentlicher Audit-Text, Mail-Entwurf, Betreff, Telefon-Skript, CTA-Typ.
6. **Qualitäts-Gate:** Anti-KI-Slop-Check (zweiter, billiger Modell-Pass oder Heuristiken) gegen Voice-Regeln.
7. **Persistenz:** Rohdaten + finale Texte in Convex; Job-Status reaktiv ans Dashboard; „fertig"-Notification via Resend.
Fehler je Stufe werden im `RunLog` sichtbar gemacht; der Lauf bricht sauber ab oder überspringt einzelne Leads nachvollziehbar.
---
## 11. UI/UX-Prinzipien und Accessibility
- Dashboard = funktionales Arbeitswerkzeug: dicht, scanbar, effizient, keine Marketing-Optik. Hauptansicht Kanban/Funnel. Review-Ansicht bündelt Lead-Daten, Audit, Mail, Betreff, Skript, Quellen, Freigabeaktionen.
- Öffentliche Audit-Seite: ruhiger als eine Marketing-Seite, persönlich, beratend, respektvoll. Keine Scores, Ampeln, Warnfarben, anklagende Sprache. Screenshots ohne Annotationen im Kontext der Befunde. Angebot + Link zur Hauptsite am Ende.
- Vollständige Tastaturbedienbarkeit, klare Labels/Fehlermeldungen, valide Kontraste, kein Textüberlauf auf Mobile/Desktop.
- Deutsche UI, für i18n vorbereitet.
---
## 12. Voice & Tone
Alle Mails/Audit-Texte in Ich-Form (Solo-Freelancer-Perspektive bzw. die des jeweiligen Nutzers). Stil: auf Augenhöhe, konkret, lokal/nahbar, respektvoll, nicht belehrend, frei von KI-Floskeln, ohne übertriebene Dringlichkeit, ohne Preise in der Erstansprache. Der Agent formuliert Beobachtungen und Vorschläge, keine Urteile; technische Befunde werden in Kundennutzen übersetzt (weniger Absprünge, bessere mobile Kontaktaufnahme, mehr Vertrauen, bessere lokale Auffindbarkeit).
---
## 13. Nicht-funktionale Anforderungen
- Mandantenisolation ist Pflichtinvariante (Tests dafür).
- Qualität vor Menge; kleine, harte Lead-/Audit-Limits.
- Ein aktiver Agentenlauf pro Org; Läufe abbrechbar oder nachvollziehbar fehlschlagend.
- Fehlerstatus je externem Dienst im Dashboard sichtbar.
- Öffentliche Audit-Seiten laden schnell, werden gecached, Invalidierung bei Änderung.
- Audit-Rohdaten intern nachvollziehbar.
- Globaler Budget-Cap + Kill-Switch je Org.
- DSGVO-Löschworkflow funktionsfähig.
---
## 14. Sicherheit, Mandantenisolation & Secrets
- `orgId`-Scoping in jeder Query; serverseitige Autorisierung, nie nur clientseitig.
- Kunden-API-Keys und SMTP-/OAuth-Credentials **verschlüsselt at rest**; nie im UI rückanzeigbar, nie in Logs.
- Plattform-Secrets in Convex Secrets / Env, nicht im Repo.
- Rate-Limiting/Abuse-Schutz pro Org (verhindert, dass ein Nutzer Kosten/Reputation anderer beeinflusst — im BYO-Modus ohnehin entkoppelt).
- Öffentliche Audit-Seiten: `noindex`, keine Sitemap, kein Verzeichnis, Slugs nicht erratbar (Hash-Anteil).
---
## 15. Entwicklungsphasen und Meilensteine
**M1 — Foundation & Multi-Tenancy:** Next.js-Dashboard-Gerüst, Convex Cloud, Better Auth mit Org-Scoping, `orgId` auf allen Modellen, Kampagnen-/Lead-Datenmodell, Basis-Kanban, ApiKeyVault (BYO-Keys-Eingabe, verschlüsselt).
**M2 — Recherche & Qualifizierung:** Geocoding, Places-Suche (mit Subdivision/Field-Mask-Kostenkontrolle), Kategorien + „Anderes", Dublettenprüfung, „Kontakt-fehlt", Blacklist, Quellen-/Rechtsgrundlagen-Feld.
**M3 — Audit-Pipeline:** Cheerio, Jina, ScreenshotOne, PageSpeed, `skills.md`, GPT-5.4-mini-Analyse (Vision), Rohdatenhaltung, async Job + RunLog.
**M4 — Audit-Seiten & Review:** öffentliche Seite, manuelle Freigabe, `noindex`/Cache, 30-/60-Tage-Lifecycle, Review-Workspace.
**M5 — Outreach (BYO-Mailbox) & Follow-up:** Mailbox-Connect (OAuth/SMTP), Mail-/Betreff-/Skript-Entwurf, Versand nach Freigabe, Follow-up, Suppression/Opt-out, manuelles Antwort-/Sales-Tracking, Pflicht-Footer.
**M6 — Abrechnung & Lizenz:** Credit-Metering, Kontingent-Durchsetzung, Budget-Cap/Kill-Switch, Stripe-Abo und/oder AppSumo-Webhook.
**M7 — Analytics & Compliance-Härtung:** Rybbit auf Audit-Seiten + API im Dashboard, Kampagnenmetriken, Anti-KI-Slop-Regeln, AVV-Vorlage + DSGVO-Löschworkflow, UI-/Accessibility-Review, Fachanwalt-Review vor Launch.
---
## 16. Technische Herausforderungen & Mitigation
- **API-Kosten/Limits:** BYO-Keys verlagern variable Kosten; harte Limits + Budget-Cap; Field-Mask-Disziplin bei Places (Contact-Data nur wenn nötig).
- **Schlechte/fehlende Kontaktdaten:** „Kontakt fehlt" + Telefon-Skript; keine geratenen Adressen.
- **Generische KI-Texte:** konkrete Website-Daten + Screenshots + Skills + Voice-Regeln + Qualitäts-Gate + manuelle Bearbeitung.
- **Fehlerhafte visuelle Interpretation:** multimodale Befunde mit DOM/Text/PageSpeed kreuzvalidieren; extern nur vorsichtig formulierte Beobachtungen.
- **Screenshot-Zuverlässigkeit:** Retry/Fallback-Provider, da Benchmarks Ausfälle bei komplexen Seiten zeigen.
- **Rechtliche Sensibilität:** keine Automatik, Quellenhaltung, manuelle Freigabe, zurückhaltende Sprache, Sperrfristen, BYO-Postfach, AVV, Fachanwalt vor Launch.
- **Mandanten-Datenlecks:** `orgId`-Invariante + Tests; verschlüsselte Secrets.
- **Next-auf-Cloudflare-Reibung:** früh ein Spike-Deployment, sonst Vercel.
- **LTD-Unwirtschaftlichkeit:** BYO-Keys-Default; Managed nur mit ≥2× Marge und nicht-rollierenden Kontingenten.
---
## 17. Erfolgskriterien für das MVP (überarbeitet, realistisch)
1. Matthias kann wiederkehrende lokale Kampagnen anlegen, ausführen und qualitativ passende Leads mit nachvollziehbaren Quellen erhalten.
2. Der Agent erstellt brauchbare Mini-Audits (Screenshots, PageSpeed-Kontext, konkrete Vorschläge); öffentliche Audit-Seiten wirken persönlich, ruhig, nicht anklagend.
3. **Reale Stückkosten gemessen und transparent:** Ziel-Korridor ~$0,050,08 pro „Lead + Audit" (nicht die alten $0,02); jeder Lauf loggt tatsächliche Kosten.
4. Mail/Betreff/Skript editierbar; **kein Versand ohne `approved`-Status**; Versand läuft über das verbundene Postfach des Nutzers.
5. Mandantenisolation nachweislich dicht (mind. 1 Test, der Cross-Org-Zugriff verhindert).
6. Compliance-Features funktionsfähig: Quellen-Doku, Opt-out + Suppression, Pflicht-Footer, Sperrfristen, Löschworkflow.
7. Unit-Economics-Modell bestätigt: Im gewählten Preis-/BYO-Modell ist ein aktiver Nutzer nicht strukturell defizitär.
8. Kampagnenmetriken + Rybbit-Daten geben schnellen Überblick über Wirkung und Fortschritt.
---
## 18. Zukünftige Erweiterungen
Mehrere Sitze/Teamrollen · zusätzliche Märkte (je mit eigener `MarketConfig` + Rechtsprüfung) · automatische Inbox-/Thread-Zuordnung (JMAP) · vollwertiges CRM · Call-Queue mit Notizen · automatisierte Re-Audits · erweiterte Rybbit-/Conversion-Auswertung · alternative Screenshot-/Scrape-Provider · zusätzliche Sprachen · self-hosted Convex für Enterprise.
---
## 19. Offene Entscheidungen & verwendete Annahmen
**Zu entscheiden:**
1. **Hosting:** Cloudflare Pages (billiger, mehr Reibung mit Next App Router) vs. Vercel (reibungslos, teurer). → Spike empfohlen.
2. **Preismodell:** SaaS-Abo ist gesetzt (Tiers/Margen siehe 4.3). Offen: finale Preispunkte & Kontingente, Ziel-Bruttomarge, ob ein BYO-LTD als sekundärer Reichweiten-Kanal überhaupt aufgelegt wird.
3. **BYO vs. Managed als Default** für die erste Zielgruppe (Empfehlung: BYO; Managed als Aufpreis-Tarif).
4. **Screenshot-Provider** final: ScreenshotOne vs. Alternative nach Reliability-Test.
5. **Fachanwalt-Mandat** vor Launch terminieren (UWG/DSGVO/AVV).
**Verwendete Annahmen (Unit Economics, vor Launch verifizieren):**
- GPT-5.4 mini: $0,75/M Input, $4,50/M Output (OpenRouter, Mitte 2026).
- ScreenshotOne: ~$0,0085/Screenshot.
- Google Places: Pro-SKUs Nearby/Text Search ~$0,032/Call, Place Details ~$0,017/Call, Contact-Data-SKU ~$0,003/Call; SKU-/Field-Mask-basiert; begrenzte monatliche Free-Caps.
- AppSumo-Umsatzbeteiligung: ~30 % (Planungswert).
- FX: EUR ≈ USD × 0,92.

225
v2_elemente/audit.ts Normal file
View File

@@ -0,0 +1,225 @@
/**
* convex/lib/audit.ts
*
* Externe Clients und reine Helfer für die Audit-Pipeline. Wird nur aus
* Convex *Actions* aufgerufen (fetch). Alles dependency-frei (kein "use node"),
* damit audits.ts Queries/Mutations/Actions in einer Datei halten kann.
*/
// ---- Kosten (Quelle: PitchFast_Pricing_Modell.xlsx) --------------------------
export const AUDIT_COSTS = {
llmInputPerMTokUsd: 0.75,
llmOutputPerMTokUsd: 4.5,
screenshotUsd: 0.0085,
jinaUsd: 0.003,
};
export function estimateAuditCostUsd(p: {
inputTokens: number;
outputTokens: number;
screenshots: number;
}): number {
return (
(p.inputTokens / 1_000_000) * AUDIT_COSTS.llmInputPerMTokUsd +
(p.outputTokens / 1_000_000) * AUDIT_COSTS.llmOutputPerMTokUsd +
p.screenshots * AUDIT_COSTS.screenshotUsd +
AUDIT_COSTS.jinaUsd
);
}
// ---- ScreenshotOne -----------------------------------------------------------
const SCREENSHOTONE_URL = "https://api.screenshotone.com/take";
/** Einen Screenshot holen (PNG-Bytes). Cookie-Banner/Ads werden gefiltert. */
export async function takeScreenshot(args: {
accessKey: string;
url: string;
device: "desktop" | "mobile";
}): Promise<Uint8Array> {
const params = new URLSearchParams({
access_key: args.accessKey,
url: args.url,
format: "png",
full_page: "true",
block_cookie_banners: "true",
block_ads: "true",
block_banners_by_heuristics: "true",
viewport_width: args.device === "mobile" ? "390" : "1280",
viewport_height: args.device === "mobile" ? "844" : "900",
device_scale_factor: args.device === "mobile" ? "2" : "1",
});
const res = await fetch(`${SCREENSHOTONE_URL}?${params.toString()}`);
if (!res.ok) {
throw new Error(`ScreenshotOne ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
return new Uint8Array(await res.arrayBuffer());
}
export function toDataUrl(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return `data:image/png;base64,${btoa(bin)}`;
}
// ---- Jina AI Reader (HTML → Markdown) ----------------------------------------
const COMMON_SUBPATHS = ["kontakt", "impressum", "leistungen", "ueber-uns"];
const MARKDOWN_CHAR_CAP = 12000;
/** Startseite + (falls vorhanden) relevante Unterseiten als Markdown. */
export async function fetchSiteMarkdown(args: {
jinaKey?: string;
baseUrl: string;
}): Promise<{ markdown: string; pages: string[] }> {
const headers: Record<string, string> = { "X-Return-Format": "markdown" };
if (args.jinaKey) headers.Authorization = `Bearer ${args.jinaKey}`;
const base = args.baseUrl.replace(/\/$/, "");
const urls = [base, ...COMMON_SUBPATHS.map((p) => `${base}/${p}`)];
const pages: string[] = [];
let combined = "";
for (const u of urls) {
if (combined.length >= MARKDOWN_CHAR_CAP) break;
try {
const res = await fetch(`https://r.jina.ai/${u}`, { headers });
if (!res.ok) continue;
const md = await res.text();
if (md && md.trim().length > 50) {
pages.push(u);
combined += `\n\n## Seite: ${u}\n${md}`;
}
} catch {
// Unterseite existiert nicht / Fehler → überspringen
}
}
return { markdown: combined.slice(0, MARKDOWN_CHAR_CAP), pages };
}
// ---- PageSpeed Insights ------------------------------------------------------
export async function fetchPageSpeed(args: {
googleKey: string;
url: string;
}): Promise<any> {
const params = new URLSearchParams({
url: args.url,
key: args.googleKey,
strategy: "mobile",
});
params.append("category", "performance");
params.append("category", "accessibility");
const res = await fetch(
`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${params.toString()}`,
);
if (!res.ok) throw new Error(`PageSpeed ${res.status}`);
const data = await res.json();
const lh = data.lighthouseResult ?? {};
const audits = lh.audits ?? {};
return {
performanceScore: lh.categories?.performance?.score ?? null,
accessibilityScore: lh.categories?.accessibility?.score ?? null,
lcp: audits["largest-contentful-paint"]?.displayValue ?? null,
cls: audits["cumulative-layout-shift"]?.displayValue ?? null,
inp: audits["interaction-to-next-paint"]?.displayValue ?? null,
};
}
// ---- Struktur-Check (regex, dependency-frei) ---------------------------------
export function parseStructuralSignals(html: string, url: string) {
const m = (re: RegExp) => (html.match(re)?.[1] ?? "").trim();
return {
title: m(/<title[^>]*>([^<]*)<\/title>/i) || null,
metaDescription:
m(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i) ||
m(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i) ||
null,
h1Count: (html.match(/<h1[\s>]/gi) ?? []).length,
hasViewport: /<meta[^>]+name=["']viewport["']/i.test(html),
isHttps: url.startsWith("https://"),
};
}
// ---- Multimodale Auswertung (OpenRouter, GPT-5.4 mini) -----------------------
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const MODEL = "openai/gpt-5.4-mini"; // OpenRouter-Slug ggf. verifizieren
export function buildSystemPrompt(skillsRegistry: string): string {
return [
"Du bist der Audit-Agent von PitchFast. Du beurteilst die Website eines lokalen",
"Dienstleisters anhand der folgenden Skill-Registry. Wähle alle zutreffenden",
"Skills, deren benötigte Eingaben vorliegen, und erzeuge Findings.",
"",
"VOICE: Ich-Form, auf Augenhöhe, respektvoll, ohne Floskeln, ohne Dringlichkeit,",
"ohne Preise. Beobachtung statt Urteil. Übersetze jeden Befund in Kundennutzen.",
"Severity ist INTERN — niemals im öffentlichen Text nennen, keine Scores/Ampeln.",
"",
"Gib AUSSCHLIESSLICH valides JSON zurück, ohne Markdown-Fences, exakt in diesem Schema:",
`{
"findings": [{"skill_id": string, "observation": string, "customer_benefit": string,
"public_phrasing": string, "severity": 1|2|3, "evidence": string}],
"finalSummary": string, // intern, vollständig
"publicAuditText": string, // extern, voice-konform, wenige konkrete Punkte
"emailSubject": string,
"emailBody": string, // Ich-Form, persönlich, ohne Preise
"phoneScript": string,
"ctaType": string, // z.B. "anruf" | "termin" | "rueckruf"
"usedSkills": [string]
}`,
"",
"=== SKILL-REGISTRY (skills.md) ===",
skillsRegistry,
].join("\n");
}
export async function runMultimodalAnalysis(args: {
openRouterKey: string;
systemPrompt: string;
textContext: string;
desktopDataUrl: string;
mobileDataUrl: string;
}): Promise<{ json: any; inputTokens: number; outputTokens: number }> {
const res = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${args.openRouterKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: args.systemPrompt },
{
role: "user",
content: [
{ type: "text", text: args.textContext },
{ type: "image_url", image_url: { url: args.desktopDataUrl } },
{ type: "image_url", image_url: { url: args.mobileDataUrl } },
],
},
],
}),
});
if (!res.ok) {
throw new Error(`OpenRouter ${res.status}: ${(await res.text()).slice(0, 300)}`);
}
const data = await res.json();
const content: string = data.choices?.[0]?.message?.content ?? "{}";
const clean = content.replace(/```json|```/g, "").trim();
let json: any;
try {
json = JSON.parse(clean);
} catch {
throw new Error("LLM-Antwort war kein valides JSON");
}
return {
json,
inputTokens: data.usage?.prompt_tokens ?? 0,
outputTokens: data.usage?.completion_tokens ?? 0,
};
}

419
v2_elemente/audits.ts Normal file
View File

@@ -0,0 +1,419 @@
/**
* convex/audits.ts
*
* Audit-Pipeline (M3). Dockt am Hook in campaigns.ts an: runAuditBatch.
* Ablauf je Lead: Struktur-Check → Screenshots → Markdown → PageSpeed →
* multimodale Auswertung gegen skills.md → strukturiertes Ergebnis.
*
* Convex-Pattern: Actions machen fetch + storage + Entschlüsselung; die DB
* läuft über runQuery/runMutation. Mandanten-Invariante: alles orgId-gescoped.
*/
import { v } from "convex/values";
import {
query,
mutation,
internalAction,
internalMutation,
internalQuery,
} from "./_generated/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { decryptSecret } from "./lib/crypto";
import {
takeScreenshot,
toDataUrl,
fetchSiteMarkdown,
fetchPageSpeed,
parseStructuralSignals,
buildSystemPrompt,
runMultimodalAnalysis,
estimateAuditCostUsd,
} from "./lib/audit";
// Build-Step erzeugt diese Datei aus skills.md (z. B. prebuild-Skript):
// echo "export const SKILLS_REGISTRY = \`$(cat skills.md)\`;" > convex/lib/skillsRegistry.ts
import { SKILLS_REGISTRY } from "./lib/skillsRegistry";
function normalizeUrl(domain: string): string {
return domain.startsWith("http") ? domain : `https://${domain}`;
}
function slugify(s: string): string {
return s
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
// ---- Interne DB-Helfer -------------------------------------------------------
export const getAuditContext = internalQuery({
args: { orgId: v.id("organizations"), leadId: v.id("leads") },
handler: async (ctx, { orgId, leadId }) => {
const lead = await ctx.db.get(leadId);
if (!lead || lead.orgId !== orgId) return null;
const org = await ctx.db.get(orgId);
const keyFor = async (provider: "google" | "openrouter" | "screenshotone") => {
const e = await ctx.db
.query("apiKeyVault")
.withIndex("by_org_provider", (q) =>
q.eq("orgId", orgId).eq("provider", provider),
)
.first();
return e?.encryptedKey ?? null;
};
return {
lead,
killSwitch: org?.killSwitch ?? false,
googleCipher: await keyFor("google"),
screenshotoneCipher: await keyFor("screenshotone"),
openrouterCipher: await keyFor("openrouter"),
// Jina ist optional; falls als eigener Provider geführt, hier ergänzen.
};
},
});
/** Hochpriorisierte Leads einer Kampagne ohne bestehendes Audit. */
export const getLeadsForAudit = internalQuery({
args: {
orgId: v.id("organizations"),
campaignId: v.id("campaigns"),
limit: v.number(),
},
handler: async (ctx, { orgId, campaignId, limit }) => {
const leads = await ctx.db
.query("leads")
.withIndex("by_campaign", (q) => q.eq("campaignId", campaignId))
.filter((q) => q.neq(q.field("priority"), "low"))
.collect();
const result: Id<"leads">[] = [];
for (const lead of leads) {
if (result.length >= limit) break;
if (lead.orgId !== orgId) continue;
if (!lead.domain) continue; // ohne Website kein Website-Audit
const existing = await ctx.db
.query("audits")
.withIndex("by_lead", (q) => q.eq("leadId", lead._id))
.first();
if (!existing) result.push(lead._id);
}
return result;
},
});
export const createAuditDraft = internalMutation({
args: { orgId: v.id("organizations"), leadId: v.id("leads") },
returns: v.object({
auditId: v.id("audits"),
alreadyExisted: v.boolean(),
}),
handler: async (ctx, { orgId, leadId }) => {
const existing = await ctx.db
.query("audits")
.withIndex("by_lead", (q) => q.eq("leadId", leadId))
.first();
if (existing) return { auditId: existing._id, alreadyExisted: true };
const lead = await ctx.db.get(leadId);
if (!lead || lead.orgId !== orgId) throw new Error("Lead nicht berechtigt");
// Deterministischer, aber nicht erratbarer Slug (Hash-Anteil aus der opaken _id).
const hash = leadId.toString().slice(-6);
const slug = `${slugify(lead.companyName)}-${slugify(lead.ort ?? "")}-${hash}`
.replace(/-+/g, "-");
const auditId = await ctx.db.insert("audits", {
orgId,
leadId,
status: "draft",
slug,
});
return { auditId, alreadyExisted: false };
},
});
export const saveAuditResult = internalMutation({
args: {
orgId: v.id("organizations"),
auditId: v.id("audits"),
leadId: v.id("leads"),
checkedDomain: v.string(),
checkedSubpages: v.array(v.string()),
desktopStorageId: v.optional(v.id("_storage")),
mobileStorageId: v.optional(v.id("_storage")),
pageSpeedRaw: v.optional(v.any()),
cheerioFindings: v.optional(v.any()),
jinaMarkdown: v.optional(v.string()),
result: v.any(), // geparste LLM-JSON
costUsd: v.number(),
tokensIn: v.number(),
tokensOut: v.number(),
},
handler: async (ctx, a) => {
const r = a.result ?? {};
await ctx.db.patch(a.auditId, {
checkedDomain: a.checkedDomain,
checkedSubpages: a.checkedSubpages,
screenshots: {
desktop: a.desktopStorageId,
mobile: a.mobileStorageId,
},
pageSpeedRaw: a.pageSpeedRaw,
cheerioFindings: a.cheerioFindings,
jinaMarkdown: a.jinaMarkdown,
usedSkills: r.usedSkills ?? [],
skillOutputs: r.findings ?? [],
multimodalAnalysis: r.findings ?? [],
finalSummary: r.finalSummary ?? "",
publicAuditText: r.publicAuditText ?? "",
ctaType: r.ctaType,
// bleibt "draft" — Freigabe ist manuell (Versand-Gate)
});
// Outreach-Entwurf anlegen (pending), Versand erst nach Freigabe.
await ctx.db.insert("outreach", {
orgId: a.orgId,
leadId: a.leadId,
auditId: a.auditId,
contactStrategy: "email_direct",
emailSubject: r.emailSubject,
emailBody: r.emailBody,
phoneScript: r.phoneScript,
approvalStatus: "pending",
replyStatus: "none",
});
await ctx.db.patch(a.leadId, { contactStatus: "audited" });
await ctx.db.insert("usageEvents", {
orgId: a.orgId,
type: "audit",
auditId: a.auditId,
leadId: a.leadId,
costEstimateUsd: a.costUsd,
tokensIn: a.tokensIn,
tokensOut: a.tokensOut,
callCounts: { screenshotone: 2, jina: 1, pagespeed: 1 },
});
},
});
// ---- Pipeline-Actions --------------------------------------------------------
export const runSingleAudit = internalAction({
args: { orgId: v.id("organizations"), leadId: v.id("leads") },
handler: async (ctx, { orgId, leadId }) => {
const c = await ctx.runQuery(internal.audits.getAuditContext, { orgId, leadId });
if (!c) throw new Error("Audit-Kontext nicht gefunden");
if (c.killSwitch) throw new Error("Kill-Switch aktiv");
if (!c.lead.domain) throw new Error("Lead ohne Domain");
if (!c.screenshotoneCipher || !c.openrouterCipher) {
throw new Error("Screenshot-/LLM-Key fehlt");
}
const { auditId, alreadyExisted } = await ctx.runMutation(
internal.audits.createAuditDraft,
{ orgId, leadId },
);
if (alreadyExisted) return; // schon auditiert
const url = normalizeUrl(c.lead.domain);
const screenshotKey = await decryptSecret(c.screenshotoneCipher);
const openRouterKey = await decryptSecret(c.openrouterCipher);
// 1) Struktur-Check (Roh-HTML)
let structural: any = null;
try {
const html = await (await fetch(url)).text();
structural = parseStructuralSignals(html, url);
} catch {
/* nicht erreichbar → Struktur bleibt null */
}
// 2) Screenshots → Convex File Storage
const desktopBytes = await takeScreenshot({ accessKey: screenshotKey, url, device: "desktop" });
const mobileBytes = await takeScreenshot({ accessKey: screenshotKey, url, device: "mobile" });
const desktopStorageId = await ctx.storage.store(
new Blob([desktopBytes], { type: "image/png" }),
);
const mobileStorageId = await ctx.storage.store(
new Blob([mobileBytes], { type: "image/png" }),
);
// 3) Copy als Markdown
const { markdown, pages } = await fetchSiteMarkdown({ baseUrl: url });
// 4) PageSpeed (optional, nur mit Google-Key)
let pageSpeed: any = null;
if (c.googleCipher) {
try {
const googleKey = await decryptSecret(c.googleCipher);
pageSpeed = await fetchPageSpeed({ googleKey, url });
} catch {
/* PageSpeed optional */
}
}
// 5) Multimodale Auswertung
const textContext = [
`Unternehmen: ${c.lead.companyName}`,
`Ort: ${c.lead.ort ?? c.lead.address ?? "unbekannt"}`,
`Branche: ${c.lead.niche ?? "unbekannt"}`,
`URL: ${url}`,
"",
`STRUKTUR-SIGNALE: ${JSON.stringify(structural)}`,
`PAGESPEED (mobil): ${JSON.stringify(pageSpeed)}`,
"",
"WEBSITE-INHALT (Markdown):",
markdown,
].join("\n");
const { json, inputTokens, outputTokens } = await runMultimodalAnalysis({
openRouterKey,
systemPrompt: buildSystemPrompt(SKILLS_REGISTRY),
textContext,
desktopDataUrl: toDataUrl(desktopBytes),
mobileDataUrl: toDataUrl(mobileBytes),
});
const costUsd = estimateAuditCostUsd({ inputTokens, outputTokens, screenshots: 2 });
await ctx.runMutation(internal.audits.saveAuditResult, {
orgId,
auditId,
leadId,
checkedDomain: url,
checkedSubpages: pages,
desktopStorageId,
mobileStorageId,
pageSpeedRaw: pageSpeed,
cheerioFindings: structural,
jinaMarkdown: markdown,
result: json,
costUsd,
tokensIn: inputTokens,
tokensOut: outputTokens,
});
},
});
/** Vom campaigns.ts-Hook aufgerufen: höchstpriorisierte neue Leads auditieren. */
export const runAuditBatch = internalAction({
args: {
orgId: v.id("organizations"),
campaignId: v.id("campaigns"),
limit: v.number(),
},
handler: async (ctx, { orgId, campaignId, limit }) => {
const leadIds = await ctx.runQuery(internal.audits.getLeadsForAudit, {
orgId,
campaignId,
limit,
});
// Sequenziell — schont Rate-Limits und hält die Kosten kalkulierbar.
for (const leadId of leadIds) {
try {
await ctx.runAction(internal.audits.runSingleAudit, { orgId, leadId });
} catch (err) {
console.error(`Audit fehlgeschlagen für ${leadId}:`, err);
}
}
},
});
// ---- Freigabe-Gate (manuell) -------------------------------------------------
async function requireOwnedAudit(ctx: any, auditId: Id<"audits">) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Nicht authentifiziert");
const user = await ctx.db
.query("users")
.withIndex("by_auth", (q: any) => q.eq("authRef", identity.subject))
.first();
const audit = await ctx.db.get(auditId);
if (!user || !audit || audit.orgId !== user.orgId) {
throw new Error("Audit nicht gefunden oder nicht berechtigt");
}
return audit;
}
export const approveAudit = mutation({
args: { auditId: v.id("audits") },
handler: async (ctx, { auditId }) => {
await requireOwnedAudit(ctx, auditId);
await ctx.db.patch(auditId, { status: "approved" });
},
});
export const publishAudit = mutation({
args: { auditId: v.id("audits") },
handler: async (ctx, { auditId }) => {
const audit = await requireOwnedAudit(ctx, auditId);
if (audit.status !== "approved") throw new Error("Erst freigeben");
await ctx.db.patch(auditId, { status: "published", publishedAt: Date.now() });
},
});
// ---- Öffentliche Audit-Seite -------------------------------------------------
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, { slug }) => {
const audit = await ctx.db
.query("audits")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
if (!audit) return null;
// Nicht freigegeben → extern nur Hinweis, keine Inhalte.
if (audit.status !== "published") {
return { status: audit.status, notReady: true as const };
}
const lead = await ctx.db.get(audit.leadId);
return {
status: audit.status,
notReady: false as const,
companyName: lead?.companyName ?? "",
publicAuditText: audit.publicAuditText ?? "",
ctaType: audit.ctaType ?? "anruf",
desktopUrl: audit.screenshots?.desktop
? await ctx.storage.getUrl(audit.screenshots.desktop)
: null,
mobileUrl: audit.screenshots?.mobile
? await ctx.storage.getUrl(audit.screenshots.mobile)
: null,
};
},
});
// ---- Lifecycle (von crons.ts, täglich) ---------------------------------------
const DAY = 24 * 60 * 60 * 1000;
export const processLifecycle = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
// Hinweis: bei Volumen einen dedizierten by_status-Index ergänzen.
const published = await ctx.db
.query("audits")
.filter((q) => q.eq(q.field("status"), "published"))
.collect();
for (const a of published) {
if (!a.publishedAt) continue;
const age = now - a.publishedAt;
if (age >= 60 * DAY) {
await ctx.db.patch(a._id, { status: "expired", deactivatedAt: now });
} else if (age >= 30 * DAY && !a.noticeSentAt) {
await ctx.db.patch(a._id, { noticeSentAt: now }); // Dashboard-Hinweis
}
}
},
});

View File

@@ -0,0 +1,286 @@
/**
* convex/campaigns.ts
*
* Orchestrierung eines Kampagnenlaufs: Places-Suche → Dedup → Lead-Anlage.
*
* Aufbau (Convex-Pattern):
* - Mutations/Queries: deterministisch, DB-Zugriff, KEIN fetch.
* - Actions: dürfen fetch (Google Places) + Secrets entschlüsseln, aber
* greifen auf die DB nur über runQuery/runMutation zu.
*
* Mandanten-Invariante: jeder DB-Zugriff ist orgId-gescoped.
*/
import { v } from "convex/values";
import {
mutation,
internalAction,
internalMutation,
internalQuery,
} from "./_generated/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { searchPlacesPage, leadLookupCostUsd } from "./lib/googlePlaces";
import { decryptSecret } from "./lib/crypto";
// ---- Auth-Helfer -------------------------------------------------------------
/** Org des Aufrufers ermitteln und sicherstellen, dass die Kampagne dazugehört. */
async function requireOwnedCampaign(
ctx: { auth: any; db: any },
campaignId: Id<"campaigns">,
) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Nicht authentifiziert");
const user = await ctx.db
.query("users")
.withIndex("by_auth", (q: any) => q.eq("authRef", identity.subject))
.first();
if (!user) throw new Error("Kein Nutzer-/Org-Kontext");
const campaign = await ctx.db.get(campaignId);
if (!campaign || campaign.orgId !== user.orgId) {
throw new Error("Kampagne nicht gefunden oder nicht berechtigt");
}
return { orgId: user.orgId as Id<"organizations">, campaign };
}
// ---- Öffentlicher Trigger ("Jetzt ausführen") --------------------------------
export const triggerCampaignRun = mutation({
args: { campaignId: v.id("campaigns") },
handler: async (ctx, { campaignId }) => {
const { orgId } = await requireOwnedCampaign(ctx, campaignId);
// Nur EIN aktiver Lauf pro Org gleichzeitig.
const running = await ctx.db
.query("runLogs")
.withIndex("by_org", (q) => q.eq("orgId", orgId))
.filter((q) => q.eq(q.field("status"), "running"))
.first();
if (running) throw new Error("Es läuft bereits ein Kampagnenlauf");
const runLogId = await ctx.db.insert("runLogs", {
orgId,
campaignId,
status: "running",
startedAt: Date.now(),
});
await ctx.scheduler.runAfter(0, internal.campaigns.runCampaign, {
orgId,
campaignId,
runLogId,
});
return runLogId;
},
});
// ---- Interne Helfer (DB) -----------------------------------------------------
export const getCampaignForRun = internalQuery({
args: { orgId: v.id("organizations"), campaignId: v.id("campaigns") },
handler: async (ctx, { orgId, campaignId }) => {
const campaign = await ctx.db.get(campaignId);
if (!campaign || campaign.orgId !== orgId) return null;
const org = await ctx.db.get(orgId);
const keyEntry = await ctx.db
.query("apiKeyVault")
.withIndex("by_org_provider", (q) =>
q.eq("orgId", orgId).eq("provider", "google"),
)
.first();
return {
campaign,
killSwitch: org?.killSwitch ?? false,
budgetCapUsdMonthly: org?.budgetCapUsdMonthly,
googleKeyMode: keyEntry?.mode ?? null,
googleKeyCipher: keyEntry?.encryptedKey ?? null,
};
},
});
/** Monatsverbrauch summieren und gegen das Budget prüfen. */
export const checkMonthlyBudget = internalQuery({
args: { orgId: v.id("organizations"), capUsd: v.optional(v.number()) },
handler: async (ctx, { orgId, capUsd }) => {
if (capUsd === undefined) return { ok: true, spentUsd: 0 };
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const events = await ctx.db
.query("usageEvents")
.withIndex("by_org", (q) => q.eq("orgId", orgId))
.filter((q) => q.gte(q.field("_creationTime"), monthStart.getTime()))
.collect();
const spentUsd = events.reduce((s, e) => s + e.costEstimateUsd, 0);
return { ok: spentUsd < capUsd, spentUsd };
},
});
export const finishRunLog = internalMutation({
args: {
runLogId: v.id("runLogs"),
campaignId: v.id("campaigns"),
status: v.union(
v.literal("completed"),
v.literal("failed"),
v.literal("canceled"),
),
leadsFound: v.optional(v.number()),
googleError: v.optional(v.string()),
},
handler: async (ctx, a) => {
await ctx.db.patch(a.runLogId, {
status: a.status,
leadsFound: a.leadsFound,
finishedAt: Date.now(),
...(a.googleError ? { errorByService: { google: a.googleError } } : {}),
});
await ctx.db.patch(a.campaignId, { lastRunAt: Date.now() });
},
});
// ---- Die eigentliche Recherche-Action ----------------------------------------
export const runCampaign = internalAction({
args: {
orgId: v.id("organizations"),
campaignId: v.id("campaigns"),
runLogId: v.id("runLogs"),
},
handler: async (ctx, { orgId, campaignId, runLogId }) => {
let leadsFound = 0;
try {
const ctxData = await ctx.runQuery(internal.campaigns.getCampaignForRun, {
orgId,
campaignId,
});
if (!ctxData) throw new Error("Kampagne nicht gefunden");
const { campaign, killSwitch, budgetCapUsdMonthly } = ctxData;
if (killSwitch) throw new Error("Kill-Switch aktiv — Lauf abgebrochen");
if (!campaign.coordinates) throw new Error("Kampagne ohne Koordinaten");
// Budget-Cap prüfen
const budget = await ctx.runQuery(internal.campaigns.checkMonthlyBudget, {
orgId,
capUsd: budgetCapUsdMonthly,
});
if (!budget.ok) throw new Error("Monats-Budget erreicht");
// BYO-Key entschlüsseln (Managed-Modus würde hier den Plattform-Key nutzen)
if (!ctxData.googleKeyCipher) {
throw new Error("Kein Google-API-Key hinterlegt");
}
const apiKey = await decryptSecret(ctxData.googleKeyCipher);
const costPerLead = leadLookupCostUsd();
let pageToken: string | undefined = undefined;
// Seitenweise suchen, bis das Limit erreicht ist oder Google nichts mehr liefert.
while (leadsFound < campaign.maxLeadsPerRun) {
const page = await searchPlacesPage({
apiKey,
textQuery: campaign.niche,
lat: campaign.coordinates.lat,
lng: campaign.coordinates.lng,
radiusMeters: campaign.radiusMeters,
pageToken,
});
for (const place of page.places) {
if (leadsFound >= campaign.maxLeadsPerRun) break;
const result = await ctx.runMutation(
internal.leads.upsertLeadFromPlace,
{
orgId,
campaignId,
niche: campaign.niche,
place: {
placeId: place.placeId,
name: place.name,
address: place.address,
phone: place.phone,
website: place.website,
rating: place.rating,
},
leadCostUsd: costPerLead,
},
);
if (result.status === "inserted") leadsFound++;
// duplicate/blocked werden übersprungen.
}
if (!page.nextPageToken) break;
pageToken = page.nextPageToken;
}
await ctx.runMutation(internal.campaigns.finishRunLog, {
runLogId,
campaignId,
status: "completed",
leadsFound,
});
// Höchstpriorisierte neue Leads an die Audit-Pipeline übergeben
// (eigenständig eingeplant, damit der Recherche-Lauf sauber abschließt).
if (campaign.maxAuditsPerRun > 0 && leadsFound > 0) {
await ctx.scheduler.runAfter(0, internal.audits.runAuditBatch, {
orgId,
campaignId,
limit: campaign.maxAuditsPerRun,
});
}
} catch (err) {
await ctx.runMutation(internal.campaigns.finishRunLog, {
runLogId,
campaignId,
status: "failed",
leadsFound,
googleError: err instanceof Error ? err.message : String(err),
});
}
},
});
// ---- Cron-Dispatcher ---------------------------------------------------------
/** Findet fällige Kampagnen und stößt je einen Lauf an (von crons.ts aufgerufen). */
export const runDueCampaigns = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const due = await ctx.db
.query("campaigns")
.withIndex("by_next_run", (q) => q.lte("nextRunAt", now))
.filter((q) => q.eq(q.field("status"), "active"))
.take(25);
for (const campaign of due) {
// Doppelläufe vermeiden
const running = await ctx.db
.query("runLogs")
.withIndex("by_org", (q) => q.eq("orgId", campaign.orgId))
.filter((q) => q.eq(q.field("status"), "running"))
.first();
if (running) continue;
const runLogId = await ctx.db.insert("runLogs", {
orgId: campaign.orgId,
campaignId: campaign._id,
status: "running",
startedAt: now,
});
await ctx.scheduler.runAfter(0, internal.campaigns.runCampaign, {
orgId: campaign.orgId,
campaignId: campaign._id,
runLogId,
});
// nextRunAt neu setzen (vereinfachte Wochen-Kadenz; rrule später)
await ctx.db.patch(campaign._id, {
nextRunAt: now + 7 * 24 * 60 * 60 * 1000,
});
}
},
});

286
v2_elemente/campaigns.ts Normal file
View File

@@ -0,0 +1,286 @@
/**
* convex/campaigns.ts
*
* Orchestrierung eines Kampagnenlaufs: Places-Suche → Dedup → Lead-Anlage.
*
* Aufbau (Convex-Pattern):
* - Mutations/Queries: deterministisch, DB-Zugriff, KEIN fetch.
* - Actions: dürfen fetch (Google Places) + Secrets entschlüsseln, aber
* greifen auf die DB nur über runQuery/runMutation zu.
*
* Mandanten-Invariante: jeder DB-Zugriff ist orgId-gescoped.
*/
import { v } from "convex/values";
import {
mutation,
internalAction,
internalMutation,
internalQuery,
} from "./_generated/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { searchPlacesPage, leadLookupCostUsd } from "./lib/googlePlaces";
import { decryptSecret } from "./lib/crypto";
// ---- Auth-Helfer -------------------------------------------------------------
/** Org des Aufrufers ermitteln und sicherstellen, dass die Kampagne dazugehört. */
async function requireOwnedCampaign(
ctx: { auth: any; db: any },
campaignId: Id<"campaigns">,
) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Nicht authentifiziert");
const user = await ctx.db
.query("users")
.withIndex("by_auth", (q: any) => q.eq("authRef", identity.subject))
.first();
if (!user) throw new Error("Kein Nutzer-/Org-Kontext");
const campaign = await ctx.db.get(campaignId);
if (!campaign || campaign.orgId !== user.orgId) {
throw new Error("Kampagne nicht gefunden oder nicht berechtigt");
}
return { orgId: user.orgId as Id<"organizations">, campaign };
}
// ---- Öffentlicher Trigger ("Jetzt ausführen") --------------------------------
export const triggerCampaignRun = mutation({
args: { campaignId: v.id("campaigns") },
handler: async (ctx, { campaignId }) => {
const { orgId } = await requireOwnedCampaign(ctx, campaignId);
// Nur EIN aktiver Lauf pro Org gleichzeitig.
const running = await ctx.db
.query("runLogs")
.withIndex("by_org", (q) => q.eq("orgId", orgId))
.filter((q) => q.eq(q.field("status"), "running"))
.first();
if (running) throw new Error("Es läuft bereits ein Kampagnenlauf");
const runLogId = await ctx.db.insert("runLogs", {
orgId,
campaignId,
status: "running",
startedAt: Date.now(),
});
await ctx.scheduler.runAfter(0, internal.campaigns.runCampaign, {
orgId,
campaignId,
runLogId,
});
return runLogId;
},
});
// ---- Interne Helfer (DB) -----------------------------------------------------
export const getCampaignForRun = internalQuery({
args: { orgId: v.id("organizations"), campaignId: v.id("campaigns") },
handler: async (ctx, { orgId, campaignId }) => {
const campaign = await ctx.db.get(campaignId);
if (!campaign || campaign.orgId !== orgId) return null;
const org = await ctx.db.get(orgId);
const keyEntry = await ctx.db
.query("apiKeyVault")
.withIndex("by_org_provider", (q) =>
q.eq("orgId", orgId).eq("provider", "google"),
)
.first();
return {
campaign,
killSwitch: org?.killSwitch ?? false,
budgetCapUsdMonthly: org?.budgetCapUsdMonthly,
googleKeyMode: keyEntry?.mode ?? null,
googleKeyCipher: keyEntry?.encryptedKey ?? null,
};
},
});
/** Monatsverbrauch summieren und gegen das Budget prüfen. */
export const checkMonthlyBudget = internalQuery({
args: { orgId: v.id("organizations"), capUsd: v.optional(v.number()) },
handler: async (ctx, { orgId, capUsd }) => {
if (capUsd === undefined) return { ok: true, spentUsd: 0 };
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const events = await ctx.db
.query("usageEvents")
.withIndex("by_org", (q) => q.eq("orgId", orgId))
.filter((q) => q.gte(q.field("_creationTime"), monthStart.getTime()))
.collect();
const spentUsd = events.reduce((s, e) => s + e.costEstimateUsd, 0);
return { ok: spentUsd < capUsd, spentUsd };
},
});
export const finishRunLog = internalMutation({
args: {
runLogId: v.id("runLogs"),
campaignId: v.id("campaigns"),
status: v.union(
v.literal("completed"),
v.literal("failed"),
v.literal("canceled"),
),
leadsFound: v.optional(v.number()),
googleError: v.optional(v.string()),
},
handler: async (ctx, a) => {
await ctx.db.patch(a.runLogId, {
status: a.status,
leadsFound: a.leadsFound,
finishedAt: Date.now(),
...(a.googleError ? { errorByService: { google: a.googleError } } : {}),
});
await ctx.db.patch(a.campaignId, { lastRunAt: Date.now() });
},
});
// ---- Die eigentliche Recherche-Action ----------------------------------------
export const runCampaign = internalAction({
args: {
orgId: v.id("organizations"),
campaignId: v.id("campaigns"),
runLogId: v.id("runLogs"),
},
handler: async (ctx, { orgId, campaignId, runLogId }) => {
let leadsFound = 0;
try {
const ctxData = await ctx.runQuery(internal.campaigns.getCampaignForRun, {
orgId,
campaignId,
});
if (!ctxData) throw new Error("Kampagne nicht gefunden");
const { campaign, killSwitch, budgetCapUsdMonthly } = ctxData;
if (killSwitch) throw new Error("Kill-Switch aktiv — Lauf abgebrochen");
if (!campaign.coordinates) throw new Error("Kampagne ohne Koordinaten");
// Budget-Cap prüfen
const budget = await ctx.runQuery(internal.campaigns.checkMonthlyBudget, {
orgId,
capUsd: budgetCapUsdMonthly,
});
if (!budget.ok) throw new Error("Monats-Budget erreicht");
// BYO-Key entschlüsseln (Managed-Modus würde hier den Plattform-Key nutzen)
if (!ctxData.googleKeyCipher) {
throw new Error("Kein Google-API-Key hinterlegt");
}
const apiKey = await decryptSecret(ctxData.googleKeyCipher);
const costPerLead = leadLookupCostUsd();
let pageToken: string | undefined = undefined;
// Seitenweise suchen, bis das Limit erreicht ist oder Google nichts mehr liefert.
while (leadsFound < campaign.maxLeadsPerRun) {
const page = await searchPlacesPage({
apiKey,
textQuery: campaign.niche,
lat: campaign.coordinates.lat,
lng: campaign.coordinates.lng,
radiusMeters: campaign.radiusMeters,
pageToken,
});
for (const place of page.places) {
if (leadsFound >= campaign.maxLeadsPerRun) break;
const result = await ctx.runMutation(
internal.leads.upsertLeadFromPlace,
{
orgId,
campaignId,
niche: campaign.niche,
place: {
placeId: place.placeId,
name: place.name,
address: place.address,
phone: place.phone,
website: place.website,
rating: place.rating,
},
leadCostUsd: costPerLead,
},
);
if (result.status === "inserted") leadsFound++;
// duplicate/blocked werden übersprungen.
}
if (!page.nextPageToken) break;
pageToken = page.nextPageToken;
}
await ctx.runMutation(internal.campaigns.finishRunLog, {
runLogId,
campaignId,
status: "completed",
leadsFound,
});
// Höchstpriorisierte neue Leads an die Audit-Pipeline übergeben
// (eigenständig eingeplant, damit der Recherche-Lauf sauber abschließt).
if (campaign.maxAuditsPerRun > 0 && leadsFound > 0) {
await ctx.scheduler.runAfter(0, internal.audits.runAuditBatch, {
orgId,
campaignId,
limit: campaign.maxAuditsPerRun,
});
}
} catch (err) {
await ctx.runMutation(internal.campaigns.finishRunLog, {
runLogId,
campaignId,
status: "failed",
leadsFound,
googleError: err instanceof Error ? err.message : String(err),
});
}
},
});
// ---- Cron-Dispatcher ---------------------------------------------------------
/** Findet fällige Kampagnen und stößt je einen Lauf an (von crons.ts aufgerufen). */
export const runDueCampaigns = internalMutation({
args: {},
handler: async (ctx) => {
const now = Date.now();
const due = await ctx.db
.query("campaigns")
.withIndex("by_next_run", (q) => q.lte("nextRunAt", now))
.filter((q) => q.eq(q.field("status"), "active"))
.take(25);
for (const campaign of due) {
// Doppelläufe vermeiden
const running = await ctx.db
.query("runLogs")
.withIndex("by_org", (q) => q.eq("orgId", campaign.orgId))
.filter((q) => q.eq(q.field("status"), "running"))
.first();
if (running) continue;
const runLogId = await ctx.db.insert("runLogs", {
orgId: campaign.orgId,
campaignId: campaign._id,
status: "running",
startedAt: now,
});
await ctx.scheduler.runAfter(0, internal.campaigns.runCampaign, {
orgId: campaign.orgId,
campaignId: campaign._id,
runLogId,
});
// nextRunAt neu setzen (vereinfachte Wochen-Kadenz; rrule später)
await ctx.db.patch(campaign._id, {
nextRunAt: now + 7 * 24 * 60 * 60 * 1000,
});
}
},
});

View File

@@ -0,0 +1,26 @@
/**
* convex/crons.ts
*
* Zeitgesteuerte Ausführung. Prüft regelmäßig auf fällige Kampagnen
* (nextRunAt <= now) und stößt je einen Lauf an.
*/
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"fällige Kampagnen ausführen",
{ minutes: 15 },
internal.campaigns.runDueCampaigns,
{},
);
crons.daily(
"audit-lifecycle",
{ hourUTC: 3, minuteUTC: 0 },
internal.audits.processLifecycle,
{},
);
export default crons;

26
v2_elemente/crons.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* convex/crons.ts
*
* Zeitgesteuerte Ausführung. Prüft regelmäßig auf fällige Kampagnen
* (nextRunAt <= now) und stößt je einen Lauf an.
*/
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
crons.interval(
"fällige Kampagnen ausführen",
{ minutes: 15 },
internal.campaigns.runDueCampaigns,
{},
);
crons.daily(
"audit-lifecycle",
{ hourUTC: 3, minuteUTC: 0 },
internal.audits.processLifecycle,
{},
);
export default crons;

52
v2_elemente/crypto.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* convex/lib/crypto.ts
*
* Symmetrische Ver-/Entschlüsselung der hinterlegten Kunden-Secrets
* (BYO-API-Keys, SMTP-Passwörter). Nutzt Web Crypto (AES-GCM).
*
* WICHTIG: Nur aus Convex *Actions* aufrufen — encrypt() braucht
* Zufalls-IV (nicht-deterministisch) und ist daher in Mutations/Queries
* nicht erlaubt. Der Master-Key kommt aus der Umgebung (niemals ins Repo).
*
* Env: SECRET_ENCRYPTION_KEY = base64-kodierter 32-Byte-Schlüssel.
*/
function getKeyMaterial(): Promise<CryptoKey> {
const b64 = process.env.SECRET_ENCRYPTION_KEY;
if (!b64) throw new Error("SECRET_ENCRYPTION_KEY ist nicht gesetzt");
const raw = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, [
"encrypt",
"decrypt",
]);
}
/** Klartext → "ivBase64:cipherBase64". */
export async function encryptSecret(plaintext: string): Promise<string> {
const key = await getKeyMaterial();
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = new TextEncoder().encode(plaintext);
const cipher = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc);
return `${toB64(iv)}:${toB64(new Uint8Array(cipher))}`;
}
/** "ivBase64:cipherBase64" → Klartext. */
export async function decryptSecret(stored: string): Promise<string> {
const key = await getKeyMaterial();
const [ivB64, cipherB64] = stored.split(":");
const iv = fromB64(ivB64);
const cipher = fromB64(cipherB64);
const plain = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
cipher,
);
return new TextDecoder().decode(plain);
}
function toB64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function fromB64(b64: string): Uint8Array {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}

173
v2_elemente/email.ts Normal file
View File

@@ -0,0 +1,173 @@
/**
* convex/lib/email.ts
*
* Reine Helfer für den Outreach-Versand (fetch-basiert, kein Node nötig):
* Pflicht-Footer, HTML-Aufbau, MIME-Erzeugung, OAuth-Token-Refresh sowie
* Versand über Gmail API und Microsoft Graph. SMTP liegt separat in
* outreachNode.ts ("use node" + nodemailer).
*/
// ---- Pflicht-Footer (Compliance) ---------------------------------------------
export type FooterInput = {
requiredFields: string[]; // aus MarketConfig.requiredFooterFields
fromName: string;
fromAddress: string;
senderAddress?: string; // ladungsfähige Anschrift (aus Org-Einstellungen)
imprintUrl?: string;
unsubscribeUrl: string;
};
/** Erzeugt den verpflichtenden Absender-/Abmelde-Block (Text + HTML). */
export function buildFooter(f: FooterInput): { text: string; html: string } {
const lines: string[] = ["—", f.fromName];
if (f.requiredFields.includes("address") && f.senderAddress) {
lines.push(f.senderAddress);
}
lines.push(f.fromAddress);
if (f.requiredFields.includes("imprint_link") && f.imprintUrl) {
lines.push(`Impressum: ${f.imprintUrl}`);
}
lines.push(
`Wenn Sie keine weitere Nachricht von mir möchten, können Sie sich hier abmelden: ${f.unsubscribeUrl}`,
);
const text = lines.join("\n");
const html =
`<hr style="border:none;border-top:1px solid #ddd;margin:24px 0"/>` +
`<div style="font-size:12px;color:#666;line-height:1.5">` +
lines.slice(1).map(escapeHtml).join("<br/>") +
`</div>`;
return { text, html };
}
export function buildHtmlEmail(bodyText: string, footerHtml: string): string {
const body = escapeHtml(bodyText).replace(/\n/g, "<br/>");
return (
`<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;` +
`line-height:1.6;color:#222">${body}${footerHtml}</div>`
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---- MIME / Encoding ---------------------------------------------------------
function utf8ToBase64Url(input: string): string {
const bytes = new TextEncoder().encode(input);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function encodeHeader(value: string): string {
// RFC 2047 für nicht-ASCII-Header (z. B. Betreff mit Umlauten)
return /[^\x00-\x7F]/.test(value)
? `=?UTF-8?B?${btoa(unescape(encodeURIComponent(value)))}?=`
: value;
}
/** RFC-822-Nachricht als base64url (für Gmail users.messages.send). */
export function buildGmailRaw(args: {
fromName: string;
fromAddress: string;
to: string;
subject: string;
html: string;
}): string {
const mime = [
`From: ${encodeHeader(args.fromName)} <${args.fromAddress}>`,
`To: ${args.to}`,
`Subject: ${encodeHeader(args.subject)}`,
"MIME-Version: 1.0",
'Content-Type: text/html; charset="UTF-8"',
"Content-Transfer-Encoding: 8bit",
"",
args.html,
].join("\r\n");
return utf8ToBase64Url(mime);
}
// ---- OAuth-Token-Refresh -----------------------------------------------------
export async function getGmailAccessToken(refreshToken: string): Promise<string> {
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GMAIL_CLIENT_ID ?? "",
client_secret: process.env.GMAIL_CLIENT_SECRET ?? "",
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) throw new Error(`Gmail-Token ${res.status}`);
return (await res.json()).access_token as string;
}
export async function getMsAccessToken(refreshToken: string): Promise<string> {
const res = await fetch(
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.MS_CLIENT_ID ?? "",
client_secret: process.env.MS_CLIENT_SECRET ?? "",
refresh_token: refreshToken,
grant_type: "refresh_token",
scope: "https://graph.microsoft.com/Mail.Send offline_access",
}),
},
);
if (!res.ok) throw new Error(`MS-Token ${res.status}`);
return (await res.json()).access_token as string;
}
// ---- Versand (fetch) ---------------------------------------------------------
export async function sendViaGmail(accessToken: string, raw: string): Promise<void> {
const res = await fetch(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ raw }),
},
);
if (!res.ok) throw new Error(`Gmail send ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
export async function sendViaGraph(args: {
accessToken: string;
to: string;
subject: string;
html: string;
}): Promise<void> {
const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", {
method: "POST",
headers: {
Authorization: `Bearer ${args.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: {
subject: args.subject,
body: { contentType: "HTML", content: args.html },
toRecipients: [{ emailAddress: { address: args.to } }],
},
saveToSentItems: true,
}),
});
if (!res.ok) throw new Error(`Graph send ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
export function domainOf(email: string): string {
return (email.split("@")[1] ?? "").toLowerCase();
}

142
v2_elemente/googlePlaces.ts Normal file
View File

@@ -0,0 +1,142 @@
/**
* convex/lib/googlePlaces.ts
*
* Dünner Client für die Google Places API (New). Wird ausschließlich aus
* Convex *Actions* aufgerufen (Mutations/Queries dürfen kein fetch).
*
* Kostenrelevant: nationalPhoneNumber + websiteUri liegen in der teureren
* Contact-Data-SKU. Field-Mask bewusst schlank halten (siehe Pricing-Modell).
*/
const PLACES_SEARCH_TEXT_URL =
"https://places.googleapis.com/v1/places:searchText";
// Nur die Felder, die wir wirklich brauchen — jedes Feld kostet.
const FIELD_MASK = [
"places.id",
"places.displayName",
"places.formattedAddress",
"places.nationalPhoneNumber",
"places.websiteUri",
"places.rating",
"places.location",
"nextPageToken",
].join(",");
/** Geschätzte Stückkosten — Quelle: PitchFast_Pricing_Modell.xlsx. */
export const COSTS = {
/** Search-Call (~$0,032) amortisiert auf ~20 Ergebnisse. */
leadSearchAmortizedUsd: 0.032 / 20,
/** Place Details (Pro) ~$0,017 + Contact-Data ~$0,003. */
leadDetailsUsd: 0.02,
};
export function leadLookupCostUsd(): number {
return COSTS.leadSearchAmortizedUsd + COSTS.leadDetailsUsd; // ~$0,022
}
export type PlaceResult = {
placeId: string;
name: string;
address?: string;
phone?: string;
website?: string;
domain?: string;
rating?: number;
lat?: number;
lng?: number;
};
type SearchArgs = {
apiKey: string;
textQuery: string; // z. B. "Friseur" oder Freitext-Nische
lat: number;
lng: number;
radiusMeters: number; // Places-Limit: max. 50.000
pageToken?: string;
};
type SearchPage = { places: PlaceResult[]; nextPageToken?: string };
/** Eine Seite Ergebnisse (bis zu 20) holen. */
export async function searchPlacesPage(args: SearchArgs): Promise<SearchPage> {
const body: Record<string, unknown> = {
textQuery: args.textQuery,
languageCode: "de",
regionCode: "DE",
pageSize: 20,
locationBias: {
circle: {
center: { latitude: args.lat, longitude: args.lng },
radius: Math.min(args.radiusMeters, 50000),
},
},
};
if (args.pageToken) body.pageToken = args.pageToken;
const res = await fetch(PLACES_SEARCH_TEXT_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Goog-Api-Key": args.apiKey,
"X-Goog-FieldMask": FIELD_MASK,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Places API ${res.status}: ${text.slice(0, 300)}`);
}
const data = (await res.json()) as {
places?: Array<{
id: string;
displayName?: { text?: string };
formattedAddress?: string;
nationalPhoneNumber?: string;
websiteUri?: string;
rating?: number;
location?: { latitude?: number; longitude?: number };
}>;
nextPageToken?: string;
};
const places: PlaceResult[] = (data.places ?? []).map((p) => ({
placeId: p.id,
name: p.displayName?.text ?? "(ohne Namen)",
address: p.formattedAddress,
phone: p.nationalPhoneNumber,
website: p.websiteUri,
domain: extractDomain(p.websiteUri),
rating: p.rating,
lat: p.location?.latitude,
lng: p.location?.longitude,
}));
return { places, nextPageToken: data.nextPageToken };
}
/** Domain aus einer URL extrahieren (für Dublettenprüfung & Blacklist). */
export function extractDomain(url?: string): string | undefined {
if (!url) return undefined;
try {
return new URL(url).hostname.replace(/^www\./, "").toLowerCase();
} catch {
return undefined;
}
}
/**
* Priorisierungs-Heuristik (Recherchephase, ohne Audit):
* - keine Website = höchstes Potenzial für Webdesign
* - schwache Bewertung trotz Website = mittel
* - sonst niedrig
*/
export function computePriority(
place: PlaceResult,
): "high" | "medium" | "low" {
if (!place.website) return "high";
if (place.rating !== undefined && place.rating < 3.5) return "medium";
return "low";
}

47
v2_elemente/http.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* convex/http.ts
*
* Öffentliche HTTP-Routen. /abmelden ist der frictionless Opt-out aus dem
* Pflicht-Footer jeder Outreach-Mail (UWG/DSGVO).
*/
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
const http = httpRouter();
http.route({
path: "/abmelden",
method: "GET",
handler: httpAction(async (ctx, req) => {
const o = new URL(req.url).searchParams.get("o");
if (o) {
try {
const target = await ctx.runQuery(internal.outreach.getOptOutTarget, {
outreachId: o as Id<"outreach">,
});
if (target) {
await ctx.runMutation(internal.outreach.recordOptOut, {
orgId: target.orgId,
email: target.email,
domain: target.domain,
outreachId: o as Id<"outreach">,
});
}
} catch {
// ungültiger Link → trotzdem freundlich bestätigen
}
}
return new Response(
`<!doctype html><html lang="de"><meta charset="utf-8">
<body style="font-family:Arial,sans-serif;max-width:480px;margin:80px auto;color:#222">
<h1 style="font-size:20px">Sie wurden abgemeldet.</h1>
<p>Sie erhalten keine weitere Nachricht. Entschuldigen Sie die Störung.</p>
</body></html>`,
{ headers: { "Content-Type": "text/html; charset=utf-8" } },
);
}),
});
export default http;

116
v2_elemente/leads.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* convex/leads.ts
*
* Lead-Anlage mit Dubletten- und Blacklist-Prüfung. Die eigentliche
* Recherche-Orchestrierung liegt in campaigns.ts (Action); hier sind die
* deterministischen DB-Operationen.
*/
import { v } from "convex/values";
import { query, internalMutation } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { computePriority, extractDomain } from "./lib/googlePlaces";
const placeArg = v.object({
placeId: v.string(),
name: v.string(),
address: v.optional(v.string()),
phone: v.optional(v.string()),
website: v.optional(v.string()),
rating: v.optional(v.number()),
});
/**
* Legt einen Lead aus einem Places-Ergebnis an — sofern keine Dublette und
* nicht geblockt. Idempotent über (orgId, googlePlaceId).
* Rückgabe: Status, damit der Lauf zählen kann.
*/
export const upsertLeadFromPlace = internalMutation({
args: {
orgId: v.id("organizations"),
campaignId: v.id("campaigns"),
niche: v.string(),
place: placeArg,
leadCostUsd: v.number(),
},
returns: v.object({
status: v.union(
v.literal("inserted"),
v.literal("duplicate"),
v.literal("blocked"),
),
leadId: v.optional(v.id("leads")),
}),
handler: async (ctx, { orgId, campaignId, niche, place, leadCostUsd }) => {
// 1) Dublette? (gleicher Place in dieser Org)
const existing = await ctx.db
.query("leads")
.withIndex("by_org_place", (q) =>
q.eq("orgId", orgId).eq("googlePlaceId", place.placeId),
)
.first();
if (existing) return { status: "duplicate" as const };
// 2) Blacklist? (Place-ID, Domain, Telefon, Firmenname)
const domain = extractDomain(place.website);
const candidates: string[] = [place.placeId, place.name];
if (domain) candidates.push(domain);
if (place.phone) candidates.push(place.phone);
for (const value of candidates) {
const hit = await ctx.db
.query("blacklist")
.withIndex("by_org_value", (q) =>
q.eq("orgId", orgId).eq("value", value),
)
.first();
if (hit) return { status: "blocked" as const };
}
// 3) Anlegen. E-Mail wird hier NICHT geraten — sie wird (falls überhaupt)
// später aus einer öffentlich ausgewiesenen Kontaktadresse gewonnen.
// Ohne Telefon → "Kontakt fehlt".
const contactStatus = place.phone ? "new" : "contact_missing";
const leadId: Id<"leads"> = await ctx.db.insert("leads", {
orgId,
campaignId,
companyName: place.name,
niche,
address: place.address,
googlePlaceId: place.placeId,
source: "google_places",
domain,
phone: place.phone,
email: undefined,
contactPerson: undefined,
priority: computePriority({ ...place, domain }),
contactStatus,
duplicateStatus: "unique",
blocklistStatus: "clear",
googleRating: place.rating, // nur intern
});
// 4) Verbrauch protokollieren (Abrechnung + reale Unit-Economics)
await ctx.db.insert("usageEvents", {
orgId,
type: "lead_lookup",
leadId,
costEstimateUsd: leadCostUsd,
callCounts: { google: 1 },
});
return { status: "inserted" as const, leadId };
},
});
/** Leads einer Kampagne, nach Priorität sortiert (fürs Dashboard). */
export const listByCampaign = query({
args: { campaignId: v.id("campaigns") },
handler: async (ctx, { campaignId }) => {
// Hinweis: orgId-Scoping erfolgt im aufrufenden Auth-Layer; zusätzlich
// sollte hier die Org des Aufrufers gegen campaign.orgId geprüft werden.
return await ctx.db
.query("leads")
.withIndex("by_campaign", (q) => q.eq("campaignId", campaignId))
.collect();
},
});

310
v2_elemente/outreach.ts Normal file
View File

@@ -0,0 +1,310 @@
/**
* convex/outreach.ts
*
* Outreach-Versand über die BYO-Mailbox des Nutzers — erst nach manueller
* Freigabe, mit Suppression-/Blacklist-Prüfung und Pflicht-Footer.
*
* Versand-Gate (architektonisch erzwungen): gesendet wird ausschließlich,
* wenn approvalStatus === "approved" UND der Empfänger nicht gesperrt ist.
* SMTP wird an outreachNode.ts delegiert; Gmail/Graph laufen hier per fetch.
*/
import { v } from "convex/values";
import {
query,
mutation,
internalAction,
internalMutation,
internalQuery,
} from "./_generated/server";
import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import { decryptSecret } from "./lib/crypto";
import {
buildFooter,
buildHtmlEmail,
buildGmailRaw,
getGmailAccessToken,
getMsAccessToken,
sendViaGmail,
sendViaGraph,
domainOf,
} from "./lib/email";
const DEFAULT_FOOTER_FIELDS = ["name", "address", "imprint_link"];
// ---- Auth-Helfer -------------------------------------------------------------
async function requireOwnedOutreach(ctx: any, outreachId: Id<"outreach">) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Nicht authentifiziert");
const user = await ctx.db
.query("users")
.withIndex("by_auth", (q: any) => q.eq("authRef", identity.subject))
.first();
const outreach = await ctx.db.get(outreachId);
if (!user || !outreach || outreach.orgId !== user.orgId) {
throw new Error("Outreach nicht gefunden oder nicht berechtigt");
}
return { user, outreach };
}
// ---- Entwurf bearbeiten & freigeben ------------------------------------------
export const updateDraft = mutation({
args: {
outreachId: v.id("outreach"),
emailSubject: v.optional(v.string()),
emailBody: v.optional(v.string()),
phoneScript: v.optional(v.string()),
contactStrategy: v.optional(
v.union(
v.literal("call_first"),
v.literal("email_direct"),
v.literal("defer"),
v.literal("do_not_contact"),
),
),
},
handler: async (ctx, { outreachId, ...patch }) => {
const { outreach } = await requireOwnedOutreach(ctx, outreachId);
if (outreach.approvalStatus !== "pending") {
throw new Error("Nur Entwürfe sind editierbar");
}
await ctx.db.patch(outreachId, patch);
},
});
export const approveOutreach = mutation({
args: { outreachId: v.id("outreach") },
handler: async (ctx, { outreachId }) => {
const { outreach } = await requireOwnedOutreach(ctx, outreachId);
if (outreach.auditId) {
const audit = await ctx.db.get(outreach.auditId);
if (audit && audit.status === "draft") {
throw new Error("Zugehöriges Audit ist noch nicht freigegeben");
}
}
await ctx.db.patch(outreachId, { approvalStatus: "approved" });
},
});
/** Versand anstoßen — nur für freigegebene Outreach mit gewähltem Postfach. */
export const requestSend = mutation({
args: {
outreachId: v.id("outreach"),
mailboxConnectionId: v.id("mailboxConnections"),
},
handler: async (ctx, { outreachId, mailboxConnectionId }) => {
const { user, outreach } = await requireOwnedOutreach(ctx, outreachId);
if (outreach.approvalStatus !== "approved") {
throw new Error("Erst freigeben");
}
if (outreach.sentAt) throw new Error("Bereits versendet");
const lead = await ctx.db.get(outreach.leadId);
if (!lead?.email) throw new Error("Kein Empfänger (keine E-Mail-Adresse)");
const conn = await ctx.db.get(mailboxConnectionId);
if (!conn || conn.orgId !== user.orgId || conn.status !== "active") {
throw new Error("Postfach nicht verfügbar");
}
const org = await ctx.db.get(user.orgId);
if (org?.killSwitch) throw new Error("Kill-Switch aktiv");
await ctx.db.patch(outreachId, { mailboxConnectionId });
await ctx.scheduler.runAfter(0, internal.outreach.sendApprovedOutreach, {
orgId: user.orgId,
outreachId,
});
},
});
// ---- Interne DB-Helfer -------------------------------------------------------
export const getSendContext = internalQuery({
args: { orgId: v.id("organizations"), outreachId: v.id("outreach") },
handler: async (ctx, { orgId, outreachId }) => {
const outreach = await ctx.db.get(outreachId);
if (!outreach || outreach.orgId !== orgId) return null;
const lead = await ctx.db.get(outreach.leadId);
const org = await ctx.db.get(orgId);
const conn = outreach.mailboxConnectionId
? await ctx.db.get(outreach.mailboxConnectionId)
: null;
const marketConfig = org?.marketConfigId
? await ctx.db.get(org.marketConfigId)
: null;
return {
outreach,
email: lead?.email ?? null,
domain: lead?.email ? domainOf(lead.email) : null,
conn,
requiredFooterFields: marketConfig?.requiredFooterFields ?? DEFAULT_FOOTER_FIELDS,
killSwitch: org?.killSwitch ?? false,
// senderAddress / imprintUrl sollten aus Org-Einstellungen kommen:
senderAddress: undefined as string | undefined,
imprintUrl: undefined as string | undefined,
};
},
});
/** Suppression (Opt-out) UND Blacklist prüfen — gilt für E-Mail und Domain. */
export const isBlockedRecipient = internalQuery({
args: {
orgId: v.id("organizations"),
email: v.string(),
domain: v.string(),
},
handler: async (ctx, { orgId, email, domain }) => {
for (const value of [email.toLowerCase(), domain]) {
const supp = await ctx.db
.query("suppressions")
.withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value))
.first();
if (supp) return { blocked: true, reason: "opt_out" as const };
const bl = await ctx.db
.query("blacklist")
.withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value))
.first();
if (bl) return { blocked: true, reason: "blacklist" as const };
}
return { blocked: false };
},
});
export const markSent = internalMutation({
args: { outreachId: v.id("outreach"), leadId: v.id("leads") },
handler: async (ctx, { outreachId, leadId }) => {
await ctx.db.patch(outreachId, {
sentAt: Date.now(),
salesStatus: "follow_up_planned",
});
await ctx.db.patch(leadId, { contactStatus: "contacted" });
},
});
// ---- Versand-Orchestrator (Action) -------------------------------------------
export const sendApprovedOutreach = internalAction({
args: { orgId: v.id("organizations"), outreachId: v.id("outreach") },
handler: async (ctx, { orgId, outreachId }) => {
const c = await ctx.runQuery(internal.outreach.getSendContext, { orgId, outreachId });
if (!c) throw new Error("Send-Kontext nicht gefunden");
// Gate erneut serverseitig prüfen (Defense in Depth)
if (c.outreach.approvalStatus !== "approved") throw new Error("Nicht freigegeben");
if (c.outreach.sentAt) return; // schon versendet
if (c.killSwitch) throw new Error("Kill-Switch aktiv");
if (!c.email || !c.domain) throw new Error("Kein Empfänger");
if (!c.conn || c.conn.status !== "active") throw new Error("Postfach inaktiv");
// Suppression / Blacklist
const blocked = await ctx.runQuery(internal.outreach.isBlockedRecipient, {
orgId,
email: c.email,
domain: c.domain,
});
if (blocked.blocked) {
console.warn(`Versand blockiert (${blocked.reason}) für ${outreachId}`);
return;
}
// Pflicht-Footer + Inhalt
const unsubscribeUrl = `${process.env.PUBLIC_APP_URL ?? ""}/abmelden?o=${outreachId}`;
const footer = buildFooter({
requiredFields: c.requiredFooterFields,
fromName: c.conn.fromName,
fromAddress: c.conn.fromAddress,
senderAddress: c.senderAddress,
imprintUrl: c.imprintUrl,
unsubscribeUrl,
});
const subject = c.outreach.emailSubject ?? "";
const bodyText = c.outreach.emailBody ?? "";
const html = buildHtmlEmail(bodyText, footer.html);
const text = `${bodyText}\n\n${footer.text}`;
// Versand je nach Provider
if (c.conn.provider === "gmail") {
if (!c.conn.oauthRef) throw new Error("Kein OAuth-Token");
const token = await getGmailAccessToken(await decryptSecret(c.conn.oauthRef));
const raw = buildGmailRaw({
fromName: c.conn.fromName,
fromAddress: c.conn.fromAddress,
to: c.email,
subject,
html,
});
await sendViaGmail(token, raw);
} else if (c.conn.provider === "microsoft") {
if (!c.conn.oauthRef) throw new Error("Kein OAuth-Token");
const token = await getMsAccessToken(await decryptSecret(c.conn.oauthRef));
await sendViaGraph({ accessToken: token, to: c.email, subject, html });
} else if (c.conn.provider === "smtp") {
const cfg = c.conn.smtpConfig;
if (!cfg) throw new Error("Keine SMTP-Konfiguration");
await ctx.runAction(internal.outreachNode.sendViaSmtp, {
host: cfg.host,
port: cfg.port,
secure: cfg.secure,
username: cfg.username,
password: await decryptSecret(cfg.encryptedPassword),
fromName: c.conn.fromName,
fromAddress: c.conn.fromAddress,
to: c.email,
subject,
html,
text,
});
}
await ctx.runMutation(internal.outreach.markSent, {
outreachId,
leadId: c.outreach.leadId,
});
},
});
// ---- Opt-out (von http.ts /abmelden aufgerufen) ------------------------------
export const getOptOutTarget = internalQuery({
args: { outreachId: v.id("outreach") },
handler: async (ctx, { outreachId }) => {
const outreach = await ctx.db.get(outreachId);
if (!outreach) return null;
const lead = await ctx.db.get(outreach.leadId);
if (!lead?.email) return null;
return { orgId: outreach.orgId, email: lead.email, domain: domainOf(lead.email) };
},
});
export const recordOptOut = internalMutation({
args: {
orgId: v.id("organizations"),
email: v.string(),
domain: v.string(),
outreachId: v.optional(v.id("outreach")),
},
handler: async (ctx, { orgId, email, domain, outreachId }) => {
for (const [value, valueType] of [
[email.toLowerCase(), "email"] as const,
[domain, "domain"] as const,
]) {
const existing = await ctx.db
.query("suppressions")
.withIndex("by_org_value", (q) => q.eq("orgId", orgId).eq("value", value))
.first();
if (!existing) {
await ctx.db.insert("suppressions", { orgId, value, valueType, reason: "opt_out" });
}
}
if (outreachId) {
const o = await ctx.db.get(outreachId);
if (o) {
await ctx.db.patch(outreachId, { salesStatus: "do_not_pursue" });
await ctx.db.patch(o.leadId, { contactStatus: "do_not_contact" });
}
}
},
});

View File

@@ -0,0 +1,44 @@
"use node";
/**
* convex/outreachNode.ts
*
* SMTP-Versand. Eigene Datei mit "use node", weil nodemailer (TCP) im
* Standard-Isolate nicht läuft. Dateien mit "use node" dürfen NUR Actions
* enthalten — Queries/Mutations bleiben in outreach.ts.
*
* Voraussetzung: `nodemailer` in package.json.
*/
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import nodemailer from "nodemailer";
export const sendViaSmtp = internalAction({
args: {
host: v.string(),
port: v.number(),
secure: v.boolean(),
username: v.string(),
password: v.string(), // bereits entschlüsselt vom Orchestrator übergeben
fromName: v.string(),
fromAddress: v.string(),
to: v.string(),
subject: v.string(),
html: v.string(),
text: v.string(),
},
handler: async (_ctx, a) => {
const transport = nodemailer.createTransport({
host: a.host,
port: a.port,
secure: a.secure,
auth: { user: a.username, pass: a.password },
});
await transport.sendMail({
from: `"${a.fromName}" <${a.fromAddress}>`,
to: a.to,
subject: a.subject,
text: a.text,
html: a.html,
});
},
});

212
v2_elemente/skills.md Normal file
View File

@@ -0,0 +1,212 @@
# skills.md — Audit-Skill-Registry
Diese Datei steuert, **wie der KI-Auditor eine Website beurteilt**. Jeder Skill ist
eine fokussierte Prüf-Linse mit klarem Einsatzbereich, definierten Eingaben und einer
einheitlichen Ausgabestruktur. Die Registry liegt im Repo, ist versioniert und wird der
Audit-Action als Kontext mitgegeben.
## So nutzt der Agent diese Datei
1. Die Audit-Pipeline sammelt Artefakte: `cheerioFindings`, `jinaMarkdown`,
`pageSpeedRaw`, Screenshots (Desktop/Mobile).
2. Der Agent liest die Registry und wählt **alle Skills, deren `applies_when` erfüllt
ist und deren benötigte `inputs` vorliegen**.
3. Pro Skill erzeugt er Findings im unten definierten Schema.
4. Aus den Findings entstehen zwei Dinge:
- **`finalSummary` (intern):** vollständig, mit `severity` zur Priorisierung.
- **`publicAuditText` (extern):** nur Beobachtung + Kundennutzen, **ohne**
Scores, Ampeln, Severity oder anklagende Sprache (siehe Voice-Regeln).
5. `usedSkills` (Liste der `id`) wird am Audit gespeichert und im Dashboard angezeigt.
## Voice-Regeln (für jeden Skill verbindlich)
- **Beobachtung statt Urteil.** „Die Telefonnummer ist auf dem Smartphone erst nach
Scrollen sichtbar" statt „schlechte mobile UX".
- **In Kundennutzen übersetzen.** Technischer Befund → konkreter Vorteil
(weniger Absprünge, mehr Anfragen, mehr Vertrauen, bessere lokale Auffindbarkeit).
- **Ich-Form, auf Augenhöhe, lokal, respektvoll, ohne Floskeln, ohne Dringlichkeit,
ohne Preise.**
- **Severity ist intern.** Extern nie Zahlen, Noten oder Warnfarben.
## Finding-Schema (Ausgabe jedes Skills)
```yaml
findings:
- skill_id: string # id des erzeugenden Skills
observation: string # neutrale Beobachtung (intern + Basis für extern)
customer_benefit: string # was die Verbesserung dem Betrieb bringt
public_phrasing: string # fertige, voice-konforme Formulierung für die Audit-Seite
severity: 1 | 2 | 3 # INTERN: 1 niedrig … 3 hoch (Priorisierung)
evidence: string # Beleg: "screenshot_mobile" | "markdown:/kontakt" | "pagespeed:LCP" | "dom:title"
applies: boolean # false, wenn der Skill nichts Relevantes gefunden hat
```
## Skill-Format (so wird ein Skill deklariert)
Jeder Skill ist ein `##`-Abschnitt mit YAML-Metablock + Anleitung in Prosa:
```yaml
id: kebab-case-id
title: Lesbarer Titel
applies_when: always | website_exists | has_mobile_screenshot | has_pagespeed
inputs: [desktop_screenshot, mobile_screenshot, markdown, pagespeed, dom]
outputs: findings
```
---
## visual-design
```yaml
id: visual-design
title: Visueller Gesamteindruck & Zeitgemäßheit
applies_when: website_exists
inputs: [desktop_screenshot, mobile_screenshot]
outputs: findings
```
Beurteile den ersten visuellen Eindruck: wirkt der Auftritt zeitgemäß oder veraltet?
Achte auf visuelle Hierarchie, Weißraum, Typografie (Lesbarkeit, Schriftmischung),
Farbkontraste, Bildqualität und Konsistenz. Konkrete Beobachtungen statt
Geschmacksurteilen — z. B. „kleine Schrift mit geringem Zeilenabstand erschwert das
Lesen auf dem Smartphone", nicht „sieht altbacken aus". Kundennutzen: ein moderner,
ruhiger Auftritt schafft Vertrauen, bevor der erste Satz gelesen wird.
## first-impression-clarity
```yaml
id: first-impression-clarity
title: Klarheit über dem Falz
applies_when: website_exists
inputs: [desktop_screenshot, mobile_screenshot, markdown]
outputs: findings
```
Prüfe, ob im sichtbaren Bereich (ohne Scrollen) sofort klar wird: **Was** macht der
Betrieb, **für wen**, **wo**? Fehlt eine klare Überschrift, ein Leistungsversprechen
oder der Ort, muss ein Besucher raten. Kundennutzen: Besucher entscheiden in Sekunden,
ob sie bleiben — Klarheit hält sie auf der Seite.
## contact-conversion
```yaml
id: contact-conversion
title: Kontaktaufnahme & Handlungsaufforderung
applies_when: website_exists
inputs: [mobile_screenshot, markdown, dom]
outputs: findings
```
Wie leicht kann ein Interessent Kontakt aufnehmen? Sind Telefonnummer, E-Mail bzw.
Formular und Öffnungszeiten leicht auffindbar — besonders mobil und ohne langes
Scrollen? Ist die Telefonnummer auf dem Smartphone klickbar (`tel:`)? Gibt es eine
klare nächste Handlung (anrufen, schreiben, Termin)? Kundennutzen: jede
Reibung weniger ist eine Anfrage mehr.
## mobile-usability
```yaml
id: mobile-usability
title: Mobile Nutzbarkeit
applies_when: has_mobile_screenshot
inputs: [mobile_screenshot, pagespeed]
outputs: findings
```
Beurteile die mobile Darstellung: bricht Text oder Layout um, sind Tap-Ziele groß
genug, ist die Schrift ohne Zoom lesbar, verdecken Banner Inhalte? Nutze
PageSpeed-Mobile-Signale ergänzend. Kundennutzen: der Großteil lokaler Suchen passiert
am Handy — hier entscheidet sich, ob aus Interesse eine Anfrage wird.
## trust-signals
```yaml
id: trust-signals
title: Vertrauenssignale & Seriosität
applies_when: website_exists
inputs: [desktop_screenshot, markdown, dom]
outputs: findings
```
Welche Vertrauenssignale sind vorhanden oder fehlen? Echte Fotos statt Stockbilder,
Team/Über-uns, Referenzen oder Bewertungen, vollständiges Impressum, sichtbare
Erreichbarkeit, gültiges HTTPS. Kundennutzen: lokale Kunden beauftragen, wem sie
vertrauen — sichtbare Seriosität senkt die Hemmschwelle.
## conversion-copy
```yaml
id: conversion-copy
title: Texte & Ansprache
applies_when: website_exists
inputs: [markdown]
outputs: findings
```
Sind die Texte klar, nutzenorientiert und auf die Zielgruppe zugeschnitten — oder
generisch, fachsprachlich oder leer? Wird beschrieben, was der Betrieb leistet und
welches Problem er löst? Achte auf Verständlichkeit und Tonalität (Deutsch, lokal).
Kundennutzen: verständliche Texte holen mehr Besucher in eine Anfrage.
## local-seo-basics
```yaml
id: local-seo-basics
title: Lokale Auffindbarkeit (Grundlagen)
applies_when: website_exists
inputs: [dom, markdown]
outputs: findings
```
Prüfe Title-Tag und Meta-Description (vorhanden, aussagekräftig, mit Ort?),
Überschriftenstruktur (genau eine sinnvolle H1?), sowie die Konsistenz von Name,
Adresse, Telefon (NAP) und ob der Ort/Einzugsbereich textlich auftaucht. Nutzt
`cheerioFindings`. Kundennutzen: wer lokal gefunden wird, bekommt Anfragen aus der
Region — ohne Werbebudget.
## performance-experience
```yaml
id: performance-experience
title: Tempo & Ladeerlebnis
applies_when: has_pagespeed
inputs: [pagespeed]
outputs: findings
```
Übersetze PageSpeed-Rohdaten (LCP, CLS, INP, Gesamt-Score) in ein erlebbares Bild,
**ohne** Scores zu nennen. Beispiel: „Auf dem Smartphone erscheinen die ersten Inhalte
spürbar verzögert." Kundennutzen: schnelle Seiten halten Besucher — langsame verlieren
sie, bevor sie etwas gesehen haben.
## accessibility-basics
```yaml
id: accessibility-basics
title: Zugänglichkeit (Grundlagen)
applies_when: website_exists
inputs: [desktop_screenshot, dom]
outputs: findings
```
Niedrigschwellige Barrieren: ausreichende Farbkontraste, lesbare Schriftgrößen,
sinnvolle Alt-Texte bei zentralen Bildern, bedienbare Menüs. Kundennutzen: gut
zugängliche Seiten erreichen mehr Menschen — und wirken professioneller.
---
## Einen neuen Skill hinzufügen
1. Neuen `##`-Abschnitt mit eindeutiger `id` (kebab-case) anlegen.
2. YAML-Metablock ausfüllen: `applies_when`, `inputs`, `outputs`.
3. In der Prosa beschreiben: **woran** der Skill erkennt, was relevant ist, und **wie**
die Befunde voice-konform formuliert werden (Beobachtung + Kundennutzen).
4. Keine Code-Änderung nötig — der Agent liest die Registry zur Laufzeit. Nur wenn ein
neuer `inputs`-Typ gebraucht wird, muss die Pipeline dieses Artefakt liefern.
## Reihenfolge & Priorisierung
Der Agent gewichtet Findings nach interner `severity` und Relevanz für lokale
Dienstleister. Für den öffentlichen Audit-Text gilt: **wenige, konkrete, freundlich
formulierte Beobachtungen** schlagen eine lange Mängelliste. Ziel ist ein Gespräch,
keine Abrechnung.