From e9463e8ef2d7a4d78023dd7bae526005a53eab14 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 6 Jun 2026 18:14:27 +0200 Subject: [PATCH] Surface audit generations on dashboard audits --- ...e-audit-generations-on-dashboard-audits.md | 42 ++ components/audits/audits-board.tsx | 125 ++++-- convex/audits.ts | 159 ++++++- tests/audit-skills-ui.test.ts | 24 +- tests/audits-dashboard-query-source.test.ts | 124 ++++++ v2_elemente/PitchFast_PRD_v3.md | 381 ++++++++++++++++ v2_elemente/audit.ts | 225 ++++++++++ v2_elemente/audits.ts | 419 ++++++++++++++++++ v2_elemente/campaigns Kopie.ts | 286 ++++++++++++ v2_elemente/campaigns.ts | 286 ++++++++++++ v2_elemente/crons Kopie.ts | 26 ++ v2_elemente/crons.ts | 26 ++ v2_elemente/crypto.ts | 52 +++ v2_elemente/email.ts | 173 ++++++++ v2_elemente/googlePlaces.ts | 142 ++++++ v2_elemente/http.ts | 47 ++ v2_elemente/leads.ts | 116 +++++ v2_elemente/outreach.ts | 310 +++++++++++++ v2_elemente/outreachNode.ts | 44 ++ v2_elemente/skills.md | 212 +++++++++ 20 files changed, 3181 insertions(+), 38 deletions(-) create mode 100644 backlog/tasks/task-29 - Surface-audit-generations-on-dashboard-audits.md create mode 100644 tests/audits-dashboard-query-source.test.ts create mode 100644 v2_elemente/PitchFast_PRD_v3.md create mode 100644 v2_elemente/audit.ts create mode 100644 v2_elemente/audits.ts create mode 100644 v2_elemente/campaigns Kopie.ts create mode 100644 v2_elemente/campaigns.ts create mode 100644 v2_elemente/crons Kopie.ts create mode 100644 v2_elemente/crons.ts create mode 100644 v2_elemente/crypto.ts create mode 100644 v2_elemente/email.ts create mode 100644 v2_elemente/googlePlaces.ts create mode 100644 v2_elemente/http.ts create mode 100644 v2_elemente/leads.ts create mode 100644 v2_elemente/outreach.ts create mode 100644 v2_elemente/outreachNode.ts create mode 100644 v2_elemente/skills.md diff --git a/backlog/tasks/task-29 - Surface-audit-generations-on-dashboard-audits.md b/backlog/tasks/task-29 - Surface-audit-generations-on-dashboard-audits.md new file mode 100644 index 0000000..8c7b3fc --- /dev/null +++ b/backlog/tasks/task-29 - Surface-audit-generations-on-dashboard-audits.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + diff --git a/components/audits/audits-board.tsx b/components/audits/audits-board.tsx index 3e602b6..69929e0 100644 --- a/components/audits/audits-board.tsx +++ b/components/audits/audits-board.tsx @@ -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; -type AuditRow = NonNullable[number]; +type AuditDashboardRowsResult = FunctionReturnType; +type AuditRow = Extract[number], { kind: "audit" }>; +type AuditDashboardRow = NonNullable[number]; const statusText: Record = { draft: "Entwurf", @@ -23,14 +24,48 @@ const statusText: Record = { const fallbackStatus = "Unbekannt"; -function formatPageCount(pages: AuditRow["checkedPages"]) { - return `${pages.length} Seite${pages.length === 1 ? "" : "n"}`; +const generationStageText: Record = { + 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, +) { + 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 (
@@ -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 ; } @@ -75,8 +110,8 @@ export function AuditsBoard() {

Noch keine Audits

- 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.

@@ -90,40 +125,70 @@ export function AuditsBoard() {

Audits

-
-
+
+
Slug Domain Status - Seitenanzahl + Seiten/Phase Aktion
- {rows.map((audit: AuditRow) => ( + {rows.map((row: AuditDashboardRow) => (
-

{audit.slug}

+

{row.title}

+ {row.kind === "generation" ? ( +

+ Run {row.runId} +

+ ) : null}
-

{audit.checkedDomain}

- {getStatusLabel(audit.status)} +

{row.checkedDomain}

+ + {row.kind === "audit" + ? getStatusLabel(row.status) + : getGenerationStatusLabel(row)} +

- - {formatPageCount(audit.checkedPages)} + {row.kind === "audit" ? ( + <> + + {formatPageCount(row.pageCount)} + + ) : ( + <> + + {getStageLabel(row.latestStage)} + + )} + {row.kind === "generation" && row.errorSummary ? ( + + {row.errorSummary} + + ) : null}

- - - Öffnen - + {row.kind === "audit" ? ( + + + Öffnen + + ) : ( + Pipeline + )}
))} diff --git a/convex/audits.ts b/convex/audits.ts index c903080..1fa7f98 100644 --- a/convex/audits.ts +++ b/convex/audits.ts @@ -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, "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 => { + await requireOperator(ctx); + + const limit = normalizeListLimit(args.limit); + const audits = await ctx.db.query("audits").order("desc").take(limit); + + const finalAuditLeadIds = new Set(); + const finalAuditRunIds = new Set(); + const finalAuditIds = new Set(); + 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); + }, +}); diff --git a/tests/audit-skills-ui.test.ts b/tests/audit-skills-ui.test.ts index 612df85..6e63168 100644 --- a/tests/audit-skills-ui.test.ts +++ b/tests/audit-skills-ui.test.ts @@ -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.", ); }); diff --git a/tests/audits-dashboard-query-source.test.ts b/tests/audits-dashboard-query-source.test.ts new file mode 100644 index 0000000..48e29f1 --- /dev/null +++ b/tests/audits-dashboard-query-source.test.ts @@ -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.", + ); +}); diff --git a/v2_elemente/PitchFast_PRD_v3.md b/v2_elemente/PitchFast_PRD_v3.md new file mode 100644 index 0000000..d324b18 --- /dev/null +++ b/v2_elemente/PitchFast_PRD_v3.md @@ -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 | ~15–20k Input-Token (Markdown 4 Seiten + 2 Screenshots als Vision-Input + skills.md) + ~3–5k Output inkl. Reasoning | **~$0,025–0,035** | +| Screenshots (ScreenshotOne ~$0,0085/Shot) | Desktop + Mobile = 2 Shots | **~$0,017** | +| Copy-Extraktion (Jina AI Reader) | 4 Seiten, token-/anfragebasiert | **~$0,002–0,005** | +| PageSpeed Insights | kostenlose Quota | **$0,00** | +| **Summe pro Audit** | | **~$0,045–0,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,02–0,025** | + +**Realistische Gesamtkosten „Lead gefunden + auditiert + Outreach entworfen": ~$0,07–0,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 (~98–100 %). + +**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//`. +- 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,05–0,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. diff --git a/v2_elemente/audit.ts b/v2_elemente/audit.ts new file mode 100644 index 0000000..58da202 --- /dev/null +++ b/v2_elemente/audit.ts @@ -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 { + 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 = { "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 { + 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>/i) || null, + metaDescription: + m(/]+name=["']description["'][^>]+content=["']([^"']*)["']/i) || + m(/]+content=["']([^"']*)["'][^>]+name=["']description["']/i) || + null, + h1Count: (html.match(/]/gi) ?? []).length, + hasViewport: /]+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, + }; +} diff --git a/v2_elemente/audits.ts b/v2_elemente/audits.ts new file mode 100644 index 0000000..ed3e545 --- /dev/null +++ b/v2_elemente/audits.ts @@ -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 + } + } + }, +}); diff --git a/v2_elemente/campaigns Kopie.ts b/v2_elemente/campaigns Kopie.ts new file mode 100644 index 0000000..051ca6a --- /dev/null +++ b/v2_elemente/campaigns Kopie.ts @@ -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, + }); + } + }, +}); diff --git a/v2_elemente/campaigns.ts b/v2_elemente/campaigns.ts new file mode 100644 index 0000000..051ca6a --- /dev/null +++ b/v2_elemente/campaigns.ts @@ -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, + }); + } + }, +}); diff --git a/v2_elemente/crons Kopie.ts b/v2_elemente/crons Kopie.ts new file mode 100644 index 0000000..8d9f668 --- /dev/null +++ b/v2_elemente/crons Kopie.ts @@ -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; diff --git a/v2_elemente/crons.ts b/v2_elemente/crons.ts new file mode 100644 index 0000000..8d9f668 --- /dev/null +++ b/v2_elemente/crons.ts @@ -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; diff --git a/v2_elemente/crypto.ts b/v2_elemente/crypto.ts new file mode 100644 index 0000000..f8b8c48 --- /dev/null +++ b/v2_elemente/crypto.ts @@ -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 { + 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 { + 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 { + 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)); +} diff --git a/v2_elemente/email.ts b/v2_elemente/email.ts new file mode 100644 index 0000000..209877e --- /dev/null +++ b/v2_elemente/email.ts @@ -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 = + `
` + + `
` + + lines.slice(1).map(escapeHtml).join("
") + + `
`; + return { text, html }; +} + +export function buildHtmlEmail(bodyText: string, footerHtml: string): string { + const body = escapeHtml(bodyText).replace(/\n/g, "
"); + return ( + `
${body}${footerHtml}
` + ); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """); +} + +// ---- 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/v2_elemente/googlePlaces.ts b/v2_elemente/googlePlaces.ts new file mode 100644 index 0000000..b318c3e --- /dev/null +++ b/v2_elemente/googlePlaces.ts @@ -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 { + const body: Record = { + 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"; +} diff --git a/v2_elemente/http.ts b/v2_elemente/http.ts new file mode 100644 index 0000000..b0e9f60 --- /dev/null +++ b/v2_elemente/http.ts @@ -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( + ` + +

Sie wurden abgemeldet.

+

Sie erhalten keine weitere Nachricht. Entschuldigen Sie die Störung.

+ `, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ); + }), +}); + +export default http; diff --git a/v2_elemente/leads.ts b/v2_elemente/leads.ts new file mode 100644 index 0000000..9d804ee --- /dev/null +++ b/v2_elemente/leads.ts @@ -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(); + }, +}); diff --git a/v2_elemente/outreach.ts b/v2_elemente/outreach.ts new file mode 100644 index 0000000..8e8b7c0 --- /dev/null +++ b/v2_elemente/outreach.ts @@ -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" }); + } + } + }, +}); diff --git a/v2_elemente/outreachNode.ts b/v2_elemente/outreachNode.ts new file mode 100644 index 0000000..66b9299 --- /dev/null +++ b/v2_elemente/outreachNode.ts @@ -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, + }); + }, +}); diff --git a/v2_elemente/skills.md b/v2_elemente/skills.md new file mode 100644 index 0000000..f3d3fde --- /dev/null +++ b/v2_elemente/skills.md @@ -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.